@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.
- package/README.md +12 -12
- package/bench/README.md +94 -0
- package/bench/bfcl/checker.ts +313 -0
- package/bench/bfcl/data.ts +93 -0
- package/bench/bfcl/observe.ts +93 -0
- package/bench/bfcl/run.ts +213 -0
- package/bench/bfcl/tools.ts +72 -0
- package/bench/bridge/bridge.ts +392 -0
- package/bench/bridge/openai.ts +150 -0
- package/bench/bridge/python/qvac_bench/__init__.py +3 -0
- package/bench/bridge/python/qvac_bench/airline.py +42 -0
- package/bench/bridge/python/qvac_bench/approval.py +26 -0
- package/bench/bridge/python/qvac_bench/bfcl_multi_turn.py +152 -0
- package/bench/bridge/python/qvac_bench/client.py +88 -0
- package/bench/bridge/python/qvac_bench/harbor_agent.py +85 -0
- package/bench/bridge/python/qvac_bench/session.py +107 -0
- package/bench/bridge/python/qvac_bench/speed.py +30 -0
- package/bench/bridge/python/qvac_bench/tau2_agent.py +106 -0
- package/bench/bridge/python/qvac_bench/tools.py +11 -0
- package/bench/bridge/python/run_bfcl_multi_turn.py +95 -0
- package/bench/bridge/python/run_tau2.py +150 -0
- package/bench/bridge/python/smoke.py +47 -0
- package/bench/bridge/python/test_airline.py +28 -0
- package/bench/bridge/python/test_approval.py +57 -0
- package/bench/bridge/python/test_bfcl_multi_turn.py +185 -0
- package/bench/bridge/python/test_harbor_agent.py +107 -0
- package/bench/bridge/serve.ts +52 -0
- package/bench/bridge/transcript.ts +52 -0
- package/bench/bun-tsconfig.json +15 -0
- package/bench/cpu-main.mjs +216 -0
- package/bench/cpu.ts +457 -0
- package/bench/evidence/2026-09-28-review15.md +225 -0
- package/bench/evidence/redact-bun1.3.14.json +92 -0
- package/bench/evidence/redact-node24.11.json +92 -0
- package/bench/graph-metrics.ts +43 -0
- package/bench/harness.ts +73 -0
- package/bench/issue129-matrix.mjs +722 -0
- package/bench/redact-adversarial.ts +256 -0
- package/bench/repeat-cpu.mjs +281 -0
- package/bench/report.ts +1075 -0
- package/bench/skill-eval/harness.ts +30 -0
- package/bench/skill-eval/laws.ts +37 -0
- package/bench/skill-eval/mermaid/client.ts +132 -0
- package/bench/skill-eval/mermaid/validate.mjs +115 -0
- package/bench/skill-eval/pack.ts +61 -0
- package/bench/skill-eval/packs/diagrams/cases.ts +281 -0
- package/bench/skill-eval/packs/diagrams/index.ts +50 -0
- package/bench/skill-eval/packs/diagrams/score.ts +74 -0
- package/bench/skill-eval/packs/index.ts +5 -0
- package/bench/skill-eval/packs/weather/cases.ts +279 -0
- package/bench/skill-eval/packs/weather/fixtures/wttr.json +566 -0
- package/bench/skill-eval/packs/weather/fixtures.ts +84 -0
- package/bench/skill-eval/packs/weather/index.ts +55 -0
- package/bench/skill-eval/packs/weather/score.ts +92 -0
- package/bench/skill-eval/report.ts +194 -0
- package/bench/skill-eval/run.ts +316 -0
- package/bench/speed.ts +80 -0
- 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 [
|
|
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
|
|
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](
|
|
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
|
package/bench/README.md
ADDED
|
@@ -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
|
+
}
|