Evaluation loop
Evaluation loop
Overview
Phases 1-16 prove the mechanics: fake providers, golden streams, race and isolation suites. Nothing yet measures whether a prompt, middleware or model change makes the agent better or worse at real tasks. This phase adds the regression suite for agent behaviour: a fixed task set in the repo, a `hango eval` command that drives every task through real runs on a running server, deterministic checks that score each run, scores and dataset runs pushed to Langfuse against the run's own trace, and a comparison between two runs of the set. It teaches the last thing a harness needs: knowing whether a change helped.Requirements
Functional
- A task set lives in
evals/tasks/as one JSON file per task:id, a shortdescription, the agent spec to create (model,provider,tools,system_prompt, optionalsubagents), the userinput, and a list ofchecks. The set ships with 8-12 tasks that cover the harness features built so far: plain answer, single tool call, tool error recovery, planning withwrite_todos, a file written through the virtual filesystem, a subagent delegation, a long conversation that triggers compaction, and memory read on a second session. - Checks are deterministic and closed:
final_contains(substring),final_matches(regex),tool_called(name, optional minimum count),tool_not_called,file_exists(virtual path),file_contains(path and substring),max_turns,max_cost_micro_cents,run_state(expected terminal state, defaultsucceeded). Each check scores 0 or 1. A task’s score is the mean of its checks, and it passes only when every check scores 1. hango eval run --tasks evals/tasks --label <name> [--repeat N] [--only <id,...>]readsHANGO_EVAL_BASE_URLandHANGO_EVAL_API_KEY(anownerkey in a tenant reserved for evals), then for each task and repetition: upserts the agent by slugeval-<task-id>, creates a session, creates a run, followsGET /v1/runs/{id}/eventsuntil the run is terminal, reads the final messages, the run’strace_id, the files the checks name, and the run’s token and cost totals, and scores the checks.- A run that ends in
awaiting_approvalfails its task with reasonawaiting_approval; eval agents configure nointerrupt_onrules beyond the defaults, so this only happens when a task writes to a reserved prefix. - Results are written to
evals/results/<label>.json(git-ignored): per task and repetition the score, each check’s outcome and reason, run id, trace id, turns, tokens, cost and duration, plus the git commit and the model of each task. - When
LANGFUSE_PUBLIC_KEYandLANGFUSE_SECRET_KEYare set, the command upserts a Langfuse datasethango-evalwith one item per task, links each run’s trace to the dataset run named<label>, and posts one score per check plus atask_scoreon the trace. Langfuse’s trace id is the OTel trace id, so the score lands on the trace the run already produced. With the keys unset, the command logs that Langfuse push is skipped and still writes the results file. hango eval compare <base.json> <candidate.json> [--max-regression 0]prints a table per task (pass rate, mean score, turns, tokens, cost, duration, each as base → candidate with the delta), then the totals, and exits non-zero when the candidate’s overall pass rate falls by more than--max-regressionpercentage points.GET /v1/usageaccepts arun_idfilter so the eval command can read one run’s token and cost totals. The(tenant_id, run_id)index onusage_recordsfrom phase 13 already serves it.
Non-functional
- No new package. The command lives in
cmd/hangonext toprobeandmigrate; it is an HTTP client of hango’s public API plus a small Langfuse HTTP client, so it exercises the same auth, tenancy, audit and telemetry paths a real user does. The package cap stays at 18 plusinternal/tool/builtin. - Standard library only for the client code:
net/http,encoding/json,regexp. No Langfuse SDK. - The command never prints the API key or the Langfuse secret, and the results file never contains prompt bodies beyond the final assistant text the checks read.
- Repeats exist because model output is nondeterministic. The compare output reports pass rate over repetitions, not a single pass or fail, so one lucky or unlucky sample does not decide a change.
Architecture
// cmd/hango/eval_task.go
type evalTask struct {
ID string `json:"id"`
Description string `json:"description"`
Agent json.RawMessage `json:"agent"` // POST /v1/agents body, slug forced to eval-<id>
Input string `json:"input"`
Checks []evalCheck `json:"checks"`
}
type evalCheck struct {
Kind string `json:"kind"` // final_contains|final_matches|tool_called|tool_not_called|file_exists|file_contains|max_turns|max_cost_micro_cents|run_state
Value string `json:"value,omitempty"`
Path string `json:"path,omitempty"`
Min int `json:"min,omitempty"`
}
// cmd/hango/eval_score.go
type checkResult struct {
Kind, Reason string
Score float64 // 0 or 1
}
// observed is everything a check may read about one finished run.
type observed struct {
State, FinalText, TraceID string
ToolCalls map[string]int
Files map[string]string // path -> content, only the paths the checks name
Turns int
CostMicroCents, Tokens int64
Duration time.Duration
}
func score(c evalCheck, o observed) checkResultevals/
tasks/plain-answer.json
tasks/tool-call.json
tasks/tool-error-recovery.json
tasks/plan-with-todos.json
tasks/write-file.json
tasks/delegate-to-subagent.json
tasks/long-conversation-compaction.json
tasks/memory-second-session.json
results/ # git-ignored, one <label>.json per eval runhango eval run --label base
for each task, for each repetition:
upsert agent eval-<id> → POST session → POST run → follow SSE to terminal
read messages, files, usage(run_id), trace_id → score checks
Langfuse: dataset item, dataset run item (trace_id, run name = label), scores on trace_id
write evals/results/base.json
hango eval compare evals/results/base.json evals/results/candidate.jsonInstrumentation
- The eval command sends no telemetry of its own. Every run it creates is a normal hango run, so its
invoke_agenttrace, logs, metrics, audit rows and Langfuse generations already exist; the scores attach to that trace. - The results file stores each run’s
trace_id, so a failed check opens straight into Tempo and Langfuse with the phase-14 runbook.
Related Code Files
Create
cmd/hango/eval.go— theeval runandeval comparesubcommands, flag parsing, the task loop and the results file.cmd/hango/eval_task.go— task and check types, loading and validatingevals/tasks/*.json.cmd/hango/eval_score.go,cmd/hango/eval_score_test.go—scorefor every check kind, table-tested against hand-builtobservedvalues.cmd/hango/eval_client.go— the hango API client: agent upsert, session, run, SSE follow, messages, files, usage.cmd/hango/eval_langfuse.go,cmd/hango/eval_langfuse_test.go— dataset, dataset run item and score calls againsthttptest.Server.cmd/hango/eval_compare.go,cmd/hango/eval_compare_test.go— the comparison table and the regression exit code.evals/tasks/*.json— the task set.docs/learning/17-evaluation-loop.md.
Modify
internal/server/handlers_usage.go,internal/store/pg/usage.go— therun_idfilter onGET /v1/usage..gitignore—evals/results/.Makefile—evalandeval-comparetargets..env.example—HANGO_EVAL_BASE_URL,HANGO_EVAL_API_KEYplaceholders.README.md,docs/learning/README.md,docs/system-architecture.md.
Implementation Steps
- Write
eval_task.go: load every*.jsonin the task directory, reject unknown check kinds, duplicate ids and a missinginput, and sort tasks by id so runs are reproducible. - Write
eval_score.goand its table test first: one case per check kind for both outcomes, plus the empty-checks task (invalid at load time, never scored). - Add the
run_idfilter toGET /v1/usagewith an integration test that two runs in one session report separate totals. - Write
eval_client.go: upsert the agent by slug, create the session and run, follow the SSE stream withLast-Event-IDresume until a terminal state or a per-task timeout (--timeout, default 5m), then read messages, the named files and usage. Count turns and tool calls from the persisted messages, not from the stream, so a resumed stream cannot double-count. - Write
eval.goforeval run: loop tasks and repetitions sequentially, score, write the results file atomically (write to a temp file, then rename), and print a one-line summary per task. - Write
eval_langfuse.go: upsert datasethango-evaland its items, create the dataset run item linking the trace to the run name<label>, post one score per check and atask_score. Test the request shapes againsthttptest.Server. Skip with a log line when the keys are unset. - Write
eval_compare.goand its test: align tasks by id, compute pass rate over repetitions, mean score, and medians for turns, tokens, cost and duration, print the table, and return the exit code from--max-regression. - Write the task set. Start every task from a real phase capability, keep checks deterministic, and give each task a cost ceiling so a runaway loop fails the check instead of draining the budget.
- Run
make eval LABEL=baseagainstdocker compose up, change one thing on purpose (for example remove the planning middleware from the stack or switch the model), runmake eval LABEL=candidate, and record theeval compareoutput and the Langfuse dataset-run comparison in the learning doc. - Update the Makefile,
.gitignore,.env.example, README and docs, and write the learning doc.
Learning Doc Outline
docs/learning/17-evaluation-loop.md must contain:
- Concept: tests prove mechanics, evals measure behaviour. Why a harness change (prompt, middleware order, compaction threshold, model) needs an eval run before and after, and why the task set is versioned with the code.
- Design choices: deterministic checks before model-graded ones, pass rate over repetitions instead of a single sample, cost and turn ceilings as checks, the eval command as an ordinary API client so it goes through auth, tenancy and audit like any user.
- Trade-offs: deterministic checks vs an LLM judge (cheap and stable vs able to grade open-ended answers); a small fixed set vs a large sampled one; offline results file vs Langfuse datasets (both are kept: the file is the CI gate, Langfuse is the place to read traces side by side).
- How Langfuse datasets, dataset runs and scores fit together, and why the OTel trace id is what lets a score land on the run’s existing trace.
- goclaw comparison: what goclaw uses to check agent behaviour, if anything, and where it would plug in.
- deepagents comparison: LangSmith datasets and evaluators as the LangChain answer, and how hango’s Langfuse path maps onto them.
- How to debug this layer: a failed check names its reason and trace id; open the trace in Langfuse to read the generations, then follow the phase-14 runbook. A task that flips between pass and fail across repetitions is a flaky task, not a regression, and the compare table shows it as a pass rate below 100%.
- Follow-ups: an LLM-judge check kind, running a small subset in CI against a recorded provider, and a larger task set built from real failed runs.
- Exercises: add a task for a bug you fixed in an earlier phase; break one middleware and watch which tasks drop; run
--repeat 5on one task and read its pass rate. - Time budget: 8h implementation + 2h learning doc.
Success Criteria
-
go build ./... && go vet ./... && go test ./...pass, and the package list is unchanged (no new package). -
eval_score_test.gocovers every check kind for both outcomes. - Loading a task with an unknown check kind, a duplicate id or no input fails with a message naming the file.
-
GET /v1/usage?run_id=returns one run’s totals; an integration test with two runs in one session shows separate totals. -
make eval LABEL=baseagainstdocker compose upruns every task and writesevals/results/base.jsonwith a score, a trace id and the cost for each task. - With Langfuse keys set, the dataset
hango-evalshows a dataset run namedbase, and each linked trace carries one score per check plustask_score(manual check, recorded in the learning doc). - With Langfuse keys unset,
hango eval runlogs that the push is skipped and still writes the results file. -
hango eval compareprints per-task pass rate, score, turns, tokens, cost and duration deltas, and exits non-zero when the pass rate drops by more than--max-regression(test). - A deliberate regression (a middleware removed or the model changed) shows up as a lower pass rate in
eval compare, recorded in the learning doc. - Neither stdout nor the results file contains the API key or the Langfuse secret (test over a run against
httptest.Server).
Risk Assessment
| Risk | Likelihood x Impact | Signal | Pre-decided response |
|---|---|---|---|
| Nondeterministic model output makes results noisy | Certain x Medium | A task flips between runs with no code change | Report pass rate over --repeat runs; treat a pass rate below 100% as a flaky task to tighten, not a regression |
| Eval runs cost real money | High x Medium | Provider bill grows with every eval | Per-task max_cost_micro_cents checks, the tenant quota from phase 13 on the eval tenant, and --only for iterating on one task |
| Checks pass for the wrong reason | Medium x Medium | A task passes while the trace shows a broken path | Combine an outcome check with a path check (tool_called, file_exists, max_turns) in every task |
| Langfuse public API shape changes | Medium x Low | Score or dataset calls return 4xx | The client is small and tested against httptest.Server; the results file and eval compare do not depend on Langfuse |
| Eval code drifts into a new package | Low x Low | A shared eval package appears | The command stays in cmd/hango; a new package needs the plan.md cap amendment first |
Rollback
The eval command and the task set are additive and live outside the server path. Removing cmd/hango/eval*.go and evals/ returns the binary to phase 16 behaviour. The run_id filter on GET /v1/usage is a backward-compatible query parameter and can stay.