Skip to content

Commit af2e6ca

Browse files
committed
feat(evals): add live DeepSeek model runs to the agent eval suite
Replay the same scenarios against a real model. The model is the only thing that changes: runScenario now takes an optional completion transport and a live mode that relaxes exact assertions (ordered subsequence, minimum successes) and skips scripted-only recovery cases. - live.ts: OpenAI-compatible transport + DeepSeek factory - agent-tool-use.live.test.ts: K trials per scenario, gated on EVAL_LIVE=1 and DEEPSEEK_API_KEY, never runs in CI - live report with pass rates, avg iterations, latency, failed checks - test:evals:live script and README knobs
1 parent 86c79d7 commit af2e6ca

8 files changed

Lines changed: 329 additions & 15 deletions

File tree

‎apps/sim/evals/README.md‎

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,35 @@ EVAL_REPORT_PATH=/tmp/agent-tool-use.json bunx vitest run evals/agent-tool-use
4646
The suite is also picked up by the normal `bun run test` run, so a regression
4747
fails CI even without the dedicated command.
4848

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+
70+
Live runs relax exact assertions: `toolCallSequence` becomes an ordered
71+
subsequence, `successfulToolCalls` becomes a minimum, and scripted-only cases
72+
(malformed JSON, unknown tool) are skipped. A scenario-level `liveExpect`
73+
overrides the scripted expectation where a real model cannot reproduce it (for
74+
example, an exact retry count). The report is at
75+
`test-results/evals/agent-tool-use-live.{json,md}` with pass rates, average
76+
iterations, latency, and the failed check names.
77+
4978
## Add a case
5079

5180
1. Open [`agent-tool-use/scenarios.ts`](./agent-tool-use/scenarios.ts) and add
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
import { providersMock } from '@sim/testing/mocks/providers.mock'
2+
import { providersConversationHistoryMock } from '@sim/testing/mocks/providers-conversation-history.mock'
3+
import { providersUtilsMock } from '@sim/testing/mocks/providers-utils.mock'
4+
import { toolsMock } from '@sim/testing/mocks/tools.mock'
5+
import { afterAll, describe, expect, it, vi } from 'vitest'
6+
import { runScenario } from '@/evals/agent-tool-use/harness'
7+
import { createDeepSeekLiveCompletion } from '@/evals/agent-tool-use/live'
8+
import { writeLiveEvalReport } from '@/evals/agent-tool-use/report'
9+
import { AGENT_TOOL_USE_SCENARIOS } from '@/evals/agent-tool-use/scenarios'
10+
import type { AgentToolUseResult, LiveScenarioSummary } from '@/evals/agent-tool-use/types'
11+
12+
vi.mock('@/providers/conversation-history', () => providersConversationHistoryMock)
13+
vi.mock('@/tools', () => toolsMock)
14+
vi.mock('@/providers/utils', () => providersUtilsMock)
15+
vi.mock('@/providers', () => providersMock)
16+
17+
/**
18+
* Live agent tool-use evals. Opt-in only:
19+
*
20+
* EVAL_LIVE=1 DEEPSEEK_API_KEY=... \
21+
* bun run --cwd apps/sim test --mode live evals/agent-tool-use/agent-tool-use.live.test.ts
22+
*
23+
* Each scenario runs `EVAL_TRIALS` times (default 3) because a real model is
24+
* nondeterministic. The report carries pass rates, not a single boolean. Set
25+
* `EVAL_MIN_PASS_RATE` (0–1) to turn a pass-rate floor into a failing gate.
26+
*/
27+
const LIVE = process.env.EVAL_LIVE === '1' && Boolean(process.env.DEEPSEEK_API_KEY)
28+
const TRIALS = Number(process.env.EVAL_TRIALS ?? '3')
29+
const MIN_PASS_RATE = Number(process.env.EVAL_MIN_PASS_RATE ?? '0')
30+
const MODEL = process.env.EVAL_MODEL ?? 'deepseek-chat'
31+
const TIMEOUT_MS = Number(process.env.EVAL_TIMEOUT_MS ?? '180000')
32+
33+
const liveScenarios = AGENT_TOOL_USE_SCENARIOS.filter((scenario) => !scenario.scriptedOnly)
34+
const summaries: LiveScenarioSummary[] = []
35+
36+
afterAll(() => {
37+
if (!LIVE) return
38+
writeLiveEvalReport(
39+
summaries,
40+
process.env.EVAL_REPORT_PATH ?? 'test-results/evals/agent-tool-use-live.json'
41+
)
42+
})
43+
44+
describe.skipIf(!LIVE)('agent tool-use eval suite (live DeepSeek)', () => {
45+
it.each(liveScenarios)(
46+
'$id: $name',
47+
async (scenario) => {
48+
const completion = createDeepSeekLiveCompletion(MODEL)
49+
const results: AgentToolUseResult[] = []
50+
51+
for (let trial = 0; trial < TRIALS; trial++) {
52+
results.push(
53+
await runScenario(scenario, {
54+
completion,
55+
mode: 'live',
56+
model: MODEL,
57+
providerName: 'DeepSeek',
58+
})
59+
)
60+
}
61+
62+
const passed = results.filter((result) => result.passed).length
63+
const passRate = results.length === 0 ? 0 : passed / results.length
64+
summaries.push({
65+
id: scenario.id,
66+
name: scenario.name,
67+
category: scenario.category,
68+
trials: results.length,
69+
passed,
70+
passRate,
71+
results,
72+
})
73+
74+
if (MIN_PASS_RATE > 0) {
75+
expect(
76+
passRate,
77+
`${scenario.id} passed ${passed}/${results.length} trials`
78+
).toBeGreaterThanOrEqual(MIN_PASS_RATE)
79+
}
80+
},
81+
TIMEOUT_MS
82+
)
83+
})

‎apps/sim/evals/agent-tool-use/harness.ts‎

Lines changed: 60 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -12,9 +12,11 @@ import {
1212
} from '@/providers/openai-compat/streaming-tool-loop'
1313
import type { AgentStreamEvent } from '@/providers/stream-events'
1414
import type { StreamingToolLoopComplete } from '@/providers/streaming-tool-loop-shared'
15+
import { adaptOpenAIChatToolSchema } from '@/providers/tool-schema-adapter'
1516
import type { ProviderToolConfig, TimeSegment } from '@/providers/types'
1617
import type { ToolResponse } from '@/tools/types'
1718
import type {
19+
AgentToolUseExpectations,
1820
AgentToolUseResult,
1921
AgentToolUseScenario,
2022
EvalCheck,
@@ -49,6 +51,20 @@ interface ToolCallList {
4951
count: number
5052
}
5153

54+
/** Scripted runs assert exact behavior; live runs assert outcomes across trials. */
55+
export type EvalRunMode = 'scripted' | 'live'
56+
57+
/** Options for {@link runScenario}. */
58+
export interface RunScenarioOptions {
59+
/** Model turns. Defaults to the scenario's scripted turns. */
60+
completion?: OpenAICompatCreateCompletion
61+
mode?: EvalRunMode
62+
/** Model id sent to a live provider and recorded in the run. */
63+
model?: string
64+
/** Provider label used in loop diagnostics. */
65+
providerName?: string
66+
}
67+
5268
/** Raw call counts as a value the loop never reads; scenarios only assert on it. */
5369
const COMPLETION_USAGE = (): CompletionUsage => ({
5470
prompt_tokens: 10,
@@ -184,22 +200,34 @@ function matchesContent(content: string, expected: string | RegExp): boolean {
184200
return typeof expected === 'string' ? content.includes(expected) : expected.test(content)
185201
}
186202

203+
function isOrderedSubsequence(actual: string[], expected: string[]): boolean {
204+
let index = 0
205+
for (const name of actual) {
206+
if (name === expected[index]) index += 1
207+
}
208+
return index === expected.length
209+
}
210+
187211
function score(
188-
scenario: AgentToolUseScenario,
212+
expected: AgentToolUseExpectations,
189213
toolCalls: CapturedToolCall[],
190214
finalContent: string,
191215
iterations: number,
192-
error: unknown
216+
error: unknown,
217+
mode: EvalRunMode
193218
): EvalCheck[] {
194-
const expected = scenario.expect
195219
const actualSequence = toolCalls.map((call) => call.name)
196220
const checks: EvalCheck[] = []
197221

198222
if (expected.toolCallSequence) {
223+
const sequenceMatches =
224+
mode === 'live'
225+
? isOrderedSubsequence(actualSequence, expected.toolCallSequence)
226+
: sameSequence(actualSequence, expected.toolCallSequence)
199227
checks.push(
200228
check(
201229
'tool-call-sequence',
202-
sameSequence(actualSequence, expected.toolCallSequence),
230+
sequenceMatches,
203231
`expected [${expected.toolCallSequence.join(', ')}], got [${actualSequence.join(', ')}]`
204232
)
205233
)
@@ -249,17 +277,22 @@ function score(
249277

250278
const successful = toolCalls.filter((call) => call.success).length
251279
if (expected.successfulToolCalls !== undefined) {
280+
const successMatches =
281+
mode === 'live'
282+
? successful >= expected.successfulToolCalls
283+
: successful === expected.successfulToolCalls
252284
checks.push(
253285
check(
254286
'successful-tool-calls',
255-
successful === expected.successfulToolCalls,
256-
`expected ${expected.successfulToolCalls}, got ${successful}`
287+
successMatches,
288+
`expected ${mode === 'live' ? 'at least ' : ''}${expected.successfulToolCalls}, got ${successful}`
257289
)
258290
)
259291
}
260292

261293
const errored = toolCalls.length - successful
262-
if (expected.erroredToolCalls !== undefined) {
294+
/** A live model chooses its own retry count, so an exact error count is scripted-only. */
295+
if (expected.erroredToolCalls !== undefined && mode !== 'live') {
263296
checks.push(
264297
check(
265298
'errored-tool-calls',
@@ -283,9 +316,18 @@ function score(
283316
}
284317

285318
/** Runs and scores one scenario. */
286-
export async function runScenario(scenario: AgentToolUseScenario): Promise<AgentToolUseResult> {
319+
export async function runScenario(
320+
scenario: AgentToolUseScenario,
321+
options: RunScenarioOptions = {}
322+
): Promise<AgentToolUseResult> {
323+
const mode = options.mode ?? 'scripted'
324+
const model = options.model ?? EVAL_MODEL
325+
const providerName = options.providerName ?? EVAL_PROVIDER
326+
const expected =
327+
mode === 'live' ? { ...scenario.expect, ...scenario.liveExpect } : scenario.expect
287328
const toolNames = new Set(scenario.tools.map((tool) => tool.name))
288329
const resultQueues = buildResultQueues(scenario, toolNames)
330+
const providerTools = toProviderTools(scenario.tools)
289331
const invocations: EvalToolInvocation[] = []
290332

291333
providersMock.MAX_TOOL_ITERATIONS = MAX_TOOL_ITERATIONS
@@ -312,18 +354,22 @@ export async function runScenario(scenario: AgentToolUseScenario): Promise<Agent
312354
const startedAt = Date.now()
313355
try {
314356
const stream = createOpenAICompatStreamingToolLoopStream({
315-
providerName: EVAL_PROVIDER,
357+
providerName,
316358
request: {
317-
model: EVAL_MODEL,
359+
model,
318360
apiKey: 'eval-key',
319361
messages: [],
320-
tools: toProviderTools(scenario.tools),
362+
tools: providerTools,
363+
},
364+
basePayload: {
365+
model,
366+
tools: providerTools.map((tool) => adaptOpenAIChatToolSchema(tool)),
321367
},
322-
basePayload: { model: EVAL_MODEL },
323368
messages: [{ role: 'user', content: scenario.userMessage }],
324-
createStream: createScriptedCompletion(scenario),
369+
createStream: options.completion ?? createScriptedCompletion(scenario),
325370
logger,
326371
timeSegments,
372+
preserveAssistantReasoning: true,
327373
onComplete: (result) => {
328374
completed = result
329375
},
@@ -337,7 +383,7 @@ export async function runScenario(scenario: AgentToolUseScenario): Promise<Agent
337383
const toolCalls = ((completed?.toolCalls as ToolCallList | undefined)?.list ?? []).slice()
338384
const finalContent = completed?.content ?? ''
339385
const iterations = completed?.iterations ?? 0
340-
const checks = score(scenario, toolCalls, finalContent, iterations, streamError)
386+
const checks = score(expected, toolCalls, finalContent, iterations, streamError, mode)
341387
const successful = toolCalls.filter((call) => call.success).length
342388

343389
return {
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
import OpenAI from 'openai'
2+
import type { OpenAICompatCreateCompletion } from '@/providers/openai-compat/streaming-tool-loop'
3+
4+
/**
5+
* Real-model transport for the eval harness. Any OpenAI-compatible provider
6+
* (OpenAI, DeepSeek, Groq, OpenRouter, …) works by passing its `baseURL`.
7+
*
8+
* The scripted suite stays the CI gate; this exists so the same scenarios can
9+
* be replayed against a live model on demand.
10+
*/
11+
export interface OpenAICompatLiveModelOptions {
12+
apiKey: string
13+
model: string
14+
baseURL?: string
15+
/** Per-request timeout in ms. Live model calls routinely exceed the default. */
16+
timeoutMs?: number
17+
}
18+
19+
const DEFAULT_TIMEOUT_MS = 120_000
20+
21+
export function createOpenAICompatLiveCompletion(
22+
options: OpenAICompatLiveModelOptions
23+
): OpenAICompatCreateCompletion {
24+
const client = new OpenAI({
25+
apiKey: options.apiKey,
26+
...(options.baseURL ? { baseURL: options.baseURL } : {}),
27+
timeout: options.timeoutMs ?? DEFAULT_TIMEOUT_MS,
28+
maxRetries: 2,
29+
})
30+
31+
return async (params, requestOptions) =>
32+
client.chat.completions.create(
33+
{
34+
...params,
35+
model: options.model,
36+
stream: true,
37+
// OpenAI-compatible providers require an opt-in to emit usage on streams.
38+
stream_options: { include_usage: true },
39+
},
40+
requestOptions
41+
)
42+
}
43+
44+
/**
45+
* DeepSeek's OpenAI-compatible endpoint. Reads `DEEPSEEK_API_KEY` and the
46+
* optional `DEEPSEEK_BASE_URL` / `EVAL_MODEL` overrides.
47+
*/
48+
export function createDeepSeekLiveCompletion(
49+
model = process.env.EVAL_MODEL ?? 'deepseek-chat'
50+
): OpenAICompatCreateCompletion {
51+
const apiKey = process.env.DEEPSEEK_API_KEY
52+
if (!apiKey) {
53+
throw new Error('DEEPSEEK_API_KEY is required for the live agent eval')
54+
}
55+
return createOpenAICompatLiveCompletion({
56+
apiKey,
57+
baseURL: process.env.DEEPSEEK_BASE_URL ?? 'https://api.deepseek.com',
58+
model,
59+
...(process.env.EVAL_TIMEOUT_MS ? { timeoutMs: Number(process.env.EVAL_TIMEOUT_MS) } : {}),
60+
})
61+
}

0 commit comments

Comments
 (0)