@qvac/harness 1.0.0-rc.2 → 1.0.0-rc.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (58) hide show
  1. package/README.md +12 -12
  2. package/bench/README.md +94 -0
  3. package/bench/bfcl/checker.ts +313 -0
  4. package/bench/bfcl/data.ts +93 -0
  5. package/bench/bfcl/observe.ts +93 -0
  6. package/bench/bfcl/run.ts +213 -0
  7. package/bench/bfcl/tools.ts +72 -0
  8. package/bench/bridge/bridge.ts +392 -0
  9. package/bench/bridge/openai.ts +150 -0
  10. package/bench/bridge/python/qvac_bench/__init__.py +3 -0
  11. package/bench/bridge/python/qvac_bench/airline.py +42 -0
  12. package/bench/bridge/python/qvac_bench/approval.py +26 -0
  13. package/bench/bridge/python/qvac_bench/bfcl_multi_turn.py +152 -0
  14. package/bench/bridge/python/qvac_bench/client.py +88 -0
  15. package/bench/bridge/python/qvac_bench/harbor_agent.py +85 -0
  16. package/bench/bridge/python/qvac_bench/session.py +107 -0
  17. package/bench/bridge/python/qvac_bench/speed.py +30 -0
  18. package/bench/bridge/python/qvac_bench/tau2_agent.py +106 -0
  19. package/bench/bridge/python/qvac_bench/tools.py +11 -0
  20. package/bench/bridge/python/run_bfcl_multi_turn.py +95 -0
  21. package/bench/bridge/python/run_tau2.py +150 -0
  22. package/bench/bridge/python/smoke.py +47 -0
  23. package/bench/bridge/python/test_airline.py +28 -0
  24. package/bench/bridge/python/test_approval.py +57 -0
  25. package/bench/bridge/python/test_bfcl_multi_turn.py +185 -0
  26. package/bench/bridge/python/test_harbor_agent.py +107 -0
  27. package/bench/bridge/serve.ts +52 -0
  28. package/bench/bridge/transcript.ts +52 -0
  29. package/bench/bun-tsconfig.json +15 -0
  30. package/bench/cpu-main.mjs +216 -0
  31. package/bench/cpu.ts +457 -0
  32. package/bench/evidence/2026-09-28-review15.md +225 -0
  33. package/bench/evidence/redact-bun1.3.14.json +92 -0
  34. package/bench/evidence/redact-node24.11.json +92 -0
  35. package/bench/graph-metrics.ts +43 -0
  36. package/bench/harness.ts +73 -0
  37. package/bench/issue129-matrix.mjs +722 -0
  38. package/bench/redact-adversarial.ts +256 -0
  39. package/bench/repeat-cpu.mjs +281 -0
  40. package/bench/report.ts +1075 -0
  41. package/bench/skill-eval/harness.ts +30 -0
  42. package/bench/skill-eval/laws.ts +37 -0
  43. package/bench/skill-eval/mermaid/client.ts +132 -0
  44. package/bench/skill-eval/mermaid/validate.mjs +115 -0
  45. package/bench/skill-eval/pack.ts +61 -0
  46. package/bench/skill-eval/packs/diagrams/cases.ts +281 -0
  47. package/bench/skill-eval/packs/diagrams/index.ts +50 -0
  48. package/bench/skill-eval/packs/diagrams/score.ts +74 -0
  49. package/bench/skill-eval/packs/index.ts +5 -0
  50. package/bench/skill-eval/packs/weather/cases.ts +279 -0
  51. package/bench/skill-eval/packs/weather/fixtures/wttr.json +566 -0
  52. package/bench/skill-eval/packs/weather/fixtures.ts +84 -0
  53. package/bench/skill-eval/packs/weather/index.ts +55 -0
  54. package/bench/skill-eval/packs/weather/score.ts +92 -0
  55. package/bench/skill-eval/report.ts +194 -0
  56. package/bench/skill-eval/run.ts +316 -0
  57. package/bench/speed.ts +80 -0
  58. package/package.json +4 -13
package/README.md CHANGED
@@ -65,8 +65,8 @@ checked against its schema and the run's grants, sorted into a read or a write,
65
65
  answers, a budget runs out, or it hands the conversation to another agent.
66
66
 
67
67
  The loop is a state machine (`lib/machine/`), and every step is journaled. So a run can pause for
68
- approval, survive the process dying, and pick up where it stopped. `bun run resume` kills a run at
69
- its approval and resumes it; see [The loop is a state machine](examples/README.md#the-loop-is-a-state-machine).
68
+ approval, survive the process dying, and pick up where it stopped. `bun run example resume` kills a run at
69
+ its approval and resumes it; see [The loop is a state machine](examples/resume/README.md#the-loop-is-a-state-machine).
70
70
 
71
71
  **Built for small models.** A 2B model makes mistakes a cloud model does not. The harness repairs
72
72
  broken tool-call markup, coerces arguments to their schema, re-asks when the model describes a call
@@ -247,7 +247,7 @@ same `runId` with `resume: true` and the run carries on. See [Resuming a complet
247
247
  Every completion ends with a `metrics` event: prefill and decode tokens per second, time to first
248
248
  token, per round and per run. Each loaded model reports the engine's own fit verdict and its device
249
249
  and host bytes in `diagnostics().models`. See [Speed stats](docs/speed-stats.md). Accuracy is measured on real
250
- benchmarks (BFCL, τ²-bench, Terminal-Bench) in [examples/bench](examples/bench/README.md).
250
+ benchmarks (BFCL, τ²-bench, Terminal-Bench) in [bench](bench/README.md).
251
251
 
252
252
  ## Plugins
253
253
 
@@ -302,14 +302,14 @@ Each runs on a local model under `QVAC_MODEL_CACHE_PATH` (`~/.qvac/models` by de
302
302
  scripted model; host needs a spawned Bare child and a model, so it has no test. More in
303
303
  [examples/](examples/README.md).
304
304
 
305
- | Run | Shows |
306
- | ------------------------ | ---------------------------------------------------------------------------------------- |
307
- | `bun run code-assistant` | [A coding team](examples/code-assistant/README.md): plan, subagents, review, Claude Code |
308
- | `bun run subagents` | A lead handing one file to each subagent, four reading at once |
309
- | `bun run handoffs` | A triage agent handing a memo to an office agent, and a health question to one on MedPsy |
310
- | `bun run parallel` | Three tools in one round, and two runs on one model |
311
- | `bun run custom-plugin` | [A third-party plugin](examples/custom-plugin/README.md): tools, a hook, a service |
312
- | `bun run host` | Hosting the harness as a sidecar, the way a desktop app does |
305
+ | Run | Shows |
306
+ | -------------------------------- | ---------------------------------------------------------------------------------------- |
307
+ | `bun run example code-assistant` | [A coding team](examples/code-assistant/README.md): plan, subagents, review, Claude Code |
308
+ | `bun run example subagents` | A lead handing one file to each subagent, four reading at once |
309
+ | `bun run example handoffs` | A triage agent handing a memo to an office agent, and a health question to one on MedPsy |
310
+ | `bun run example parallel` | Three tools in one round, and two runs on one model |
311
+ | `bun run example custom-plugin` | [A third-party plugin](examples/custom-plugin/README.md): tools, a hook, a service |
312
+ | `bun run example host` | Hosting the harness as a sidecar, the way a desktop app does |
313
313
 
314
314
  ## Host it
315
315
 
@@ -332,7 +332,7 @@ takes it unchanged to run agents across your devices; see its
332
332
  - [Architecture](docs/architecture.md): the engine layout and each module's API.
333
333
  - [Resuming a completion](docs/resuming.md): the host run log, process death, the tool-call grammar.
334
334
  - [Custom models](docs/custom-models.md): run a GGUF the catalog does not carry.
335
- - [Speed stats](docs/speed-stats.md) and [benchmarks](examples/bench/README.md).
335
+ - [Speed stats](docs/speed-stats.md) and [benchmarks](bench/README.md).
336
336
  - [Decisions](docs/decisions/INDEX.md): why the harness is built the way it is.
337
337
 
338
338
  ## Development
@@ -0,0 +1,94 @@
1
+ # bench
2
+
3
+ Runs the harness on published benchmarks with local models. `harness.ts` builds the tool list the
4
+ sidecar builds, so a score is the product's. Results render on one page with `report.ts`.
5
+
6
+ The other files here (`cpu.ts`, `graph-metrics.ts`, `issue129-matrix.mjs` and friends) are the
7
+ completion engine's own performance runners and are unrelated. [`skill-eval/`](#skill-eval)
8
+ measures a bundled skill's wording on the real model.
9
+
10
+ ## BFCL v4
11
+
12
+ The eleven single-turn categories of the Berkeley Function Calling Leaderboard, downloaded from a
13
+ pinned gorilla commit and judged by a port of its AST checker. Each case's functions are the run's
14
+ only tools. `strict` scores the first round as BFCL does; `across rounds` also credits parallel
15
+ calls made one round at a time.
16
+
17
+ ```
18
+ bare bench/bfcl/run.ts --models Qwen3.5-2B-Q4_K_M,Qwen3.5-9B-Q4_K_M --limit 50 --out output/bfcl/run
19
+ ```
20
+
21
+ Set `QVAC_BENCH_CODE_MODE=1` to give the model `run_code` beside the case's functions; a script's
22
+ inner calls are the ones scored.
23
+
24
+ ## BFCL v4 multi-turn
25
+
26
+ The four multi-turn categories, run on gorilla's own backends and scored by BFCL's checker; the report says how. Needs a Python 3.12 venv with `bfcl-eval` at the gorilla commit the single-turn adapter pins, without its deps.
27
+
28
+ ```
29
+ python3.12 -m venv output/venvs/bfcl && output/venvs/bfcl/bin/pip install --no-deps \
30
+ "bfcl-eval @ git+https://github.com/ShishirPatil/gorilla@6ea57973c7a6097fd7c5915698c54c17c5b1b6c8#subdirectory=berkeley-function-call-leaderboard"
31
+ output/venvs/bfcl/bin/pip install mpmath==1.3.0 filelock==3.20.0 overrides tenacity \
32
+ tree_sitter==0.21.3 tree-sitter-java==0.21.0 tree-sitter-javascript==0.21.4
33
+ output/venvs/bfcl/bin/python bench/bridge/python/run_bfcl_multi_turn.py \
34
+ --models Qwen3.5-2B-Q4_K_M,Qwen3.5-9B-Q4_K_M --limit 10 --out output/bfcl-multi-turn/run
35
+ ```
36
+
37
+ `--limit` is entries per category, spread evenly over its 200; `--out` must be a new dir. Tests,
38
+ with no model: `cd bench/bridge/python && ../../../output/venvs/bfcl/bin/python -m unittest test_bfcl_multi_turn`.
39
+
40
+ ## tau2-bench
41
+
42
+ The harness is the agent over the stdio bridge; tau2's orchestrator runs the tools and scores the
43
+ task. The simulated user is a raw local model behind `bridge/openai.ts`, so scores are tau2's own
44
+ but not comparable to its GPT-4.1-user leaderboard. Needs a Python 3.12 venv with tau2 installed.
45
+
46
+ ```
47
+ python3.12 -m venv output/venvs/tau2 && output/venvs/tau2/bin/pip install -e <tau2-bench checkout> websockets
48
+ output/venvs/tau2/bin/python bench/bridge/python/run_tau2.py --domain airline --tasks 20 \
49
+ --model Qwen3.5-4B-Q4_K_M --user-model Qwen3.5-9B-Q4_K_M --out output/tau2/run
50
+ ```
51
+
52
+ Set `QVAC_BRIDGE_PLAN=1` to give the agent the `update_plan` tool, and compare against a run without it.
53
+
54
+ ## Terminal-Bench 2.0
55
+
56
+ A Harbor agent runs the harness with one bash tool in the task container. Needs a container
57
+ runtime: with OrbStack, give it 4 or more CPUs and 8 GB or more of memory before a full run. Run
58
+ one task at a time (`-n 1`): every trial loads the model in its own bridge process.
59
+
60
+ ```
61
+ QVAC_HARNESS_REPO=$PWD PYTHONPATH=bench/bridge/python uv tool run harbor run \
62
+ --dataset terminal-bench@2.0 -a qvac_bench.harbor_agent:QvacHarnessAgent \
63
+ -m Qwen3.5-9B-Q4_K_M -n 1 --ak max_tool_rounds=10
64
+ ```
65
+
66
+ `max_tool_rounds` is the run's round limit; leave it out for the harness default. Each trial's
67
+ `agent/qvac.jsonl` records the bash calls, the done frame and the load average at the start and end.
68
+
69
+ ## Skill eval
70
+
71
+ Runs a `SKILL.md` variant against the current one on the real local model, with the skill's tools
72
+ answered from recorded fixtures and every reply judged by fixed rules, never by a second model. A
73
+ pack in `skill-eval/packs/` is one skill's prompts, fixtures and scoring; `weather` and `diagrams`
74
+ ship. See [the decision](../docs/decisions/2026-09-10-skill-wording-is-measured-offline.md).
75
+
76
+ ```
77
+ bun run skill-eval --pack weather --repeats 3
78
+ bun run skill-eval --help
79
+ ```
80
+
81
+ ## Report
82
+
83
+ ```
84
+ bare bench/report.ts \
85
+ --bfcl main=output/bfcl/run \
86
+ --bfcl-multi-turn main=output/bfcl-multi-turn/run \
87
+ --tau2 "before=output/tau2/old" --tau2 "after=output/tau2/run" \
88
+ --terminal "10 rounds=jobs/<job>" \
89
+ --out docs/bench/benchmarks.html
90
+ ```
91
+
92
+ Each `--bfcl`, `--bfcl-multi-turn`, `--tau2` and `--terminal` run is `label=dir` (for `--terminal`, Harbor's job dir); the first BFCL run and the last τ² run are the ones the headline cards report, and τ² runs are listed in the order given.
93
+
94
+ Outside a checkout with vendored wheels, set `QVAC_PYTHON_WHEELS_DIR` to a checkout's `wheels/`.
@@ -0,0 +1,313 @@
1
+ import { isRecord } from '../../lib/guards.ts'
2
+
3
+ // a port of BFCL's single-turn AST checker (bfcl_eval/eval_checker/ast_eval/ast_checker.py)
4
+ export interface ParamDoc {
5
+ readonly type: string
6
+ readonly description?: string
7
+ readonly items?: ParamDoc
8
+ readonly properties?: Readonly<Record<string, ParamDoc>>
9
+ readonly enum?: readonly unknown[]
10
+ readonly required?: readonly string[]
11
+ readonly default?: unknown
12
+ }
13
+
14
+ export interface FunctionDoc {
15
+ readonly name: string
16
+ readonly description: string
17
+ readonly parameters: {
18
+ readonly type: string
19
+ readonly properties: Readonly<Record<string, ParamDoc>>
20
+ readonly required?: readonly string[]
21
+ }
22
+ }
23
+
24
+ // one call as BFCL writes it: { name: { param: value } }
25
+ export type Call = Readonly<Record<string, Readonly<Record<string, unknown>>>>
26
+ export type Answer = Readonly<Record<string, Readonly<Record<string, readonly unknown[]>>>>
27
+
28
+ export interface Verdict {
29
+ readonly valid: boolean
30
+ readonly error: string | null
31
+ }
32
+
33
+ type Kind = 'string' | 'integer' | 'float' | 'boolean' | 'array' | 'dict' | 'null'
34
+
35
+ const PYTHON_KIND: Record<string, Kind> = {
36
+ string: 'string',
37
+ integer: 'integer',
38
+ float: 'float',
39
+ boolean: 'boolean',
40
+ array: 'array',
41
+ tuple: 'array',
42
+ dict: 'dict',
43
+ any: 'string'
44
+ }
45
+
46
+ const NESTED = ['array', 'tuple']
47
+ const VALID: Verdict = { valid: true, error: null }
48
+
49
+ export interface Subject {
50
+ readonly category: string
51
+ readonly docs: readonly FunctionDoc[]
52
+ readonly answers: readonly Answer[] | null
53
+ }
54
+
55
+ interface Expectation {
56
+ readonly possible: readonly unknown[]
57
+ readonly expected: Kind
58
+ readonly nested: Kind | null
59
+ }
60
+
61
+ export function check(subject: Subject, calls: readonly Call[]): Verdict {
62
+ const { category, docs } = subject
63
+ if (category.includes('irrelevance')) {
64
+ return calls.length ? invalid('called a function when none applied') : VALID
65
+ }
66
+
67
+ if (category.includes('relevance')) {
68
+ return calls.length ? VALID : invalid('made no call')
69
+ }
70
+
71
+ const expected = subject.answers ?? []
72
+ if (calls.length !== expected.length) {
73
+ return invalid(`wrong number of functions: ${calls.length} for ${expected.length}`)
74
+ }
75
+
76
+ if (!category.includes('parallel')) {
77
+ return simple(docFor(docs, nameOf(expected[0])), calls[0], expected[0])
78
+ }
79
+
80
+ // unordered: each expected call claims the first unclaimed call that satisfies it
81
+ const matched = new Set<number>()
82
+ for (const answer of expected) {
83
+ const doc = docFor(docs, nameOf(answer))
84
+ const index = calls.findIndex((call, at) => !matched.has(at) && simple(doc, call, answer).valid)
85
+ if (index < 0) {
86
+ return invalid(`no call matches ${nameOf(answer)}`)
87
+ }
88
+
89
+ matched.add(index)
90
+ }
91
+
92
+ return VALID
93
+ }
94
+
95
+ function simple(doc: FunctionDoc | undefined, call: Call, answer: Answer): Verdict {
96
+ const name = nameOf(answer)
97
+ const possible = answer[name]
98
+ if (!doc || !(name in call)) {
99
+ return invalid(`function ${name} not called`)
100
+ }
101
+
102
+ const params = call[name]
103
+ const missing = (doc.parameters.required ?? []).find((param) => !(param in params))
104
+ if (missing) {
105
+ return invalid(`missing required parameter ${missing}`)
106
+ }
107
+
108
+ for (const [param, value] of Object.entries(params)) {
109
+ const detail = doc.parameters.properties[param]
110
+ if (!detail || !(param in possible)) {
111
+ return invalid(`unexpected parameter ${param}`)
112
+ }
113
+
114
+ const verdict = checkParam(param, value, expectationOf(detail, possible[param]))
115
+ if (!verdict.valid) {
116
+ return verdict
117
+ }
118
+ }
119
+
120
+ const unset = Object.keys(possible).find(
121
+ (param) => !(param in params) && !possible[param].includes('')
122
+ )
123
+
124
+ return unset ? invalid(`optional parameter ${unset} not provided`) : VALID
125
+ }
126
+
127
+ function expectationOf(detail: ParamDoc, possible: readonly unknown[]): Expectation {
128
+ const nested =
129
+ NESTED.includes(detail.type) && detail.items ? PYTHON_KIND[detail.items.type] : null
130
+ return { possible, expected: PYTHON_KIND[detail.type] ?? 'string', nested }
131
+ }
132
+
133
+ function checkParam(param: string, value: unknown, expectation: Expectation): Verdict {
134
+ const { possible, expected, nested } = expectation
135
+ const typed = typeCheck(value, expectation)
136
+ if (typed === null) {
137
+ return invalid(`wrong type for ${param}: ${JSON.stringify(value)} is not ${expected}`)
138
+ }
139
+
140
+ // a value of the answer's type where the schema wanted another is a variable: compare it plainly
141
+ const special = typed.variable ? null : specialCheck(value, expected, nested)
142
+ const matches = special ? special(possible) : possible.some((option) => equal(value, option))
143
+
144
+ return matches ? VALID : invalid(`invalid value for ${param}: ${JSON.stringify(value)}`)
145
+ }
146
+
147
+ function specialCheck(
148
+ value: unknown,
149
+ expected: Kind,
150
+ nested: Kind | null
151
+ ): ((possible: readonly unknown[]) => boolean) | null {
152
+ if (expected === 'dict') {
153
+ return (possible) => dictFits(value, possible)
154
+ }
155
+
156
+ if (expected === 'array' && nested === 'dict') {
157
+ return (possible) => listDictFits(value, possible)
158
+ }
159
+
160
+ if (expected === 'string') {
161
+ return (possible) => stringFits(value, possible)
162
+ }
163
+
164
+ if (expected === 'array') {
165
+ return (possible) => listFits(value, possible)
166
+ }
167
+
168
+ return null
169
+ }
170
+
171
+ interface Typed {
172
+ readonly variable: boolean
173
+ }
174
+
175
+ function typeCheck(value: unknown, expectation: Expectation): Typed | null {
176
+ const { possible, expected, nested } = expectation
177
+ const answerKind = possibleKind(possible)
178
+ const variable = answerKind !== null && !sameKind(answerKind, expected)
179
+ if (sameKind(kindOf(value), expected)) {
180
+ if (nested === null || !Array.isArray(value)) {
181
+ return { variable }
182
+ }
183
+
184
+ const itemsFit = possible.some(
185
+ (option) => !Array.isArray(option) || value.every((item) => sameKind(kindOf(item), nested))
186
+ )
187
+
188
+ return itemsFit ? { variable } : null
189
+ }
190
+
191
+ return answerKind !== null && sameKind(kindOf(value), answerKind) ? { variable: true } : null
192
+ }
193
+
194
+ function stringFits(value: unknown, possible: readonly unknown[]): boolean {
195
+ const wanted = possible.filter((option) => typeof option === 'string').map(standardize)
196
+ return wanted.includes(standardize(String(value)))
197
+ }
198
+
199
+ function listFits(value: unknown, possible: readonly unknown[]): boolean {
200
+ if (!Array.isArray(value)) {
201
+ return false
202
+ }
203
+
204
+ const output = value.map(standardizeItem)
205
+
206
+ return possible.filter(Array.isArray).some((option) => equal(output, option.map(standardizeItem)))
207
+ }
208
+
209
+ function dictFits(value: unknown, possible: readonly unknown[]): boolean {
210
+ return isDict(value) && possible.some((option) => isDict(option) && dictMatches(value, option))
211
+ }
212
+
213
+ function dictMatches(output: Record<string, unknown>, option: Record<string, unknown>): boolean {
214
+ const valuesFit = Object.entries(output).every(([key, item]) => {
215
+ const candidates = option[key]
216
+ return (
217
+ Array.isArray(candidates) &&
218
+ candidates.map(standardizeItem).some((candidate) => equal(standardizeItem(item), candidate))
219
+ )
220
+ })
221
+ const keysFit = Object.entries(option).every(
222
+ ([key, candidates]) => key in output || (Array.isArray(candidates) && candidates.includes(''))
223
+ )
224
+
225
+ return valuesFit && keysFit
226
+ }
227
+
228
+ function listDictFits(value: unknown, possible: readonly unknown[]): boolean {
229
+ if (!Array.isArray(value)) {
230
+ return false
231
+ }
232
+
233
+ return possible.some(
234
+ (option) =>
235
+ Array.isArray(option) &&
236
+ option.length === value.length &&
237
+ value.every((item, index) => dictFits(item, [option[index]]))
238
+ )
239
+ }
240
+
241
+ // BFCL forgives spacing and ",./-_*^" punctuation and case: "April 1, 2024" is "April 1 2024"
242
+ export function standardize(text: string): string {
243
+ return text
244
+ .replace(/[ ,./\-_*^]/g, '')
245
+ .toLowerCase()
246
+ .replaceAll("'", '"')
247
+ }
248
+
249
+ function standardizeItem(item: unknown): unknown {
250
+ return typeof item === 'string' ? standardize(item) : item
251
+ }
252
+
253
+ // JSON keeps no int/float distinction in JavaScript, so 5 and 5.0 are one number here
254
+ function kindOf(value: unknown): Kind {
255
+ if (value === null) {
256
+ return 'null'
257
+ }
258
+
259
+ if (Array.isArray(value)) {
260
+ return 'array'
261
+ }
262
+
263
+ if (typeof value === 'number') {
264
+ return Number.isInteger(value) ? 'integer' : 'float'
265
+ }
266
+
267
+ if (typeof value === 'boolean') {
268
+ return 'boolean'
269
+ }
270
+
271
+ return isRecord(value) ? 'dict' : 'string'
272
+ }
273
+
274
+ function sameKind(left: Kind, right: Kind): boolean {
275
+ const numeric = (kind: Kind) => kind === 'integer' || kind === 'float'
276
+ return left === right || (numeric(left) && numeric(right))
277
+ }
278
+
279
+ function possibleKind(possible: readonly unknown[]): Kind | null {
280
+ const first = possible.find((option) => option !== '')
281
+ return first === undefined ? null : kindOf(first)
282
+ }
283
+
284
+ function equal(left: unknown, right: unknown): boolean {
285
+ if (Array.isArray(left) && Array.isArray(right)) {
286
+ return left.length === right.length && left.every((item, index) => equal(item, right[index]))
287
+ }
288
+
289
+ if (isDict(left) && isDict(right)) {
290
+ const keys = Object.keys(left)
291
+ return (
292
+ keys.length === Object.keys(right).length && keys.every((key) => equal(left[key], right[key]))
293
+ )
294
+ }
295
+
296
+ return left === right
297
+ }
298
+
299
+ function isDict(value: unknown): value is Record<string, unknown> {
300
+ return isRecord(value) && !Array.isArray(value)
301
+ }
302
+
303
+ function nameOf(answer: Answer): string {
304
+ return Object.keys(answer)[0]
305
+ }
306
+
307
+ function docFor(docs: readonly FunctionDoc[], name: string): FunctionDoc | undefined {
308
+ return docs.find((doc) => doc.name === name)
309
+ }
310
+
311
+ function invalid(error: string): Verdict {
312
+ return { valid: false, error }
313
+ }
@@ -0,0 +1,93 @@
1
+ import fs from 'fs'
2
+ import path from 'path'
3
+ import fetch from '#fetch'
4
+ import type { Message } from '../../lib/contract/index.ts'
5
+ import type { Answer, FunctionDoc } from './checker.ts'
6
+
7
+ // the gorilla commit the data and the checker port were read from
8
+ export const BFCL_COMMIT = '6ea57973c7a6097fd7c5915698c54c17c5b1b6c8'
9
+ const BFCL_DATA = `https://raw.githubusercontent.com/ShishirPatil/gorilla/${BFCL_COMMIT}/berkeley-function-call-leaderboard/bfcl_eval/data`
10
+
11
+ // the single-turn categories BFCL scores by AST; multi-turn, memory and web search need executors
12
+ export const CATEGORIES = [
13
+ 'simple_python',
14
+ 'multiple',
15
+ 'parallel',
16
+ 'parallel_multiple',
17
+ 'irrelevance',
18
+ 'live_simple',
19
+ 'live_multiple',
20
+ 'live_parallel',
21
+ 'live_parallel_multiple',
22
+ 'live_irrelevance',
23
+ 'live_relevance'
24
+ ]
25
+
26
+ // the params the #157 stopgap rewrites, keyed as sdkProperty keys them
27
+ export const SHAPES = ['nested', 'default-only', 'untouched'] as const
28
+ export type Shape = (typeof SHAPES)[number]
29
+
30
+ export function shapeOf(functions: readonly FunctionDoc[]): Shape {
31
+ const params = functions.flatMap((doc) => Object.values(doc.parameters.properties))
32
+ if (params.some((param) => param.properties || param.items)) {
33
+ return 'nested'
34
+ }
35
+
36
+ return params.some((param) => param.default !== undefined) ? 'default-only' : 'untouched'
37
+ }
38
+
39
+ export interface BfclCase {
40
+ readonly id: string
41
+ readonly category: string
42
+ readonly turns: Message[]
43
+ readonly functions: FunctionDoc[]
44
+ readonly answers: Answer[] | null
45
+ }
46
+
47
+ export interface LoadOptions {
48
+ readonly dir: string
49
+ // cases per category, spread evenly across it
50
+ readonly limit: number
51
+ }
52
+
53
+ export async function loadCategory(category: string, options: LoadOptions): Promise<BfclCase[]> {
54
+ const entries = await lines(`BFCL_v4_${category}.json`, options.dir)
55
+ // relevance and irrelevance are judged by whether a call was made, so they ship no answers
56
+ const answers = category.includes('relevance')
57
+ ? new Map()
58
+ : new Map(
59
+ (await lines(`possible_answer/BFCL_v4_${category}.json`, options.dir)).map((entry) => [
60
+ entry.id,
61
+ entry.ground_truth
62
+ ])
63
+ )
64
+ const all = entries.map((entry): BfclCase => ({
65
+ id: entry.id,
66
+ category,
67
+ turns: entry.question[0],
68
+ functions: entry.function,
69
+ answers: answers.get(entry.id) ?? null
70
+ }))
71
+ const step = Math.max(1, Math.floor(all.length / Math.min(options.limit, all.length)))
72
+
73
+ return all.filter((_, index) => index % step === 0).slice(0, options.limit)
74
+ }
75
+
76
+ async function lines(file: string, dir: string) {
77
+ const local = path.join(dir, file)
78
+ if (!fs.existsSync(local)) {
79
+ const response = await fetch(`${BFCL_DATA}/${file}`)
80
+ if (!response.ok) {
81
+ throw new Error(`BFCL ${file}: HTTP ${response.status}`)
82
+ }
83
+
84
+ fs.mkdirSync(path.dirname(local), { recursive: true })
85
+ fs.writeFileSync(local, await response.text())
86
+ }
87
+
88
+ return fs
89
+ .readFileSync(local, 'utf8')
90
+ .split('\n')
91
+ .filter(Boolean)
92
+ .map((line) => JSON.parse(line))
93
+ }
@@ -0,0 +1,93 @@
1
+ import type {
2
+ CompletionEvent,
3
+ LlmGenerationParams,
4
+ MetricsEvent
5
+ } from '../../lib/contract/index.ts'
6
+ import { errorMessage } from '../../lib/guards.ts'
7
+ import type { Call } from './checker.ts'
8
+
9
+ export interface Made {
10
+ readonly first: Call[]
11
+ readonly all: Call[]
12
+ readonly reply: string
13
+ readonly error: string | null
14
+ readonly metrics: MetricsEvent | null
15
+ readonly firstCallRound: number | null
16
+ // what the second round was sent, where a re-ask is forced
17
+ readonly reaskParams: LlmGenerationParams | null
18
+ }
19
+
20
+ export interface Observing {
21
+ readonly original: (name: string) => string
22
+ readonly stop: () => void
23
+ // distinct calls that end the run; null ends it with the first round instead
24
+ readonly enough: number | null
25
+ }
26
+
27
+ export async function observe(
28
+ events: AsyncIterable<CompletionEvent>,
29
+ observing: Observing
30
+ ): Promise<Made> {
31
+ const { original, stop, enough } = observing
32
+ const first: Call[] = []
33
+ const all: Call[] = []
34
+ let firstRound: number | undefined
35
+ let doneRound: number | undefined
36
+ let stopped = false
37
+ let reply = ''
38
+ let error: string | null = null
39
+ let metrics: MetricsEvent | null = null
40
+ let reaskParams: LlmGenerationParams | null = null
41
+ try {
42
+ for await (const event of events) {
43
+ if (event.type === 'metrics') {
44
+ metrics = event
45
+ }
46
+
47
+ // a stopped run is drained, not left, so its speed still arrives; the rest is not scored
48
+ if (stopped) {
49
+ continue
50
+ }
51
+
52
+ // stop only at a round boundary, so every call of the round that finished the work is seen
53
+ if (doneRound !== undefined && (event.round ?? doneRound) > doneRound) {
54
+ stopped = true
55
+ stop()
56
+ continue
57
+ }
58
+
59
+ // a script's own calls arrive as tool calls of the same round, so they are the ones scored
60
+ if (event.type === 'tool-call' && event.name !== 'run_code') {
61
+ firstRound ??= event.round
62
+ record(
63
+ { [original(event.name)]: event.args },
64
+ event.round === firstRound ? first : null,
65
+ all
66
+ )
67
+ doneRound ??= enough === null || all.length >= enough ? event.round : undefined
68
+ } else if (event.type === 'content' && firstRound === undefined) {
69
+ reply += event.text
70
+ } else if (event.type === 'error') {
71
+ error = event.message
72
+ } else if (event.type === 'aborted') {
73
+ error = 'aborted'
74
+ } else if (event.type === 'inference-call' && event.call.round === 1) {
75
+ reaskParams ??= event.call.generationParams ?? null
76
+ }
77
+ }
78
+ } catch (err) {
79
+ if (!stopped) {
80
+ error = errorMessage(err)
81
+ }
82
+ }
83
+
84
+ return { first, all, reply, error, metrics, firstCallRound: firstRound ?? null, reaskParams }
85
+ }
86
+
87
+ // a repeat of the same call is one call to BFCL, not a second
88
+ function record(call: Call, first: Call[] | null, all: Call[]): void {
89
+ first?.push(call)
90
+ if (!all.some((seen) => JSON.stringify(seen) === JSON.stringify(call))) {
91
+ all.push(call)
92
+ }
93
+ }