Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .gitattributes
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
gen/go/agentevals/** linguist-generated=true
gen/go/go.sum linguist-generated=true
src/agentevals/api/v1alpha1/** linguist-generated=true
ui/src/generated/** linguist-generated=true
proto/buf.lock linguist-generated=true
35 changes: 35 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,41 @@ jobs:
- name: Build UI
run: make build-ui

- name: Lint UI
working-directory: ui
run: npm run lint

proto:
name: Protobuf Contract Check
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1

- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: '22'

- uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0
with:
go-version-file: gen/go/go.mod
cache-dependency-path: gen/go/go.sum

# proto-breaking compares against the local main branch.
- name: Fetch main
run: git fetch --no-tags --depth=1 origin main:main || true

- name: Lint, regenerate and check for drift
run: make proto-check

- name: Breaking changes
run: make proto-breaking

- name: Build the Go module
working-directory: gen/go
run: |
go build ./...
go vet ./...

docker:
runs-on: ubuntu-latest
permissions:
Expand Down
27 changes: 27 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,33 @@ jobs:
files: dist/**/*.whl
generate_release_notes: true

# Go resolves github.com/agentevals-dev/agentevals/gen/go@vX.Y.Z from the tag gen/go/vX.Y.Z.
tag-go-module:
needs: github-release
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: ${{ github.event.inputs.tag || github.ref_name }}
fetch-depth: 0

- name: Tag the generated Go module
run: |
if ! [[ "$TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+([-+][0-9A-Za-z.-]+)?$ ]]; then
echo "Not a semantic version tag: $TAG"
exit 1
fi
if git rev-parse -q --verify "refs/tags/gen/go/$TAG" >/dev/null; then
echo "gen/go/$TAG already exists"
exit 0
fi
git tag "gen/go/$TAG" "$TAG^{commit}"
git push origin "gen/go/$TAG"
env:
TAG: ${{ github.event.inputs.tag || github.ref_name }}

push-docker:
needs: github-release
runs-on: ubuntu-latest
Expand Down
15 changes: 15 additions & 0 deletions DEVELOPMENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,19 @@ make build-ui # build React app only → ui/dist/

Both `build` and `build-bundle` produce `dist/agentevals-*.whl` with the same package name and version. The difference is that `build-bundle` embeds `ui/dist/` as `agentevals/_static/` inside the wheel. The hatchling `artifacts` config ensures the gitignored `_static/` directory is included.

### API contract

The gRPC API is defined in `proto/`. The generated Go, Python and TypeScript code is committed, so only changes to `.proto` files need these:

```bash
make proto-lint # buf lint
make proto-generate # regenerate gen/go, src/agentevals/api/v1alpha1 and ui/src/generated
make proto-check # lint, regenerate and fail when committed code is out of date (CI)
make proto-breaking # compare with main for breaking changes (CI)
```

`buf` runs through `npx`, so Node is enough. Building the Go module (`cd gen/go && go build ./...`) needs Go, which the Nix dev shell provides.

### Testing

```bash
Expand Down Expand Up @@ -151,6 +164,8 @@ To release a new Nix derivation, update `flake.nix` with the new version and reb
```
4. Alternatively, trigger manually from **GitHub → Actions → Release → Run workflow** and enter the tag

The workflow also tags the same commit `gen/go/vX.Y.Z`, the version Go clients get with `go get github.com/agentevals-dev/agentevals/gen/go@vX.Y.Z`.

The workflow (`.github/workflows/release.yml`) runs `make release`, which builds the wheel twice (once without UI, once with embedded UI) into separate subdirectories:

```
Expand Down
2 changes: 1 addition & 1 deletion Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,6 @@ USER app
ENV PATH="/app/.venv/bin:$PATH"
ENV AGENTEVALS_SERVER_URL=http://127.0.0.1:8001

EXPOSE 8001 4318 4317 8080
EXPOSE 8001 4318 4317 8002 8080

CMD ["agentevals", "serve", "--host", "0.0.0.0", "--port", "8001", "--otlp-http-port", "4318", "--otlp-grpc-port", "4317", "--mcp-port", "8080"]
38 changes: 38 additions & 0 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -143,3 +143,41 @@ helm-package: helm-cleanup
.PHONY: helm-publish
helm-publish: helm-package
helm push "$(HELM_DIST_FOLDER)/agentevals-$(HELM_CHART_VERSION).tgz" "$(HELM_CHART_OCI_URL)"

# Protobuf contract. buf runs through npx so no Go toolchain is needed; regenerating spends
# Buf Schema Registry remote plugin requests, which are rate limited.
BUF ?= npx --yes @bufbuild/buf@1.72.0
PROTO_BREAKING_BRANCH ?= main
PROTO_GENERATED_PATHS := gen/go/agentevals src/agentevals/api/v1alpha1 ui/src/generated

.PHONY: proto-lint
proto-lint:
cd proto && $(BUF) lint

.PHONY: proto-generate
proto-generate:
rm -rf $(PROTO_GENERATED_PATHS)
cd proto && $(BUF) generate

.PHONY: proto-breaking
proto-breaking:
@if git cat-file -e "$(PROTO_BREAKING_BRANCH):proto/buf.yaml" 2>/dev/null; then \
$(BUF) breaking proto --against ".git#branch=$(PROTO_BREAKING_BRANCH),subdir=proto"; \
else \
echo "No protobuf module on $(PROTO_BREAKING_BRANCH); skipping the breaking check"; \
fi

.PHONY: proto-check
proto-check: proto-lint proto-generate
@if test -n "$$(git status --porcelain -- $(PROTO_GENERATED_PATHS))"; then \
echo "Generated protobuf files are out of date:"; \
git status --short -- $(PROTO_GENERATED_PATHS); \
exit 1; \
fi
@# git status skips ignored files, so a .gitignore rule could keep a generated file out of the repo unnoticed.
@ignored="$$(git ls-files --others --ignored --exclude-standard -- $(PROTO_GENERATED_PATHS) | grep -v /__pycache__/)"; \
if test -n "$$ignored"; then \
echo "Generated protobuf files are ignored by a .gitignore rule:"; \
echo "$$ignored"; \
exit 1; \
fi
6 changes: 5 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -175,18 +175,21 @@ evaluators:

**Web UI.** `agentevals serve` on `http://localhost:8001`. Upload traces and eval sets, pick evaluators, inspect span trees, watch live sessions. API docs at `/docs`.

**gRPC API.** `agentevals serve` also serves a gRPC API on port 8002 for programs: sessions, evaluation, runs and trace conversion, with generated clients for Go and Python. See [gRPC API](docs/grpc-api.md).

**MCP server.** `agentevals mcp` exposes `list_metrics`, `evaluate_traces`, `list_sessions`, `summarize_session` and `evaluate_sessions` to MCP clients. A `.mcp.json` at the repo root lets Claude Code pick it up, and the `/eval` and `/inspect` skills in `.claude/skills/` build on it.

**Docker.**

```bash
docker build -t agentevals .
docker run -p 8001:8001 -p 4317:4317 -p 4318:4318 agentevals
docker run -p 8001:8001 -p 8002:8002 -p 4317:4317 -p 4318:4318 agentevals
```

| Port | Purpose |
|------|---------|
| 8001 | Web UI and REST API |
| 8002 | gRPC API |
| 4317 | OTLP gRPC receiver (traces and logs) |
| 4318 | OTLP HTTP receiver (traces and logs) |
| 8080 | MCP (Streamable HTTP) |
Expand Down Expand Up @@ -221,6 +224,7 @@ The chart runs in memory by default. `--set storage.backend=postgres` with a bun
| [Eval Set Format](docs/eval-set-format.md) | Schema and examples for golden eval sets |
| [Custom Evaluators](docs/custom-evaluators.md) | Write scoring logic in any language |
| [Run History](docs/run-history.md) | Persist evaluations to Postgres and trend them in the UI |
| [gRPC API](docs/grpc-api.md) | Services, generated clients, configuration and security of the gRPC API |

## Development

Expand Down
5 changes: 4 additions & 1 deletion charts/agentevals/templates/NOTES.txt
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,11 @@
2. OTLP HTTP receiver: port {{ .Values.service.otlpHttp.port }} (OTEL_EXPORTER_OTLP_ENDPOINT=http://<service>:{{ .Values.service.otlpHttp.port }}).
3. OTLP gRPC receiver: port {{ .Values.service.otlpGrpc.port }} (OTEL_EXPORTER_OTLP_ENDPOINT=<service>:{{ .Values.service.otlpGrpc.port }}, OTEL_EXPORTER_OTLP_PROTOCOL=grpc).
4. MCP (Streamable HTTP): port {{ .Values.service.mcp.port }}, path /mcp (e.g. http://<service>:{{ .Values.service.mcp.port }}/mcp).
{{- if .Values.grpcApi.enabled }}
5. gRPC API (agentevals.api.v1alpha1): port {{ .Values.service.grpcApi.port }} (Service port name: grpc-api). Expose it only to trusted callers.
{{- end }}
{{- if .Values.ephemeralVolume.enabled }}
5. An emptyDir is mounted at /tmp with HOME=/tmp/agentevals-home (ephemeral; lost on pod restart). Set ephemeralVolume.enabled=false and readOnlyRootFilesystem=false if you need a writable root without this mount.
{{ ternary 6 5 .Values.grpcApi.enabled }}. An emptyDir is mounted at /tmp with HOME=/tmp/agentevals-home (ephemeral; lost on pod restart). Set ephemeralVolume.enabled=false and readOnlyRootFilesystem=false if you need a writable root without this mount.
{{- end }}

Get the Service URL:
Expand Down
16 changes: 16 additions & 0 deletions charts/agentevals/templates/deployment.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,12 @@ spec:
env:
- name: AGENTEVALS_SERVER_URL
value: "http://127.0.0.1:{{ .Values.service.http.containerPort }}"
- name: AGENTEVALS_API_GRPC_PORT
value: {{ ternary .Values.service.grpcApi.containerPort 0 .Values.grpcApi.enabled | quote }}
{{- if and .Values.grpcApi.enabled .Values.grpcApi.reflection }}
- name: AGENTEVALS_GRPC_REFLECTION
value: "true"
{{- end }}
{{- if .Values.ephemeralVolume.enabled }}
- name: TMPDIR
value: "/tmp"
Expand Down Expand Up @@ -117,6 +123,11 @@ spec:
- name: mcp
containerPort: {{ .Values.service.mcp.containerPort }}
protocol: TCP
{{- if .Values.grpcApi.enabled }}
- name: grpc-api
containerPort: {{ .Values.service.grpcApi.containerPort }}
protocol: TCP
{{- end }}
resources:
{{- toYaml .Values.resources | nindent 12 }}
securityContext:
Expand All @@ -133,9 +144,14 @@ spec:
periodSeconds: 10
timeoutSeconds: 5
readinessProbe:
{{- if and .Values.grpcApi.enabled .Values.grpcApi.grpcReadinessProbe }}
grpc:
port: {{ .Values.service.grpcApi.containerPort }}
{{- else }}
httpGet:
path: /api/health
port: http
{{- end }}
initialDelaySeconds: 5
periodSeconds: 10
livenessProbe:
Expand Down
7 changes: 7 additions & 0 deletions charts/agentevals/templates/service.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -24,5 +24,12 @@ spec:
port: {{ .Values.service.mcp.port }}
targetPort: mcp
protocol: TCP
{{- if .Values.grpcApi.enabled }}
- name: grpc-api
port: {{ .Values.service.grpcApi.port }}
targetPort: grpc-api
protocol: TCP
appProtocol: grpc
{{- end }}
selector:
{{- include "agentevals.app.selectorLabels" . | nindent 4 }}
18 changes: 18 additions & 0 deletions charts/agentevals/values.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,24 @@ service:
mcp:
port: 8080
containerPort: 8080
# -- gRPC API port (agentevals.api.v1alpha1)
grpcApi:
port: 8002
containerPort: 8002

# ==============================================================================
# gRPC API
# ==============================================================================

grpcApi:
# -- Serve the gRPC API next to the HTTP API. Every caller that can reach the port is
# trusted, so expose it only to trusted callers.
enabled: true
# -- Turn on gRPC server reflection, for tools such as grpcurl
reflection: false
# -- Check readiness with the gRPC health service on the gRPC API port instead of HTTP
# /api/health
grpcReadinessProbe: false

# ==============================================================================
# Resources
Expand Down
71 changes: 71 additions & 0 deletions docs/grpc-api.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
# gRPC API

`agentevals serve` serves a gRPC API on port 8002, next to the web UI and REST API on 8001. It is the API for programs: other services, scripts and CI. It speaks native gRPC over HTTP/2, so browsers cannot call it; the web UI uses the REST API and an SSE stream that carry the same messages as JSON.

The contract lives in [`proto/agentevals/api/v1alpha1`](../proto/agentevals/api/v1alpha1). `v1alpha1` can still change; CI checks every change for compatibility.

## Services

| Service | Methods |
|---------|---------|
| `SessionService` | `ListSessions`, `GetSession`, `WatchSessions` (every current session, then each change) |
| `EvaluationService` | `ListMetrics`, `EvaluateTraces` (streams progress and results) |
| `RunService` | `SubmitRun`, `GetRun`, `ListRuns`, `CancelRun`, `ListRunResults`, `WatchRun`. Needs the Postgres backend, see [Run History](run-history.md) |
| `ConversionService` | `ConvertTraces` (trace files to conversations and a golden eval set) |
| `SystemService` | `GetVersion`, `GetConfig` |

The server also implements the standard `grpc.health.v1.Health` service, so Kubernetes `grpc` probes and load balancers can check it.

Sessions and conversations come back as views: turns with their user text, tool calls paired with their results, model calls, token usage and status, already worked out by the server. Each field names the OpenTelemetry attribute it comes from. A token count that was not recorded is left unset, never reported as 0.

Traces go in as OTLP protobuf (`TracesData` and `LogsData`) or as files (OTLP/JSON, OTLP/JSONL or Jaeger JSON), the same formats the REST API takes.

## Calling it

With reflection on, `grpcurl` can list and call everything:

```bash
AGENTEVALS_GRPC_REFLECTION=true uv run agentevals serve --dev
grpcurl -plaintext localhost:8002 list
grpcurl -plaintext localhost:8002 agentevals.api.v1alpha1.SessionService/ListSessions
grpcurl -plaintext -d '{"includeTurns": true}' localhost:8002 agentevals.api.v1alpha1.SessionService/WatchSessions
```

Reflection is off by default. Without it, pass the protos with `-import-path` and `-proto`.

Generated clients:

* **Go:** `go get github.com/agentevals-dev/agentevals/gen/go@vX.Y.Z`, then import `github.com/agentevals-dev/agentevals/gen/go/agentevals/api/v1alpha1`.
* **Python:** the `agentevals-cli` package ships the stubs as `agentevals.api.v1alpha1`.
* **TypeScript:** generate from the protos with `buf` and `protoc-gen-es`, as `ui/src/generated` does.

Errors use standard gRPC status codes with a `google.rpc.ErrorInfo` detail whose `reason` is stable, for example `STORAGE_NOT_CONFIGURED` or `RUN_SPEC_CONFLICT`. Internal errors return `INTERNAL` with no details; the server log has them.

## Configuration

| Setting | Purpose |
|---------|---------|
| `--api-grpc-port` or `AGENTEVALS_API_GRPC_PORT` | Port, default `8002`; `0` turns the API off |
| `AGENTEVALS_GRPC_REFLECTION=true` | Turn on server reflection |
| `AGENTEVALS_API_TRACES=true` | Export traces of the API server itself (see below) |

Messages can be up to 16 MiB. Uploaded trace documents are limited to 10 MiB each, as in the REST API.

The Helm chart has the same switches under `grpcApi`: `enabled`, `reflection`, and `grpcReadinessProbe` to check readiness with the gRPC health service. The Service exposes the port as `grpc-api`.

## Tracing the API server

agentevals receives telemetry, so it exports none of its own by default. With `AGENTEVALS_API_TRACES=true` it traces every API call with the OpenTelemetry gRPC instrumentation, using the standard exporter variables:

```bash
AGENTEVALS_API_TRACES=true OTEL_EXPORTER_OTLP_ENDPOINT=http://collector:4318 uv run agentevals serve
AGENTEVALS_API_TRACES=true OTEL_TRACES_EXPORTER=console uv run agentevals serve
```

`OTEL_SDK_DISABLED=true` or `OTEL_TRACES_EXPORTER=none` turn it off again. Health checks and reflection are not traced. If these traces are sent to agentevals itself, it drops them, so they never show up as sessions.

## Security

The API has no authentication. Any caller that can reach the port can read sessions, run evaluations with the server's credentials, and submit runs. Expose port 8002 only to trusted callers, for example inside a cluster behind a NetworkPolicy, and put authentication in front of it when you need it.

On a laptop `agentevals serve` listens on all interfaces. Pass `--host 127.0.0.1` to keep every port local.
3 changes: 3 additions & 0 deletions flake.nix
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,9 @@
# NodeJS
pkgs.nodejs_22

# Go, to build and tidy the generated API module in gen/go
pkgs.go

# C++ standard library for numpy
pkgs.stdenv.cc.cc.lib

Expand Down
10 changes: 10 additions & 0 deletions gen/go/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
# agentevals Go API client

Generated Go types and gRPC clients for the `agentevals.api.v1alpha1` API.

```go
import apiv1alpha1 "github.com/agentevals-dev/agentevals/gen/go/agentevals/api/v1alpha1"
```

The code is generated from `proto/` by `make proto-generate` and must not be edited by hand.
Releases are tagged `gen/go/vX.Y.Z`, matching the agentevals version.
Loading
Loading