|
| 1 | +# Agent harness evaluations |
| 2 | + |
| 3 | +Measurement for the agent harness — the code that turns a model's tool calls |
| 4 | +into executed tools, feeds the results back, and keeps the turn alive when a |
| 5 | +tool fails. Unit and integration tests prove the harness handles the cases we |
| 6 | +already know about; evals measure whether it still behaves across a suite of |
| 7 | +scenarios when the harness changes. |
| 8 | + |
| 9 | +## What runs |
| 10 | + |
| 11 | +The first suite lives in [`agent-tool-use/`](./agent-tool-use) and drives the |
| 12 | +real OpenAI-compatible streaming tool loop |
| 13 | +(`apps/sim/providers/openai-compat/streaming-tool-loop.ts`) — the loop that |
| 14 | +serves OpenAI, DeepSeek, Groq, Cerebras, and the other OpenAI-compatible |
| 15 | +providers. The model is **scripted**: each scenario supplies the assistant turns |
| 16 | +(tool calls or a final answer) and the result of each tool call. That keeps the |
| 17 | +suite deterministic and runnable in CI with no provider key, while the thing |
| 18 | +being measured — tool dispatch, result feedback, error recovery — is real |
| 19 | +production code. |
| 20 | + |
| 21 | +The suites cover four behaviors: |
| 22 | + |
| 23 | +| Category | What it measures | |
| 24 | +| --- | --- | |
| 25 | +| `tool-selection` | The loop dispatches the tool the model asked for, including from a set of distractors. | |
| 26 | +| `planning` | Multi-turn, dependent and parallel tool calls execute in the right order and all results reach the next turn. | |
| 27 | +| `retrieval` | Values returned by a tool survive into the final answer instead of being dropped or invented. | |
| 28 | +| `recovery` | Tool errors, unknown tool names, and malformed argument JSON are fed back to the model rather than thrown out of the loop. | |
| 29 | + |
| 30 | +## Run it |
| 31 | + |
| 32 | +From `apps/sim`: |
| 33 | + |
| 34 | +```sh |
| 35 | +bun run test:evals |
| 36 | +``` |
| 37 | + |
| 38 | +The command writes a JSON report and a Markdown summary to |
| 39 | +`test-results/evals/agent-tool-use.{json,md}` (gitignored) and fails the process |
| 40 | +if any scenario fails. To point the report somewhere else, run Vitest directly: |
| 41 | + |
| 42 | +```sh |
| 43 | +EVAL_REPORT_PATH=/tmp/agent-tool-use.json bunx vitest run evals/agent-tool-use |
| 44 | +``` |
| 45 | + |
| 46 | +The suite is also picked up by the normal `bun run test` run, so a regression |
| 47 | +fails CI even without the dedicated command. |
| 48 | + |
| 49 | +## Run against a real model (live) |
| 50 | + |
| 51 | +The same scenarios can be replayed against a live model. This is opt-in and |
| 52 | +never runs in CI. DeepSeek is wired first; any OpenAI-compatible provider works |
| 53 | +through `createOpenAICompatLiveCompletion` in `live.ts`. |
| 54 | + |
| 55 | +```sh |
| 56 | +cd apps/sim |
| 57 | +DEEPSEEK_API_KEY=... bun run test:evals:live |
| 58 | +``` |
| 59 | + |
| 60 | +Useful knobs: |
| 61 | + |
| 62 | +| Variable | Default | Meaning | |
| 63 | +| --- | --- | --- | |
| 64 | +| `EVAL_TRIALS` | `3` | Runs per scenario. Models are nondeterministic, so results are pass rates. | |
| 65 | +| `EVAL_MIN_PASS_RATE` | `0` | When > 0, fail a scenario below this pass rate (0–1). | |
| 66 | +| `EVAL_MODEL` | `deepseek-chat` | Model id sent to the provider. | |
| 67 | +| `EVAL_TIMEOUT_MS` | `180000` | Per-request timeout. | |
| 68 | +| `EVAL_REPORT_PATH` | `test-results/evals/agent-tool-use-live.json` | Report location. | |
| 69 | +| `EVAL_RECORD` | `0` | Set to `1` to also write the first trial's transcript to `fixtures/`. | |
| 70 | + |
| 71 | +Live runs relax exact assertions: `toolCallSequence` becomes an ordered |
| 72 | +subsequence, `successfulToolCalls` becomes a minimum, and scripted-only cases |
| 73 | +(malformed JSON, unknown tool) are skipped. A scenario-level `liveExpect` |
| 74 | +overrides the scripted expectation where a real model cannot reproduce it (for |
| 75 | +example, an exact retry count). The report is at |
| 76 | +`test-results/evals/agent-tool-use-live.{json,md}` with pass rates, average |
| 77 | +iterations, latency, and the failed check names. |
| 78 | + |
| 79 | +### Record and replay |
| 80 | + |
| 81 | +A live run is nondeterministic and needs a key; a fixture is neither. Record one |
| 82 | +trial, then replay it forever through the real loop with no network: |
| 83 | + |
| 84 | +```sh |
| 85 | +cd apps/sim |
| 86 | +EVAL_RECORD=1 DEEPSEEK_API_KEY=... bun run test:evals:live # writes fixtures/*.json |
| 87 | +bun run test:evals # replays them, no key |
| 88 | +``` |
| 89 | + |
| 90 | +`fixtures/<scenario>.json` holds the raw streamed chunks per model call, so a |
| 91 | +diff shows a behavior change exactly as the model produced it. Fixtures are |
| 92 | +committed and reviewed like snapshots. `agent-tool-use.replay.test.ts` replays |
| 93 | +each one through `createOpenAICompatStreamingToolLoopStream` and scores it with |
| 94 | +the same checks; the suite skips until at least one fixture exists. Re-record a |
| 95 | +fixture when the scenario, prompt, or model intentionally changes. |
| 96 | + |
| 97 | +### Compare models |
| 98 | + |
| 99 | +Run the same scenarios across several models and get a scenario × model matrix: |
| 100 | + |
| 101 | +```sh |
| 102 | +cd apps/sim |
| 103 | +EVAL_MODELS=deepseek:deepseek-chat,deepseek:deepseek-reasoner \ |
| 104 | + DEEPSEEK_API_KEY=... bun run test:evals:compare |
| 105 | +``` |
| 106 | + |
| 107 | +A spec is `provider:model`; a bare model id defaults to DeepSeek. Providers are |
| 108 | +DeepSeek, OpenAI, Groq, and OpenRouter, each reading its key from |
| 109 | +`<PROVIDER>_API_KEY`. The report is |
| 110 | +`test-results/evals/agent-tool-use-compare.{json,md}`: per-model pass rate, |
| 111 | +iterations, latency, and tokens, plus a per-scenario pass-rate matrix. |
| 112 | +`EVAL_MIN_PASS_RATE` fails a model below a floor. |
| 113 | + |
| 114 | +### LLM-as-judge |
| 115 | + |
| 116 | +Substring and regex checks measure phrasing, not correctness. `judge.ts` scores |
| 117 | +an answer against a rubric with a judge model and returns structured numbers: |
| 118 | + |
| 119 | +```sh |
| 120 | +cd apps/sim |
| 121 | +DEEPSEEK_API_KEY=... bun run test:evals:judge |
| 122 | +``` |
| 123 | + |
| 124 | +`judgeAnswer` takes the judge transport, the user request, the answer, optional |
| 125 | +tool evidence, and a rubric of weighted criteria, and returns a verdict. Pass a |
| 126 | +`judge` option to `runScenario` to add a `judge` check alongside the |
| 127 | +deterministic ones. The judge transport is an ordinary OpenAI-compatible |
| 128 | +completion, so a recorded transcript can replay it deterministically in CI. |
| 129 | + |
| 130 | +## Add a case |
| 131 | + |
| 132 | +1. Open [`agent-tool-use/scenarios.ts`](./agent-tool-use/scenarios.ts) and add |
| 133 | + an entry to `AGENT_TOOL_USE_SCENARIOS`. |
| 134 | +2. Declare the `tools` the model may call and the `script` it produces. A |
| 135 | + `tools` turn lists the calls the model emits; an `answer` turn ends the run. |
| 136 | + Attach each call's stub `result` (or leave it to default to a successful |
| 137 | + empty output). |
| 138 | +3. Add the assertions you care about under `expect`: the ordered |
| 139 | + `toolCallSequence`, `requiredTools`/`forbiddenTools`, `finalContent`, |
| 140 | + `maxIterations`, and tool call counts. Every assertion becomes a named check |
| 141 | + in the report. |
| 142 | +4. Run `bun run test:evals`. |
| 143 | + |
| 144 | +A scenario is data, not code — there is no harness change needed for a new case. |
| 145 | + |
| 146 | +### Simulating a failure |
| 147 | + |
| 148 | +- **Tool error:** give the call `result: { success: false, error: '...' }`. |
| 149 | +- **Unknown tool:** call a `name` that is not in `tools`; the loop returns a |
| 150 | + tool-not-found error to the model. |
| 151 | +- **Malformed arguments:** set `argumentsJson` to an invalid or non-object JSON |
| 152 | + string. The loop must not execute the call and must return the parse error to |
| 153 | + the model. |
| 154 | + |
| 155 | +## Executor-level scenarios |
| 156 | + |
| 157 | +[`agent-tool-use/executor-harness.ts`](./agent-tool-use/executor-harness.ts) |
| 158 | +runs a case through a real `DAGExecutor`: a Start block → Agent block workflow, |
| 159 | +with only the provider boundary (`executeProviderRequest`) mocked. This covers |
| 160 | +what the loop harness cannot — agent-block input wiring, variable resolution |
| 161 | +from Start outputs, and the executor's run/error handling. Tool dispatch stays |
| 162 | +covered by the loop suite. |
| 163 | + |
| 164 | +Add a case to `EXECUTOR_SCENARIOS` in `executor-harness.ts`: |
| 165 | + |
| 166 | +- `workflowInput` is exposed on the Start block; reference an output with |
| 167 | + `<start.field>` from the Agent prompt. |
| 168 | +- `agent` is the Agent block config (`model`, `systemPrompt`, `userPrompt`). |
| 169 | +- `providerResponse` is what the mocked provider returns (`content`, |
| 170 | + `toolCalls`, `tokens`). |
| 171 | +- `expect` uses the loop's checks plus `resolvedInput` (a substring that must |
| 172 | + reach the provider messages), `succeeds` (expected `ExecutionResult.success`), |
| 173 | + and `providerCalls` (exact provider call count). |
| 174 | +- Set `agent.retry` to exercise the executor's per-block retry policy, or |
| 175 | + `agent.fallbackModels` to exercise model fallback. Make the first |
| 176 | + `providerResponse` a `reject` and the next one succeeds; assert |
| 177 | + `providerCalls` and `lastRequestModel` to prove which path recovered. |
| 178 | + |
| 179 | +Both suites write one report, so executor rows appear alongside loop rows. |
| 180 | + |
| 181 | +## Context evals |
| 182 | + |
| 183 | +[`agent-context/`](./agent-context/) drives the Agent block through the executor |
| 184 | +with conversation memory on. The memory read is stubbed per conversation id, so |
| 185 | +the provider request shows exactly what the handler assembled: prior history, |
| 186 | +then the new user prompt, with the system prompt preserved, and the conversation |
| 187 | +id must match. Windowing inside the memory service (`sliding_window`, token |
| 188 | +budgets) is covered by its unit tests; this suite covers the assembly the model |
| 189 | +sees. |
| 190 | + |
| 191 | +```sh |
| 192 | +cd apps/sim |
| 193 | +bun run test:evals:context # writes test-results/evals/agent-context.{json,md} |
| 194 | +``` |
| 195 | + |
| 196 | +Scenarios live in [`agent-context/scenarios.ts`](./agent-context/scenarios.ts) |
| 197 | +and reuse the executor harness, so a case is the same shape as an executor case |
| 198 | +plus `agent.memory`. |
| 199 | + |
| 200 | +## Report shape |
| 201 | + |
| 202 | +`report.json` is machine-readable for dashboards and trend tracking; `report.md` |
| 203 | +is the same data as a table. Each result carries the scenario id, pass/fail, |
| 204 | +every named check with a failure detail, the final content, the executed tool |
| 205 | +invocations, and metrics: iterations, tool call counts (success/error), latency, |
| 206 | +model/tool time, first-response time, and token usage. |
| 207 | + |
| 208 | +## Scope and next steps |
| 209 | + |
| 210 | +Two harnesses share one result shape and report: the tool loop and the |
| 211 | +`DAGExecutor`. The executor suite covers both recovery paths — block retry |
| 212 | +(`executor-retries-failed-block`) and model fallback |
| 213 | +(`executor-falls-back-to-secondary-model`). Further expansion (context/memory, |
| 214 | +model routing, subagent orchestration) is tracked as follow-up work. |
0 commit comments