@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
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
// BFCL v4 single-turn categories through Harness.completion, scored by the ported AST checker
|
|
2
|
+
import fs from 'fs'
|
|
3
|
+
import path from 'path'
|
|
4
|
+
import process from 'process'
|
|
5
|
+
import { fileURLToPath } from 'url'
|
|
6
|
+
import { AbortController } from 'bare-abort-controller'
|
|
7
|
+
import type { Harness } from '../../index.ts'
|
|
8
|
+
import type { AgentConfig, LlmGenerationParams } from '../../lib/contract/index.ts'
|
|
9
|
+
import { QWEN_35_4B } from '../../lib/models/model-catalog-constants.ts'
|
|
10
|
+
import { ensureBundledSkillsDir } from '../../lib/tools/skills/bundled-dir.ts'
|
|
11
|
+
import { openBenchHarness } from '../harness.ts'
|
|
12
|
+
import { speedOf, type Speed } from '../speed.ts'
|
|
13
|
+
import { check, type Call, type Verdict } from './checker.ts'
|
|
14
|
+
import {
|
|
15
|
+
BFCL_COMMIT,
|
|
16
|
+
CATEGORIES,
|
|
17
|
+
loadCategory,
|
|
18
|
+
shapeOf,
|
|
19
|
+
type BfclCase,
|
|
20
|
+
type Shape
|
|
21
|
+
} from './data.ts'
|
|
22
|
+
import { observe } from './observe.ts'
|
|
23
|
+
import { caseTools } from './tools.ts'
|
|
24
|
+
|
|
25
|
+
const REPO_ROOT = fileURLToPath(new URL('../..', import.meta.url))
|
|
26
|
+
const REASONING_MODES = ['disabled', 'enabled', 'inherit'] as const
|
|
27
|
+
|
|
28
|
+
const USAGE = `usage: bare bench/bfcl/run.ts [options]
|
|
29
|
+
--categories <a,b> default ${CATEGORIES.join(',')}
|
|
30
|
+
--models <a,b> default ${QWEN_35_4B}
|
|
31
|
+
--limit <n> cases per category, spread evenly across it; default all
|
|
32
|
+
--timeout <ms> per case, default 120000
|
|
33
|
+
--reasoning <mode> disabled|enabled|inherit, default inherit
|
|
34
|
+
--out <dir> default output/bfcl/<stamp>
|
|
35
|
+
`
|
|
36
|
+
|
|
37
|
+
interface CaseRow {
|
|
38
|
+
readonly model: string
|
|
39
|
+
readonly category: string
|
|
40
|
+
readonly id: string
|
|
41
|
+
readonly shape: Shape
|
|
42
|
+
// BFCL's rule: every call in the model's first response
|
|
43
|
+
readonly valid: boolean
|
|
44
|
+
readonly error: string | null
|
|
45
|
+
// the harness asks for one action at a time, so parallel calls are credited across the run's rounds
|
|
46
|
+
readonly wholeValid: boolean
|
|
47
|
+
readonly wholeError: string | null
|
|
48
|
+
readonly calls: Call[]
|
|
49
|
+
readonly firstCallRound: number | null
|
|
50
|
+
readonly reaskParams: LlmGenerationParams | null
|
|
51
|
+
readonly reply: string
|
|
52
|
+
readonly wallMs: number
|
|
53
|
+
readonly speed: Speed | null
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const argv = process.argv.slice(2)
|
|
57
|
+
if (argv.includes('--help')) {
|
|
58
|
+
console.log(USAGE)
|
|
59
|
+
process.exit(0)
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const flag = (name: string): string | undefined => {
|
|
63
|
+
const index = argv.indexOf(`--${name}`)
|
|
64
|
+
return index >= 0 ? argv[index + 1] : undefined
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const categories = flag('categories')?.split(',') ?? CATEGORIES
|
|
68
|
+
const models = flag('models')?.split(',') ?? [QWEN_35_4B]
|
|
69
|
+
const limit = flag('limit') === undefined ? Infinity : Number(flag('limit'))
|
|
70
|
+
const timeoutMs = Number(flag('timeout') ?? 120_000)
|
|
71
|
+
const reasoning = REASONING_MODES.find((mode) => mode === flag('reasoning')) ?? 'inherit'
|
|
72
|
+
const stamp = new Date().toISOString().replace(/[:.]/g, '-')
|
|
73
|
+
const outDir = flag('out') ?? path.join(REPO_ROOT, 'output', 'bfcl', stamp)
|
|
74
|
+
const dataDir = path.join(REPO_ROOT, 'output', 'bfcl-data', BFCL_COMMIT)
|
|
75
|
+
|
|
76
|
+
fs.mkdirSync(outDir, { recursive: true })
|
|
77
|
+
const say = (line: string) => process.stdout.write(`${line}\n`)
|
|
78
|
+
// the SDK and addons print whole prompts through console; keep them out of the progress lines
|
|
79
|
+
const log = fs.openSync(path.join(outDir, 'bench.log'), 'a')
|
|
80
|
+
console.log =
|
|
81
|
+
console.info =
|
|
82
|
+
console.debug =
|
|
83
|
+
console.warn =
|
|
84
|
+
console.error =
|
|
85
|
+
(...args: unknown[]) => fs.writeSync(log, `${args.map(String).join(' ')}\n`)
|
|
86
|
+
|
|
87
|
+
process.env.QVAC_NO_SKILL_BIN_PROBE = '1'
|
|
88
|
+
const loaded = await Promise.all(
|
|
89
|
+
categories.map((category) => loadCategory(category, { dir: dataDir, limit }))
|
|
90
|
+
)
|
|
91
|
+
const cases = loaded.flat()
|
|
92
|
+
const harness = await openBenchHarness({
|
|
93
|
+
root: path.join(REPO_ROOT, 'output', 'bench', 'sdk-root'),
|
|
94
|
+
skillsDir: await ensureBundledSkillsDir()
|
|
95
|
+
})
|
|
96
|
+
say(`${cases.length} BFCL cases × ${models.length} models → ${outDir}`)
|
|
97
|
+
|
|
98
|
+
const rows: CaseRow[] = []
|
|
99
|
+
for (const model of models) {
|
|
100
|
+
for (const bfclCase of cases) {
|
|
101
|
+
const row = await runCase(harness, bfclCase, model)
|
|
102
|
+
rows.push(row)
|
|
103
|
+
fs.appendFileSync(path.join(outDir, 'runs.jsonl'), `${JSON.stringify(row)}\n`)
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const own = rows.filter((row) => row.model === model)
|
|
107
|
+
say(`${model}: strict ${rate(own, strictly)} · across rounds ${rate(own, wholly)}`)
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
fs.writeFileSync(path.join(outDir, 'summary.md'), summary(rows))
|
|
111
|
+
say(`\n${summary(rows)}\nwritten to ${outDir}`)
|
|
112
|
+
await harness.close()
|
|
113
|
+
|
|
114
|
+
async function runCase(host: Harness, bfclCase: BfclCase, model: string): Promise<CaseRow> {
|
|
115
|
+
const { tools, original } = caseTools(bfclCase.functions)
|
|
116
|
+
const system = bfclCase.turns.filter((turn) => turn.role === 'system')
|
|
117
|
+
// only the case's functions: no product tool, no skill
|
|
118
|
+
const agent: AgentConfig = {
|
|
119
|
+
id: 'bfcl',
|
|
120
|
+
toolsScope: 'selected',
|
|
121
|
+
enabledTools: [
|
|
122
|
+
...tools.map((tool) => tool.name),
|
|
123
|
+
...(process.env.QVAC_BENCH_CODE_MODE === '1' ? ['run_code'] : [])
|
|
124
|
+
],
|
|
125
|
+
skillsScope: 'selected',
|
|
126
|
+
enabledSkills: [],
|
|
127
|
+
bio: system.length ? system.map((turn) => turn.content).join('\n\n') : undefined,
|
|
128
|
+
reasoning
|
|
129
|
+
}
|
|
130
|
+
const controller = new AbortController()
|
|
131
|
+
const deadline = setTimeout(() => controller.abort(new Error('deadline')), timeoutMs)
|
|
132
|
+
const started = Date.now()
|
|
133
|
+
// only parallel work is credited past the first round, so other runs stop when the second begins
|
|
134
|
+
const across = bfclCase.category.includes('parallel')
|
|
135
|
+
const made = await observe(
|
|
136
|
+
host.completion({
|
|
137
|
+
model,
|
|
138
|
+
agent,
|
|
139
|
+
messages: bfclCase.turns.filter((turn) => turn.role !== 'system'),
|
|
140
|
+
tools,
|
|
141
|
+
cacheKey: `bfcl-${stamp}-${model}-${bfclCase.id}`,
|
|
142
|
+
captureInferenceCalls: true,
|
|
143
|
+
signal: controller.signal
|
|
144
|
+
}),
|
|
145
|
+
{
|
|
146
|
+
original,
|
|
147
|
+
stop: () => controller.abort(new Error('scored')),
|
|
148
|
+
// a parallel run is done once it has made every call BFCL expects; the rest after one round
|
|
149
|
+
enough: across ? (bfclCase.answers?.length ?? 0) : null
|
|
150
|
+
}
|
|
151
|
+
)
|
|
152
|
+
clearTimeout(deadline)
|
|
153
|
+
|
|
154
|
+
const subject = {
|
|
155
|
+
category: bfclCase.category,
|
|
156
|
+
docs: bfclCase.functions,
|
|
157
|
+
answers: bfclCase.answers
|
|
158
|
+
}
|
|
159
|
+
const score = (calls: Call[]): Verdict =>
|
|
160
|
+
made.error && !calls.length ? { valid: false, error: made.error } : check(subject, calls)
|
|
161
|
+
const strict = score(made.first)
|
|
162
|
+
// only parallel work can span rounds; a later call elsewhere is the model reacting to a stub result
|
|
163
|
+
const whole = across ? score(made.all) : strict
|
|
164
|
+
|
|
165
|
+
return {
|
|
166
|
+
model,
|
|
167
|
+
category: bfclCase.category,
|
|
168
|
+
id: bfclCase.id,
|
|
169
|
+
shape: shapeOf(bfclCase.functions),
|
|
170
|
+
valid: strict.valid,
|
|
171
|
+
error: strict.error,
|
|
172
|
+
wholeValid: whole.valid,
|
|
173
|
+
wholeError: whole.error,
|
|
174
|
+
calls: made.all,
|
|
175
|
+
firstCallRound: made.firstCallRound,
|
|
176
|
+
reaskParams: made.reaskParams,
|
|
177
|
+
reply: made.reply.slice(0, 500),
|
|
178
|
+
wallMs: Date.now() - started,
|
|
179
|
+
speed: speedOf(made.metrics)
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function strictly(row: CaseRow): boolean {
|
|
184
|
+
return row.valid
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function wholly(row: CaseRow): boolean {
|
|
188
|
+
return row.wholeValid
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function rate(rows: readonly CaseRow[], passed: (row: CaseRow) => boolean): string {
|
|
192
|
+
const count = rows.filter(passed).length
|
|
193
|
+
|
|
194
|
+
return `${count}/${rows.length} (${Math.round((count / Math.max(1, rows.length)) * 100)}%)`
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function summary(all: readonly CaseRow[]): string {
|
|
198
|
+
const names = [...new Set(all.map((row) => row.model))]
|
|
199
|
+
const header = names.map((model) => `${model} strict | ${model} across rounds`).join(' | ')
|
|
200
|
+
const lines = [
|
|
201
|
+
`| category | ${header} |`,
|
|
202
|
+
`| --- | ${names.map(() => '--- | ---').join(' | ')} |`
|
|
203
|
+
]
|
|
204
|
+
for (const category of categories) {
|
|
205
|
+
const cells = names.map((model) => {
|
|
206
|
+
const own = all.filter((row) => row.model === model && row.category === category)
|
|
207
|
+
return `${rate(own, strictly)} | ${rate(own, wholly)}`
|
|
208
|
+
})
|
|
209
|
+
lines.push(`| ${category} | ${cells.join(' | ')} |`)
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
return lines.join('\n') + '\n'
|
|
213
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
ToolDefinition,
|
|
3
|
+
ToolParameterSchema,
|
|
4
|
+
ToolParameterType,
|
|
5
|
+
ToolParameters
|
|
6
|
+
} from '../../lib/contract/index.ts'
|
|
7
|
+
import type { FunctionDoc, ParamDoc } from './checker.ts'
|
|
8
|
+
|
|
9
|
+
const TYPES: Record<string, ToolParameterType> = {
|
|
10
|
+
dict: 'object',
|
|
11
|
+
float: 'number',
|
|
12
|
+
tuple: 'array',
|
|
13
|
+
any: 'string',
|
|
14
|
+
integer: 'integer',
|
|
15
|
+
string: 'string',
|
|
16
|
+
boolean: 'boolean',
|
|
17
|
+
array: 'array'
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface CaseTools {
|
|
21
|
+
readonly tools: ToolDefinition[]
|
|
22
|
+
// the name a model saw back to BFCL's, which may carry dots
|
|
23
|
+
original(name: string): string
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// dots become underscores as BFCL's own function-calling handlers rename them for providers
|
|
27
|
+
export function caseTools(docs: readonly FunctionDoc[]): CaseTools {
|
|
28
|
+
const names = new Map(docs.map((doc) => [toolName(doc.name), doc.name]))
|
|
29
|
+
return {
|
|
30
|
+
tools: docs.map((doc): ToolDefinition => ({
|
|
31
|
+
name: toolName(doc.name),
|
|
32
|
+
toolset: null,
|
|
33
|
+
description: doc.description,
|
|
34
|
+
parameters: parameters(doc.parameters),
|
|
35
|
+
// the answer is the call itself; an intent field would be a parameter BFCL never asked for
|
|
36
|
+
autoApprove: true,
|
|
37
|
+
effect: () => 'read',
|
|
38
|
+
execute: () => Promise.resolve({ ok: true })
|
|
39
|
+
})),
|
|
40
|
+
original: (name) => names.get(name) ?? name
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function toolName(name: string): string {
|
|
45
|
+
return name.replace(/[^\w-]/g, '_')
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function parameters(doc: FunctionDoc['parameters']): ToolParameters {
|
|
49
|
+
return {
|
|
50
|
+
type: 'object',
|
|
51
|
+
properties: Object.fromEntries(
|
|
52
|
+
Object.entries(doc.properties).map(([key, value]) => [key, schema(value)])
|
|
53
|
+
),
|
|
54
|
+
required: [...(doc.required ?? [])]
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function schema(doc: ParamDoc): ToolParameterSchema {
|
|
59
|
+
return {
|
|
60
|
+
type: TYPES[doc.type] ?? 'string',
|
|
61
|
+
description: doc.description,
|
|
62
|
+
enum: doc.enum?.map(String),
|
|
63
|
+
items: doc.items ? schema(doc.items) : undefined,
|
|
64
|
+
properties: doc.properties
|
|
65
|
+
? Object.fromEntries(
|
|
66
|
+
Object.entries(doc.properties).map(([key, value]) => [key, schema(value)])
|
|
67
|
+
)
|
|
68
|
+
: undefined,
|
|
69
|
+
required: doc.required ? [...doc.required] : undefined,
|
|
70
|
+
default: doc.default
|
|
71
|
+
}
|
|
72
|
+
}
|
|
@@ -0,0 +1,392 @@
|
|
|
1
|
+
import { AbortController } from 'bare-abort-controller'
|
|
2
|
+
import type { CompletionContext } from '../../index.ts'
|
|
3
|
+
import type {
|
|
4
|
+
AgentConfig,
|
|
5
|
+
CompletionEvent,
|
|
6
|
+
Message,
|
|
7
|
+
MetricsEvent,
|
|
8
|
+
ToolDefinition,
|
|
9
|
+
ToolParameterSchema,
|
|
10
|
+
ToolParameters
|
|
11
|
+
} from '../../lib/contract/index.ts'
|
|
12
|
+
import { errorMessage } from '../../lib/guards.ts'
|
|
13
|
+
import { Transcript } from './transcript.ts'
|
|
14
|
+
|
|
15
|
+
// the native addon prints prompts to the same stdout, so a frame is a line that starts with this
|
|
16
|
+
export const FRAME = '\u001eqvac '
|
|
17
|
+
// a round that outlives this is a runaway generation; the benchmark sees it fail, as a user's cancel
|
|
18
|
+
export const ROUND_DEADLINE_MS = 180_000
|
|
19
|
+
|
|
20
|
+
// a tool the benchmark owns: the harness decides to call it, the benchmark's environment runs it
|
|
21
|
+
export interface ToolSpec {
|
|
22
|
+
readonly name: string
|
|
23
|
+
readonly description: string
|
|
24
|
+
readonly parameters: JsonParameters
|
|
25
|
+
// the benchmark's own environment changes on this call, so the run asks before it goes out
|
|
26
|
+
readonly write?: boolean | undefined
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
interface JsonSchema extends Omit<ToolParameterSchema, 'type' | 'items' | 'properties'> {
|
|
30
|
+
readonly type?: ToolParameterSchema['type']
|
|
31
|
+
readonly $ref?: string
|
|
32
|
+
readonly anyOf?: readonly JsonSchema[]
|
|
33
|
+
readonly items?: JsonSchema
|
|
34
|
+
readonly properties?: Readonly<Record<string, JsonSchema>>
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface JsonParameters extends Omit<ToolParameters, 'properties'> {
|
|
38
|
+
readonly $defs?: Readonly<Record<string, JsonSchema>>
|
|
39
|
+
readonly properties: Readonly<Record<string, JsonSchema>>
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface RunFrame {
|
|
43
|
+
readonly type: 'run'
|
|
44
|
+
readonly id: string
|
|
45
|
+
readonly model: string
|
|
46
|
+
readonly system?: string | undefined
|
|
47
|
+
readonly messages: Message[]
|
|
48
|
+
readonly tools: ToolSpec[]
|
|
49
|
+
// epoch ms the run should take as now, for a benchmark whose world is not today
|
|
50
|
+
readonly now?: number | undefined
|
|
51
|
+
// the run's round limit; absent, the harness default
|
|
52
|
+
readonly maxToolRounds?: number | undefined
|
|
53
|
+
// one key per conversation, as a host sends it, so a later turn reuses the earlier prefill
|
|
54
|
+
readonly cacheKey?: string | undefined
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export interface ResultFrame {
|
|
58
|
+
readonly type: 'result'
|
|
59
|
+
readonly callId: string
|
|
60
|
+
readonly result: unknown
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// a benchmark that ends a task mid-turn abandons the run; this ends it and its waiting calls
|
|
64
|
+
export interface CancelFrame {
|
|
65
|
+
readonly type: 'cancel'
|
|
66
|
+
readonly id: string
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// the user's answer to an approve frame; anything but approved runs nothing
|
|
70
|
+
export interface DecisionFrame {
|
|
71
|
+
readonly type: 'decision'
|
|
72
|
+
readonly callId: string
|
|
73
|
+
readonly approved: boolean
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// a conversation is over, so its cached prefill can go
|
|
77
|
+
export interface ForgetFrame {
|
|
78
|
+
readonly type: 'forget'
|
|
79
|
+
readonly cacheKey: string
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export type Inbound = RunFrame | ResultFrame | CancelFrame | DecisionFrame | ForgetFrame
|
|
83
|
+
|
|
84
|
+
interface ActiveRun {
|
|
85
|
+
readonly controller: AbortController
|
|
86
|
+
readonly calls: Set<string>
|
|
87
|
+
readonly approvals: Set<string>
|
|
88
|
+
readonly deadline: Restartable
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export interface CallFrame {
|
|
92
|
+
readonly type: 'call'
|
|
93
|
+
readonly id: string
|
|
94
|
+
readonly callId: string
|
|
95
|
+
readonly name: string
|
|
96
|
+
readonly args: Record<string, unknown>
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// a write waits on the benchmark's user, who sees the call and answers with a decision frame
|
|
100
|
+
export interface ApproveFrame {
|
|
101
|
+
readonly type: 'approve'
|
|
102
|
+
readonly id: string
|
|
103
|
+
readonly callId: string
|
|
104
|
+
readonly name: string
|
|
105
|
+
readonly args: Record<string, unknown>
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export interface DoneFrame {
|
|
109
|
+
readonly type: 'done'
|
|
110
|
+
readonly id: string
|
|
111
|
+
readonly reply: string
|
|
112
|
+
readonly calls: number
|
|
113
|
+
readonly error: string | null
|
|
114
|
+
// the run's speed, also when it was cancelled; null when no round finished
|
|
115
|
+
readonly metrics: MetricsEvent | null
|
|
116
|
+
// the run's turns as a host stores them, to carry the conversation into the next run
|
|
117
|
+
readonly history: Message[]
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// sent once the model runtime is open, so a client never writes into a process still booting
|
|
121
|
+
export interface ReadyFrame {
|
|
122
|
+
readonly type: 'ready'
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export type Outbound = ReadyFrame | CallFrame | ApproveFrame | DoneFrame
|
|
126
|
+
|
|
127
|
+
export type Complete = (context: CompletionContext) => AsyncIterable<CompletionEvent>
|
|
128
|
+
|
|
129
|
+
export interface BridgeOptions {
|
|
130
|
+
readonly roundDeadlineMs?: number
|
|
131
|
+
// offer the agent update_plan, to score a run with the plan against one without
|
|
132
|
+
readonly plan?: boolean
|
|
133
|
+
// drops a finished conversation's cached prefill, on a forget frame
|
|
134
|
+
readonly forget?: (cacheKey: string) => Promise<unknown>
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export class Bridge {
|
|
138
|
+
private readonly _complete: Complete
|
|
139
|
+
private readonly _send: (frame: Outbound) => void
|
|
140
|
+
private readonly _pending = new Map<string, (result: unknown) => void>()
|
|
141
|
+
private readonly _approvals = new Map<string, (approved: boolean) => void>()
|
|
142
|
+
private readonly _runs = new Map<string, ActiveRun>()
|
|
143
|
+
private readonly _roundDeadlineMs: number
|
|
144
|
+
private readonly _plan: boolean
|
|
145
|
+
private readonly _forget: (cacheKey: string) => Promise<unknown>
|
|
146
|
+
private _next = 0
|
|
147
|
+
|
|
148
|
+
constructor(complete: Complete, send: (frame: Outbound) => void, opts: BridgeOptions = {}) {
|
|
149
|
+
this._complete = complete
|
|
150
|
+
this._send = send
|
|
151
|
+
this._roundDeadlineMs = opts.roundDeadlineMs ?? ROUND_DEADLINE_MS
|
|
152
|
+
this._plan = opts.plan === true
|
|
153
|
+
this._forget = opts.forget ?? (() => Promise.resolve())
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
receive(frame: Inbound): Promise<void> {
|
|
157
|
+
if (frame.type === 'result') {
|
|
158
|
+
this._settle(frame.callId, frame.result)
|
|
159
|
+
return Promise.resolve()
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
if (frame.type === 'decision') {
|
|
163
|
+
this._decide(frame)
|
|
164
|
+
return Promise.resolve()
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
if (frame.type === 'cancel') {
|
|
168
|
+
this._cancel(frame.id)
|
|
169
|
+
return Promise.resolve()
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
if (frame.type === 'forget') {
|
|
173
|
+
return this._forget(frame.cacheKey).then(() => {})
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
return this._run(frame)
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
private async _run(frame: RunFrame): Promise<void> {
|
|
180
|
+
const run: ActiveRun = {
|
|
181
|
+
controller: new AbortController(),
|
|
182
|
+
calls: new Set<string>(),
|
|
183
|
+
approvals: new Set<string>(),
|
|
184
|
+
// a long turn of many short rounds is legitimate, so the clock restarts at each round
|
|
185
|
+
deadline: restartable(() => this._cancel(frame.id), this._roundDeadlineMs)
|
|
186
|
+
}
|
|
187
|
+
this._runs.set(frame.id, run)
|
|
188
|
+
const tools = frame.tools.map((spec) => this._remote(run, frame.id, spec))
|
|
189
|
+
// only the benchmark's tools: no product tool, no skill
|
|
190
|
+
const agent: AgentConfig = {
|
|
191
|
+
id: 'bridge',
|
|
192
|
+
toolsScope: 'selected',
|
|
193
|
+
enabledTools: tools.map((tool) => tool.name),
|
|
194
|
+
skillsScope: 'selected',
|
|
195
|
+
enabledSkills: [],
|
|
196
|
+
bio: frame.system,
|
|
197
|
+
plan: this._plan
|
|
198
|
+
}
|
|
199
|
+
let reply = ''
|
|
200
|
+
let calls = 0
|
|
201
|
+
let error: string | null = null
|
|
202
|
+
let metrics: MetricsEvent | null = null
|
|
203
|
+
const history = new Transcript()
|
|
204
|
+
const { deadline } = run
|
|
205
|
+
const now = frame.now
|
|
206
|
+
try {
|
|
207
|
+
const events = this._complete({
|
|
208
|
+
model: frame.model,
|
|
209
|
+
agent,
|
|
210
|
+
messages: frame.messages,
|
|
211
|
+
tools,
|
|
212
|
+
now: now === undefined ? undefined : () => now,
|
|
213
|
+
maxToolRounds: frame.maxToolRounds,
|
|
214
|
+
cacheKey: frame.cacheKey,
|
|
215
|
+
signal: run.controller.signal,
|
|
216
|
+
// the wait for the user is not the model's time, so the round clock stops until it answers
|
|
217
|
+
approve: (call) =>
|
|
218
|
+
new Promise<boolean>((resolve) => {
|
|
219
|
+
const callId = `${frame.id}:${this._next++}`
|
|
220
|
+
deadline.clear()
|
|
221
|
+
run.approvals.add(callId)
|
|
222
|
+
this._approvals.set(callId, (approved) => {
|
|
223
|
+
run.approvals.delete(callId)
|
|
224
|
+
deadline.restart()
|
|
225
|
+
resolve(approved)
|
|
226
|
+
})
|
|
227
|
+
this._send({
|
|
228
|
+
type: 'approve',
|
|
229
|
+
id: frame.id,
|
|
230
|
+
callId,
|
|
231
|
+
name: call.name,
|
|
232
|
+
args: call.arguments
|
|
233
|
+
})
|
|
234
|
+
})
|
|
235
|
+
})
|
|
236
|
+
for await (const event of events) {
|
|
237
|
+
history.add(event)
|
|
238
|
+
// the answer is what follows the last tool result; content before it is narration
|
|
239
|
+
if (event.type === 'tool-result' || event.type === 'round-reset') {
|
|
240
|
+
reply = ''
|
|
241
|
+
deadline.restart()
|
|
242
|
+
} else if (event.type === 'content') {
|
|
243
|
+
reply += event.text
|
|
244
|
+
} else if (event.type === 'tool-call') {
|
|
245
|
+
calls++
|
|
246
|
+
} else if (event.type === 'error') {
|
|
247
|
+
error = event.message
|
|
248
|
+
} else if (event.type === 'metrics') {
|
|
249
|
+
metrics = event
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
} catch (err) {
|
|
253
|
+
error = errorMessage(err)
|
|
254
|
+
} finally {
|
|
255
|
+
deadline.clear()
|
|
256
|
+
this._runs.delete(frame.id)
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
this._send({
|
|
260
|
+
type: 'done',
|
|
261
|
+
id: frame.id,
|
|
262
|
+
reply,
|
|
263
|
+
calls,
|
|
264
|
+
error,
|
|
265
|
+
metrics,
|
|
266
|
+
history: history.turns()
|
|
267
|
+
})
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
private _settle(callId: string, result: unknown): void {
|
|
271
|
+
this._pending.get(callId)?.(result)
|
|
272
|
+
this._pending.delete(callId)
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
private _decide({ callId, approved }: Omit<DecisionFrame, 'type'>): void {
|
|
276
|
+
this._approvals.get(callId)?.(approved)
|
|
277
|
+
this._approvals.delete(callId)
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
private _cancel(id: string): void {
|
|
281
|
+
const run = this._runs.get(id)
|
|
282
|
+
if (!run) {
|
|
283
|
+
return
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
run.controller.abort(new Error('cancelled'))
|
|
287
|
+
for (const callId of run.calls) {
|
|
288
|
+
this._settle(callId, { error: 'cancelled' })
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
for (const callId of run.approvals) {
|
|
292
|
+
this._decide({ callId, approved: false })
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
private _remote(run: ActiveRun, id: string, spec: ToolSpec): ToolDefinition {
|
|
297
|
+
return {
|
|
298
|
+
name: spec.name,
|
|
299
|
+
toolset: null,
|
|
300
|
+
description: spec.description,
|
|
301
|
+
parameters: typedParameters(spec.parameters),
|
|
302
|
+
// the benchmark owns the sandbox; only a write it names waits on its user
|
|
303
|
+
autoApprove: spec.write !== true,
|
|
304
|
+
effect: () => (spec.write === true ? 'write' : 'read'),
|
|
305
|
+
// the benchmark's environment runs the call, not the model, so the round clock waits for it
|
|
306
|
+
execute: (args) =>
|
|
307
|
+
new Promise((resolve) => {
|
|
308
|
+
const callId = `${id}:${this._next++}`
|
|
309
|
+
run.deadline.clear()
|
|
310
|
+
run.calls.add(callId)
|
|
311
|
+
this._pending.set(callId, (result) => {
|
|
312
|
+
run.calls.delete(callId)
|
|
313
|
+
// a result that comes back after its run ended, or was cancelled, starts no clock
|
|
314
|
+
if (
|
|
315
|
+
run.calls.size === 0 &&
|
|
316
|
+
this._runs.get(id) === run &&
|
|
317
|
+
!run.controller.signal.aborted
|
|
318
|
+
) {
|
|
319
|
+
run.deadline.restart()
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
resolve(result)
|
|
323
|
+
})
|
|
324
|
+
this._send({ type: 'call', id, callId, name: spec.name, args })
|
|
325
|
+
})
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
// τ² sends pydantic schemas; inline $ref and anyOf so the harness can describe nested fields
|
|
331
|
+
export function typedParameters({
|
|
332
|
+
$defs: defs = {},
|
|
333
|
+
properties,
|
|
334
|
+
...rest
|
|
335
|
+
}: JsonParameters): ToolParameters {
|
|
336
|
+
return { ...rest, properties: typedProperties(properties, defs) }
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
// A node with no type (pydantic's Any, or an anyOf of only null) is dropped: the harness needs one.
|
|
340
|
+
function typed(
|
|
341
|
+
schema: JsonSchema,
|
|
342
|
+
defs: Readonly<Record<string, JsonSchema>>
|
|
343
|
+
): ToolParameterSchema | undefined {
|
|
344
|
+
const { $ref: ref, anyOf, items, properties, ...rest } = schema
|
|
345
|
+
const base = ref === undefined ? undefined : defs[ref.slice(ref.lastIndexOf('/') + 1)]
|
|
346
|
+
const branch = anyOf?.find((option) => option.type !== 'null')
|
|
347
|
+
if (base ?? branch) {
|
|
348
|
+
return typed({ ...(base ?? branch), ...rest }, defs)
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
if (rest.type === undefined) {
|
|
352
|
+
return undefined
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
return {
|
|
356
|
+
...rest,
|
|
357
|
+
type: rest.type,
|
|
358
|
+
items: items && typed(items, defs),
|
|
359
|
+
properties: properties && typedProperties(properties, defs)
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
function typedProperties(
|
|
364
|
+
properties: Readonly<Record<string, JsonSchema>>,
|
|
365
|
+
defs: Readonly<Record<string, JsonSchema>>
|
|
366
|
+
): Record<string, ToolParameterSchema> {
|
|
367
|
+
return Object.fromEntries(
|
|
368
|
+
Object.entries(properties).flatMap(([key, value]) => {
|
|
369
|
+
const schema = typed(value, defs)
|
|
370
|
+
return schema ? [[key, schema]] : []
|
|
371
|
+
})
|
|
372
|
+
)
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
interface Restartable {
|
|
376
|
+
restart(): void
|
|
377
|
+
clear(): void
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
function restartable(fire: () => void, ms: number): Restartable {
|
|
381
|
+
let timer = setTimeout(fire, ms)
|
|
382
|
+
|
|
383
|
+
return {
|
|
384
|
+
restart() {
|
|
385
|
+
clearTimeout(timer)
|
|
386
|
+
timer = setTimeout(fire, ms)
|
|
387
|
+
},
|
|
388
|
+
clear() {
|
|
389
|
+
clearTimeout(timer)
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
}
|