Skip to content

Commit 39f1a5a

Browse files
committed
feat(evals): add the agent evaluation suite
Deterministic, CI-runnable evaluation for the agent harness plus opt-in live tooling. Scripted scenarios drive the real OpenAI-compatible streaming tool loop and the DAGExecutor; scenarios cover tool selection, planning, retrieval, recovery, adversarial cases, and conversation-context assembly. Live runs compare models and support record/replay and rubric judging. - agent-tool-use/: 13 loop scenarios + 4 executor scenarios + reports - agent-context/: provider-request assembly with conversation memory - replay.ts / judge.ts / models.ts: record-replay, LLM judge, model registry - test:evals, test:evals:{live,record,context,compare,judge} - rebased onto staging per CONTRIBUTING (PRs target staging, not main)
1 parent 825e7e7 commit 39f1a5a

21 files changed

Lines changed: 3684 additions & 0 deletions

‎apps/sim/evals/README.md‎

Lines changed: 214 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,214 @@
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.
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
import {
2+
permissionCheckMock,
3+
permissionCheckMockFns,
4+
} from '@sim/testing/mocks/permission-check.mock'
5+
import { providersMock } from '@sim/testing/mocks/providers.mock'
6+
import { providersConversationHistoryMock } from '@sim/testing/mocks/providers-conversation-history.mock'
7+
import { providersUtilsMock, providersUtilsMockFns } from '@sim/testing/mocks/providers-utils.mock'
8+
import { toolsMock } from '@sim/testing/mocks/tools.mock'
9+
import { workspaceFileSecretProvenanceMock } from '@sim/testing/mocks/workspace-file-secret-provenance.mock'
10+
import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest'
11+
import { AGENT_CONTEXT_SCENARIOS } from '@/evals/agent-context/scenarios'
12+
import { runExecutorScenario } from '@/evals/agent-tool-use/executor-harness'
13+
import { writeEvalReport } from '@/evals/agent-tool-use/report'
14+
import type { AgentToolUseResult } from '@/evals/agent-tool-use/types'
15+
16+
vi.mock('@/providers/conversation-history', () => providersConversationHistoryMock)
17+
vi.mock('@/tools', () => toolsMock)
18+
vi.mock('@/providers/utils', () => providersUtilsMock)
19+
vi.mock('@/providers', () => providersMock)
20+
vi.mock('@/ee/access-control/utils/permission-check', () => permissionCheckMock)
21+
vi.mock(
22+
'@/lib/uploads/contexts/workspace/workspace-file-secret-provenance',
23+
() => workspaceFileSecretProvenanceMock
24+
)
25+
vi.mock('@/lib/memory/agent-turn-session', () => ({
26+
openAgentTurnSession: vi.fn(async () => undefined),
27+
}))
28+
vi.mock('@/lib/internal/mcp/discover-tools', () => ({
29+
discoverMcpServerToolsAsExecutor: vi.fn(async () => []),
30+
}))
31+
vi.mock('@/lib/internal/custom-tools/read-available-by-id-or-title', () => ({
32+
readAvailableCustomToolByIdOrTitleAsExecutor: vi.fn(async () => undefined),
33+
}))
34+
vi.mock('@/executor/utils/http', () => ({
35+
buildAuthHeaders: vi.fn(async () => ({ 'Content-Type': 'application/json' })),
36+
buildAPIUrl: vi.fn((path: string) => path),
37+
extractAPIErrorMessage: vi.fn(async () => 'request failed'),
38+
}))
39+
vi.mock('@/lib/execution/cancellation', () => ({
40+
subscribeToExecutionCancellation: vi.fn(async () => () => {}),
41+
isExecutionCancelled: vi.fn(async () => false),
42+
}))
43+
44+
const results: AgentToolUseResult[] = []
45+
46+
beforeEach(() => {
47+
permissionCheckMockFns.mockValidateModelProvider.mockResolvedValue(undefined)
48+
providersUtilsMockFns.mockGetProviderFromModel.mockReturnValue('mock-provider')
49+
})
50+
51+
afterAll(() => {
52+
const reportPath = process.env.EVAL_CONTEXT_REPORT_PATH
53+
if (reportPath) writeEvalReport(results, reportPath)
54+
})
55+
56+
describe('agent context eval suite', () => {
57+
it.each(AGENT_CONTEXT_SCENARIOS)('$id: $name', async (scenario) => {
58+
const result = await runExecutorScenario(scenario)
59+
results.push(result)
60+
61+
const failed = result.checks.filter((entry) => !entry.passed)
62+
expect(
63+
failed,
64+
failed.map((entry) => `${entry.name}: ${entry.detail}`).join('; ') || undefined
65+
).toEqual([])
66+
})
67+
})
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
import type { ExecutorScenario } from '@/evals/agent-tool-use/executor-harness'
2+
3+
/**
4+
* Agent context evals.
5+
*
6+
* These drive the real Agent block inside the `DAGExecutor` with conversation
7+
* memory on. The memory read is stubbed per conversation id, so the provider
8+
* request shows exactly what the handler assembled: prior history, then the new
9+
* user prompt, with the system prompt preserved. A handler that passed the
10+
* wrong conversation id, dropped history, or reordered the prompt fails.
11+
*
12+
* Windowing inside the memory service (`sliding_window`, token budgets) is
13+
* covered by its own unit tests; this suite covers the assembly the model sees.
14+
*/
15+
export const AGENT_CONTEXT_SCENARIOS: ExecutorScenario[] = [
16+
{
17+
id: 'context-remembers-prior-turns',
18+
name: 'includes prior conversation memory before the new user prompt',
19+
category: 'retrieval',
20+
description:
21+
'Memory holds two prior turns. The provider request must contain both, in order, followed by the new user prompt, with the system prompt present.',
22+
workflowInput: {},
23+
agent: {
24+
model: 'gpt-4o',
25+
systemPrompt: 'You are a helpful assistant.',
26+
userPrompt: 'What is my name?',
27+
memory: {
28+
conversationId: 'conv-ada',
29+
history: [
30+
{ role: 'user', content: 'My name is Ada.' },
31+
{ role: 'assistant', content: 'Nice to meet you, Ada.' },
32+
],
33+
},
34+
},
35+
providerResponse: {
36+
content: 'Your name is Ada.',
37+
tokens: { input: 30, output: 5, total: 35 },
38+
},
39+
expect: {
40+
succeeds: true,
41+
finalContent: 'Ada',
42+
},
43+
},
44+
{
45+
id: 'context-isolates-conversations',
46+
name: 'reads the conversation named by the block, not another',
47+
category: 'retrieval',
48+
description:
49+
'The memory stub only returns history for the block conversation id; any other id yields a placeholder. A handler that passed the wrong id would surface the placeholder and fail.',
50+
workflowInput: {},
51+
agent: {
52+
model: 'gpt-4o',
53+
userPrompt: 'What did we decide?',
54+
memory: {
55+
conversationId: 'conv-b',
56+
history: [
57+
{ role: 'user', content: 'We decided to ship on Friday.' },
58+
{ role: 'assistant', content: 'Shipping Friday.' },
59+
],
60+
},
61+
},
62+
providerResponse: {
63+
content: 'You decided to ship on Friday.',
64+
tokens: { input: 25, output: 6, total: 31 },
65+
},
66+
expect: {
67+
succeeds: true,
68+
finalContent: 'Friday',
69+
},
70+
},
71+
]

0 commit comments

Comments
 (0)