@tangle-network/agent-bench 0.1.0
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 +15 -0
- package/package.json +35 -0
- package/src/adapters.ts +60 -0
- package/src/aec-gate.mts +217 -0
- package/src/atom-humaneval.mts +197 -0
- package/src/atom-mcp-e2e.mts +223 -0
- package/src/benchmarks/_harness.ts +206 -0
- package/src/benchmarks/aec-bench.test.mts +53 -0
- package/src/benchmarks/aec-bench.ts +319 -0
- package/src/benchmarks/appworld.test.mts +45 -0
- package/src/benchmarks/appworld.ts +426 -0
- package/src/benchmarks/cad-design.ts +429 -0
- package/src/benchmarks/cadbench.ts +135 -0
- package/src/benchmarks/cadgenbench.ts +121 -0
- package/src/benchmarks/commit0.test.mts +71 -0
- package/src/benchmarks/commit0.ts +266 -0
- package/src/benchmarks/enterpriseops-gym.test.mts +77 -0
- package/src/benchmarks/enterpriseops-gym.ts +343 -0
- package/src/benchmarks/finsearchcomp.ts +371 -0
- package/src/benchmarks/frames.ts +520 -0
- package/src/benchmarks/hotpotqa.ts +320 -0
- package/src/benchmarks/humaneval.ts +251 -0
- package/src/benchmarks/mind2web.ts +311 -0
- package/src/benchmarks/programbench.test.mts +60 -0
- package/src/benchmarks/programbench.ts +210 -0
- package/src/benchmarks/simpleqa.ts +390 -0
- package/src/benchmarks/swe-bench.ts +152 -0
- package/src/benchmarks/terminal-bench.ts +182 -0
- package/src/benchmarks/trata-hedge.ts +496 -0
- package/src/benchmarks/types.ts +58 -0
- package/src/browser/adapters/bad-design-audit.ts +153 -0
- package/src/browser/adapters/bad-design-audit.verify.ts +85 -0
- package/src/browser/adapters/bad.ts +165 -0
- package/src/browser/agent-adapter.ts +145 -0
- package/src/browser/process-adapter.ts +146 -0
- package/src/browser/run-to-spans.ts +105 -0
- package/src/browser/run-to-spans.verify.ts +89 -0
- package/src/browser/ui-reviewer.ts +200 -0
- package/src/browser/ui-reviewer.verify.ts +94 -0
- package/src/browser/verify.ts +96 -0
- package/src/clbench-codebase-gate.mts +314 -0
- package/src/clbench-context-gate.mts +305 -0
- package/src/cloud-loop.mts +138 -0
- package/src/coding-skills/minimal-diff.md +9 -0
- package/src/coding-skills/read-before-edit.md +9 -0
- package/src/coding-skills/reproduce-first.md +10 -0
- package/src/coding-skills/run-tests-after-edit.md +9 -0
- package/src/coding-skills/trace-the-failure.md +9 -0
- package/src/commit0-env-run.mts +59 -0
- package/src/commit0-env.ts +173 -0
- package/src/commit0-gate.mts +529 -0
- package/src/commit0-prereqs.sh +48 -0
- package/src/corpus-replay.mts +300 -0
- package/src/corpus-report.mts +504 -0
- package/src/corpus.test.mts +273 -0
- package/src/corpus.ts +325 -0
- package/src/decoder-live.mts +133 -0
- package/src/directives.ts +84 -0
- package/src/diverse-gate.mjs +112 -0
- package/src/egress-probe.mts +26 -0
- package/src/eops-skills/address-every-subtask.md +5 -0
- package/src/eops-skills/exact-tools-and-args.md +5 -0
- package/src/eops-skills/full-sequence-to-goal.md +5 -0
- package/src/eops-skills/ground-every-value.md +5 -0
- package/src/eops-skills/honor-the-policies.md +5 -0
- package/src/examples/README.md +58 -0
- package/src/examples/math-demo.mts +110 -0
- package/src/examples/strategy-demo.mts +119 -0
- package/src/fleet.mts +121 -0
- package/src/gate-cli.mts +101 -0
- package/src/gate.test.mts +129 -0
- package/src/gate.ts +460 -0
- package/src/generate-eval/certify.ts +178 -0
- package/src/generate-eval/schema.ts +78 -0
- package/src/humaneval-gate.mts +204 -0
- package/src/humaneval-repair-gate.mts +143 -0
- package/src/index.ts +19 -0
- package/src/mcp-mount-probe.mts +126 -0
- package/src/profile-coordinates.ts +134 -0
- package/src/profiles.ts +128 -0
- package/src/refine-loop.test.mts +106 -0
- package/src/refine-loop.ts +106 -0
- package/src/research-gate.mts +132 -0
- package/src/research-shot.ts +134 -0
- package/src/resolve-client.ts +58 -0
- package/src/router-executor.ts +51 -0
- package/src/run-pool.ts +48 -0
- package/src/runtime-hook-recorder.ts +137 -0
- package/src/sandbox-run.ts +125 -0
- package/src/search-bench/bridge.ts +124 -0
- package/src/search-bench/export.mts +0 -0
- package/src/search-bench/parametric-check.mts +63 -0
- package/src/search-bench/profiles.ts +98 -0
- package/src/search-bench/run.mts +287 -0
- package/src/search-bench/tasks-fresh.ts +688 -0
- package/src/search-bench/tasks.ts +129 -0
- package/src/search-tool.ts +95 -0
- package/src/selector.test.mts +189 -0
- package/src/selector.ts +366 -0
- package/src/skill-sandbox-smoke.mts +100 -0
- package/src/stats.mts +90 -0
- package/src/terminal-compare.ts +519 -0
- package/src/trajectory-assemble.mjs +130 -0
- package/src/trata-gate.mts +243 -0
- package/src/trata-gepa.mts +434 -0
- package/src/worker-blender.ts +230 -0
- package/src/worker-browser.ts +102 -0
- package/src/worker-build123d.ts +143 -0
- package/src/worker-cad.ts +451 -0
- package/src/worker.ts +136 -0
- package/src/workspace-loop.mts +133 -0
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Discrimination check: how many fresh-docs tasks does a model get WRONG from
|
|
3
|
+
* memory (no tools, no search)? A high parametric fail-rate is the prerequisite
|
|
4
|
+
* for the whole comparison — if the model already knows the answer, no search
|
|
5
|
+
* backend can help. This is a pure router chat-completion per task, scored by
|
|
6
|
+
* the same deterministic oracle the sandbox run uses. No sandbox, no deploy dep.
|
|
7
|
+
*
|
|
8
|
+
* Run:
|
|
9
|
+
* dotenvx run -f ~/company/devops/secrets/.env.keys -f ~/company/devops/secrets/agent-state.env -- \
|
|
10
|
+
* env MODEL=gpt-4.1 CONCURRENCY=4 pnpm exec tsx src/search-bench/parametric-check.mts
|
|
11
|
+
*/
|
|
12
|
+
import { writeFileSync } from 'node:fs'
|
|
13
|
+
import { runPool } from '../run-pool'
|
|
14
|
+
import { routerChatWithUsage } from '@tangle-network/agent-runtime/loops'
|
|
15
|
+
import { freshTasks } from './tasks-fresh'
|
|
16
|
+
import { scoreTask, taskToPrompt } from './tasks'
|
|
17
|
+
|
|
18
|
+
async function main(): Promise<void> {
|
|
19
|
+
const model = process.env.MODEL ?? 'deepseek-v4-flash'
|
|
20
|
+
const routerKey = process.env.TANGLE_API_KEY
|
|
21
|
+
if (!routerKey) throw new Error('missing TANGLE_API_KEY')
|
|
22
|
+
const cfg = {
|
|
23
|
+
routerBaseUrl: process.env.ROUTER_BASE_URL ?? 'https://router.tangle.tools/v1',
|
|
24
|
+
routerKey,
|
|
25
|
+
model,
|
|
26
|
+
}
|
|
27
|
+
const conc = Number(process.env.CONCURRENCY ?? 4)
|
|
28
|
+
|
|
29
|
+
const outcomes = await runPool(freshTasks, conc, async (task) => {
|
|
30
|
+
try {
|
|
31
|
+
const res = await routerChatWithUsage(cfg, [{ role: 'user', content: taskToPrompt(task) }])
|
|
32
|
+
const { score } = scoreTask(task, res.content)
|
|
33
|
+
return { id: task.id, score: score as 0 | 1 | null, cost: res.costUsd, err: undefined as string | undefined }
|
|
34
|
+
} catch (err) {
|
|
35
|
+
return { id: task.id, score: null as 0 | 1 | null, cost: undefined, err: err instanceof Error ? err.message : String(err) }
|
|
36
|
+
}
|
|
37
|
+
})
|
|
38
|
+
const rows = outcomes.map((o) => o.value!).filter(Boolean)
|
|
39
|
+
|
|
40
|
+
const scored = rows.filter((r) => r.score !== null)
|
|
41
|
+
const passed = scored.filter((r) => r.score === 1).length
|
|
42
|
+
const failed = scored.length - passed
|
|
43
|
+
console.log(`\n=== Parametric (no-search) discrimination · ${model} · n=${scored.length} ===`)
|
|
44
|
+
for (const r of rows.sort((a, b) => (a.score ?? -1) - (b.score ?? -1))) {
|
|
45
|
+
console.log(` ${r.score === null ? 'ERR ' : r.score === 1 ? 'KNEW' : 'MISS'} ${r.id}${r.err ? ` (${r.err})` : ''}`)
|
|
46
|
+
}
|
|
47
|
+
console.log(
|
|
48
|
+
`\nparametric FAIL (search-correctable headroom): ${failed}/${scored.length} = ${((failed / scored.length) * 100).toFixed(0)}%` +
|
|
49
|
+
` · model already knew: ${passed}/${scored.length}`,
|
|
50
|
+
)
|
|
51
|
+
if (process.env.PARAM_OUT) {
|
|
52
|
+
writeFileSync(
|
|
53
|
+
process.env.PARAM_OUT,
|
|
54
|
+
rows.map((r) => JSON.stringify({ id: r.id, model, knewParametrically: r.score === 1, score: r.score })).join('\n') + '\n',
|
|
55
|
+
)
|
|
56
|
+
console.log(`wrote per-task baseline → ${process.env.PARAM_OUT}`)
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
main().catch((e) => {
|
|
61
|
+
console.error(e)
|
|
62
|
+
process.exit(1)
|
|
63
|
+
})
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-arm AgentProfiles for the coding-harness web-search comparison.
|
|
3
|
+
*
|
|
4
|
+
* Three arms, one variable (the search backend the agent can reach):
|
|
5
|
+
* - `native` — the harness's built-in web search/fetch, untouched. No MCP.
|
|
6
|
+
* - `provider` — native search DISABLED + a router-backed search MCP added
|
|
7
|
+
* (provider-pinned: you / exa / …). The agent's only web access is the MCP.
|
|
8
|
+
*
|
|
9
|
+
* The disable directive is the harness-agnostic `tools` map landed in
|
|
10
|
+
* agent-dev-container#1810 (claude `--disallowed-tools`, codex
|
|
11
|
+
* `-c tools.web_search=false`, opencode `tools.{websearch,webfetch}`). We set
|
|
12
|
+
* both canonical (`web_search`/`web_fetch`) and opencode-native
|
|
13
|
+
* (`websearch`/`webfetch`) keys so a single profile is correct on every harness
|
|
14
|
+
* pre- and post-deploy, plus the opencode `permission.webfetch=deny` belt.
|
|
15
|
+
*
|
|
16
|
+
* The provider MCP mirrors the SDK's official `buildTangleRouterSearchProfile`
|
|
17
|
+
* shape — a `transport:'http'` server at the router's `/v1/search/mcp` endpoint,
|
|
18
|
+
* provider pinned via the `?provider=` query param.
|
|
19
|
+
*/
|
|
20
|
+
import type { AgentProfile } from '@tangle-network/sandbox'
|
|
21
|
+
|
|
22
|
+
export type SearchArm = 'native' | 'off' | { provider: string }
|
|
23
|
+
|
|
24
|
+
const routerSearchMcpUrl = (provider: string, routerBaseUrl: string): string => {
|
|
25
|
+
// routerBaseUrl is typically https://router.tangle.tools/v1 — the search MCP
|
|
26
|
+
// lives at /v1/search/mcp, so trim a trailing /v1 then re-append the path.
|
|
27
|
+
const root = routerBaseUrl.replace(/\/+$/, '').replace(/\/v1$/, '')
|
|
28
|
+
return `${root}/v1/search/mcp?provider=${encodeURIComponent(provider)}`
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Tool keys that turn OFF a harness's native web search/fetch across harnesses. */
|
|
32
|
+
const nativeWebToolsDisabled: Record<string, boolean> = {
|
|
33
|
+
web_search: false,
|
|
34
|
+
web_fetch: false,
|
|
35
|
+
websearch: false,
|
|
36
|
+
webfetch: false,
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface BuildArmProfileArgs {
|
|
40
|
+
arm: SearchArm
|
|
41
|
+
/** Router base URL (…/v1). Used to derive the search-MCP endpoint. */
|
|
42
|
+
routerBaseUrl: string
|
|
43
|
+
/** Bearer for the router search MCP. Required for a provider arm. Valid for the
|
|
44
|
+
* `bridge` (local) backend; in `sandbox` mode the box egress proxy rejects
|
|
45
|
+
* foreign router credentials (403), so the provider arm needs the box-side
|
|
46
|
+
* credential flow before sandbox-backed provider runs are trustworthy. */
|
|
47
|
+
tangleApiKey: string
|
|
48
|
+
/** Optional name/metadata to merge. */
|
|
49
|
+
name?: string
|
|
50
|
+
metadata?: Record<string, unknown>
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Build the AgentProfile fragment (tools / permission / mcp) for one search arm.
|
|
55
|
+
* Returned as a partial profile to be spread into `sandboxAgentRun({ profile })`.
|
|
56
|
+
*/
|
|
57
|
+
export function buildArmProfile(args: BuildArmProfileArgs): AgentProfile {
|
|
58
|
+
const { arm, routerBaseUrl, tangleApiKey } = args
|
|
59
|
+
const base: AgentProfile = {
|
|
60
|
+
name: args.name ?? 'search-bench-worker',
|
|
61
|
+
...(args.metadata ? { metadata: args.metadata } : {}),
|
|
62
|
+
} as AgentProfile
|
|
63
|
+
|
|
64
|
+
if (arm === 'native') {
|
|
65
|
+
// Native web tools stay on (harness default). No search MCP. For codex,
|
|
66
|
+
// whose web_search ships off, explicitly enable it so the native arm is real.
|
|
67
|
+
return { ...base, tools: { web_search: true } } as AgentProfile
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
if (arm === 'off') {
|
|
71
|
+
// No web access at all — the parametric floor (search contributes nothing).
|
|
72
|
+
return { ...base, tools: { ...nativeWebToolsDisabled }, permission: { webfetch: 'deny' } } as AgentProfile
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
if (!tangleApiKey) {
|
|
76
|
+
throw new Error(`buildArmProfile: provider arm "${arm.provider}" requires a tangleApiKey for the search MCP`)
|
|
77
|
+
}
|
|
78
|
+
return {
|
|
79
|
+
...base,
|
|
80
|
+
tools: { ...nativeWebToolsDisabled },
|
|
81
|
+
permission: { webfetch: 'deny' },
|
|
82
|
+
mcp: {
|
|
83
|
+
tangle_search: {
|
|
84
|
+
transport: 'http',
|
|
85
|
+
url: routerSearchMcpUrl(arm.provider, routerBaseUrl),
|
|
86
|
+
headers: { Authorization: `Bearer ${tangleApiKey}` },
|
|
87
|
+
enabled: true,
|
|
88
|
+
},
|
|
89
|
+
},
|
|
90
|
+
} as AgentProfile
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Stable condition label for the corpus: `<harness>:<arm>`. */
|
|
94
|
+
export function armLabel(arm: SearchArm): string {
|
|
95
|
+
if (arm === 'native') return 'native'
|
|
96
|
+
if (arm === 'off') return 'off'
|
|
97
|
+
return arm.provider
|
|
98
|
+
}
|
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Coding-harness × web-search-backend benchmark runner.
|
|
3
|
+
*
|
|
4
|
+
* For each (task × harness × arm) cell: spin up a live sandbox, run the coding
|
|
5
|
+
* task once through the harness with that arm's AgentProfile (native search /
|
|
6
|
+
* provider-MCP+native-disabled), score the answer with the task's deterministic
|
|
7
|
+
* oracle, and record score + cost + tokens + latency + tool-call count +
|
|
8
|
+
* citations. Cells are concurrency-bounded and fault-isolated; an infra-errored
|
|
9
|
+
* cell is recorded as such and excluded from rates (never a silent zero).
|
|
10
|
+
*
|
|
11
|
+
* Run (opencode, you.com vs native, seed tasks):
|
|
12
|
+
* dotenvx run -f ~/company/devops/secrets/.env.keys -f ~/company/devops/secrets/agent-state.env -- \
|
|
13
|
+
* env HARNESSES=opencode ARMS=native,you MODEL=opencode/deepseek/deepseek-v4-flash \
|
|
14
|
+
* OUT=/tmp/search-bench.jsonl pnpm exec tsx src/search-bench/run.mts
|
|
15
|
+
*/
|
|
16
|
+
import { appendFileSync, mkdirSync, writeFileSync } from 'node:fs'
|
|
17
|
+
import { dirname } from 'node:path'
|
|
18
|
+
import { extractLlmCallEvent, openSandboxRun } from '@tangle-network/agent-runtime/loops'
|
|
19
|
+
import { Sandbox, type SandboxEvent } from '@tangle-network/sandbox'
|
|
20
|
+
import { answerOutput, sandboxAgentRun, type WorkerBackendType } from '../sandbox-run'
|
|
21
|
+
import { type BridgeCfg, runBridgeCell } from './bridge'
|
|
22
|
+
import { type SearchArm, armLabel, buildArmProfile } from './profiles'
|
|
23
|
+
import { freshTasks } from './tasks-fresh'
|
|
24
|
+
import { type SearchTask, scoreTask, seedTasks, taskToPrompt } from './tasks'
|
|
25
|
+
|
|
26
|
+
export interface SearchCellResult {
|
|
27
|
+
taskId: string
|
|
28
|
+
domain: string
|
|
29
|
+
harness: string
|
|
30
|
+
/** Search arm: 'native' | 'off' | provider id (e.g. 'you', 'exa'). */
|
|
31
|
+
arm: string
|
|
32
|
+
model: string
|
|
33
|
+
/** Deterministic oracle outcome. null ⇒ infra error (excluded from rates). */
|
|
34
|
+
score: 0 | 1 | null
|
|
35
|
+
reasons: string[]
|
|
36
|
+
costUsd?: number
|
|
37
|
+
tokensIn?: number
|
|
38
|
+
tokensOut?: number
|
|
39
|
+
wallMs: number
|
|
40
|
+
/** Distinct tool calls observed (evidence the agent actually searched). */
|
|
41
|
+
toolCalls: number
|
|
42
|
+
/** Tool names used — distinguishes native (`webfetch`) from the provider MCP. */
|
|
43
|
+
toolNames: string[]
|
|
44
|
+
/** Distinct http(s) URLs cited in the answer. */
|
|
45
|
+
citations: string[]
|
|
46
|
+
/** Final answer text (truncated in the export; full in the raw JSONL). */
|
|
47
|
+
answer: string
|
|
48
|
+
infraError?: string
|
|
49
|
+
ts: string
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const urlRe = /https?:\/\/[^\s)\]}"'<>]+/gi
|
|
53
|
+
|
|
54
|
+
function extractCitations(answer: string): string[] {
|
|
55
|
+
const seen = new Set<string>()
|
|
56
|
+
for (const m of answer.match(urlRe) ?? []) seen.add(m.replace(/[.,;]+$/, ''))
|
|
57
|
+
return [...seen]
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Sum token usage + cost across the run's llm_call events (the kernel's ledger). */
|
|
61
|
+
function tally(events: SandboxEvent[]): { costUsd?: number; tokensIn?: number; tokensOut?: number } {
|
|
62
|
+
let costUsd = 0
|
|
63
|
+
let tokensIn = 0
|
|
64
|
+
let tokensOut = 0
|
|
65
|
+
let any = false
|
|
66
|
+
for (const ev of events) {
|
|
67
|
+
const call = extractLlmCallEvent(ev as never, 'search-bench')
|
|
68
|
+
if (!call) continue
|
|
69
|
+
any = true
|
|
70
|
+
costUsd += call.costUsd ?? 0
|
|
71
|
+
tokensIn += call.tokensIn ?? 0
|
|
72
|
+
tokensOut += call.tokensOut ?? 0
|
|
73
|
+
}
|
|
74
|
+
return any ? { costUsd, tokensIn, tokensOut } : {}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Distinct tool calls + the tool names used. opencode emits a tool call as
|
|
79
|
+
* repeated `message.part.updated` events sharing a `data.part.callID` with
|
|
80
|
+
* `data.part.type==='tool'` and `data.part.tool` the name (e.g. `webfetch`,
|
|
81
|
+
* or an MCP tool like `tangle_search_web_search`). We dedupe by callID so a
|
|
82
|
+
* single call counts once, and surface the names so the export can show which
|
|
83
|
+
* search backend the agent actually used (native vs the provider MCP).
|
|
84
|
+
*/
|
|
85
|
+
function extractTools(events: SandboxEvent[]): { count: number; names: string[] } {
|
|
86
|
+
const calls = new Map<string, string>()
|
|
87
|
+
for (const ev of events) {
|
|
88
|
+
const part = (ev as { data?: { part?: Record<string, unknown> } }).data?.part
|
|
89
|
+
if (!part || typeof part !== 'object' || part.type !== 'tool') continue
|
|
90
|
+
const callId = typeof part.callID === 'string' ? part.callID : `${calls.size}`
|
|
91
|
+
const name = typeof part.tool === 'string' ? part.tool : 'unknown'
|
|
92
|
+
calls.set(callId, name)
|
|
93
|
+
}
|
|
94
|
+
return { count: calls.size, names: [...new Set(calls.values())] }
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export interface RunCfg {
|
|
98
|
+
tasks: SearchTask[]
|
|
99
|
+
harnesses: WorkerBackendType[]
|
|
100
|
+
arms: SearchArm[]
|
|
101
|
+
model: string
|
|
102
|
+
routerBaseUrl: string
|
|
103
|
+
tangleApiKey: string
|
|
104
|
+
sandboxBaseUrl: string
|
|
105
|
+
sandboxKey: string
|
|
106
|
+
outPath: string
|
|
107
|
+
/** In-box model provider — `openai` (default) or `openai-compat` for cheap models. */
|
|
108
|
+
provider?: string
|
|
109
|
+
/** Execution surface: `sandbox` (live box) or `bridge` (local cli-bridge). */
|
|
110
|
+
backend?: 'sandbox' | 'bridge'
|
|
111
|
+
/** cli-bridge wiring, required when `backend === 'bridge'`. */
|
|
112
|
+
bridge?: BridgeCfg
|
|
113
|
+
concurrency?: number
|
|
114
|
+
timeoutMs?: number
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
async function runCell(
|
|
118
|
+
cfg: RunCfg,
|
|
119
|
+
client: Sandbox,
|
|
120
|
+
task: SearchTask,
|
|
121
|
+
harness: WorkerBackendType,
|
|
122
|
+
arm: SearchArm,
|
|
123
|
+
): Promise<SearchCellResult> {
|
|
124
|
+
const startedAt = Date.now()
|
|
125
|
+
const armId = armLabel(arm)
|
|
126
|
+
const base = {
|
|
127
|
+
taskId: task.id,
|
|
128
|
+
domain: task.domain,
|
|
129
|
+
harness,
|
|
130
|
+
arm: armId,
|
|
131
|
+
model: cfg.model,
|
|
132
|
+
ts: new Date(startedAt).toISOString(),
|
|
133
|
+
}
|
|
134
|
+
const controller = new AbortController()
|
|
135
|
+
const timer = setTimeout(() => controller.abort(), cfg.timeoutMs ?? 300_000)
|
|
136
|
+
try {
|
|
137
|
+
const agentRun = sandboxAgentRun({
|
|
138
|
+
model: cfg.model,
|
|
139
|
+
routerBaseUrl: cfg.routerBaseUrl,
|
|
140
|
+
backendType: harness,
|
|
141
|
+
...(cfg.provider ? { provider: cfg.provider } : {}),
|
|
142
|
+
profile: buildArmProfile({
|
|
143
|
+
arm,
|
|
144
|
+
routerBaseUrl: cfg.routerBaseUrl,
|
|
145
|
+
tangleApiKey: cfg.tangleApiKey,
|
|
146
|
+
name: `search-bench-${harness}-${armId}`,
|
|
147
|
+
metadata: { harness, arm: armId, taskId: task.id },
|
|
148
|
+
}),
|
|
149
|
+
})
|
|
150
|
+
const run = await openSandboxRun<string>(
|
|
151
|
+
client,
|
|
152
|
+
{ agentRun, signal: controller.signal },
|
|
153
|
+
{ kind: 'events', fromEvents: (events) => answerOutput.parse(events as never) },
|
|
154
|
+
)
|
|
155
|
+
let turn: Awaited<ReturnType<typeof run.start>>
|
|
156
|
+
try {
|
|
157
|
+
turn = await run.start(taskToPrompt(task))
|
|
158
|
+
} finally {
|
|
159
|
+
await run.close().catch(() => {})
|
|
160
|
+
}
|
|
161
|
+
if (process.env.DUMP_EVENTS) {
|
|
162
|
+
writeFileSync(process.env.DUMP_EVENTS, JSON.stringify(turn.events, null, 2))
|
|
163
|
+
}
|
|
164
|
+
const answer = turn.out ?? ''
|
|
165
|
+
const { score, reasons } = scoreTask(task, answer)
|
|
166
|
+
const tools = extractTools(turn.events)
|
|
167
|
+
return {
|
|
168
|
+
...base,
|
|
169
|
+
score,
|
|
170
|
+
reasons,
|
|
171
|
+
...tally(turn.events),
|
|
172
|
+
wallMs: Date.now() - startedAt,
|
|
173
|
+
toolCalls: tools.count,
|
|
174
|
+
toolNames: tools.names,
|
|
175
|
+
citations: extractCitations(answer),
|
|
176
|
+
answer,
|
|
177
|
+
}
|
|
178
|
+
} catch (err) {
|
|
179
|
+
return {
|
|
180
|
+
...base,
|
|
181
|
+
score: null,
|
|
182
|
+
reasons: [],
|
|
183
|
+
wallMs: Date.now() - startedAt,
|
|
184
|
+
toolCalls: 0,
|
|
185
|
+
toolNames: [],
|
|
186
|
+
citations: [],
|
|
187
|
+
answer: '',
|
|
188
|
+
infraError: err instanceof Error ? err.message : String(err),
|
|
189
|
+
}
|
|
190
|
+
} finally {
|
|
191
|
+
clearTimeout(timer)
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
export async function runSearchBench(cfg: RunCfg): Promise<SearchCellResult[]> {
|
|
196
|
+
const useBridge = cfg.backend === 'bridge'
|
|
197
|
+
if (useBridge && !cfg.bridge) throw new Error('backend=bridge requires cfg.bridge')
|
|
198
|
+
const client = useBridge ? null : new Sandbox({ baseUrl: cfg.sandboxBaseUrl, apiKey: cfg.sandboxKey })
|
|
199
|
+
const runOne = (task: SearchTask, harness: WorkerBackendType, arm: SearchArm): Promise<SearchCellResult> =>
|
|
200
|
+
useBridge ? runBridgeCell(cfg.bridge!, task, harness, arm) : runCell(cfg, client!, task, harness, arm)
|
|
201
|
+
const cells: Array<{ task: SearchTask; harness: WorkerBackendType; arm: SearchArm }> = []
|
|
202
|
+
for (const task of cfg.tasks)
|
|
203
|
+
for (const harness of cfg.harnesses) for (const arm of cfg.arms) cells.push({ task, harness, arm })
|
|
204
|
+
|
|
205
|
+
mkdirSync(dirname(cfg.outPath), { recursive: true })
|
|
206
|
+
writeFileSync(cfg.outPath, '')
|
|
207
|
+
const results: SearchCellResult[] = []
|
|
208
|
+
const conc = cfg.concurrency ?? 3
|
|
209
|
+
let next = 0
|
|
210
|
+
const worker = async (): Promise<void> => {
|
|
211
|
+
while (next < cells.length) {
|
|
212
|
+
const i = next++
|
|
213
|
+
const { task, harness, arm } = cells[i]
|
|
214
|
+
const r = await runOne(task, harness, arm)
|
|
215
|
+
results.push(r)
|
|
216
|
+
appendFileSync(cfg.outPath, `${JSON.stringify(r)}\n`)
|
|
217
|
+
const mark = r.score === null ? 'ERR' : r.score === 1 ? 'PASS' : 'fail'
|
|
218
|
+
console.error(
|
|
219
|
+
`[${i + 1}/${cells.length}] ${harness}:${armLabel(arm)} ${task.id} → ${mark}` +
|
|
220
|
+
(r.infraError ? ` (${r.infraError})` : ` tools=${r.toolCalls}[${r.toolNames.join(',')}] cites=${r.citations.length} ${Math.round(r.wallMs / 1000)}s`),
|
|
221
|
+
)
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
await Promise.all(Array.from({ length: Math.min(conc, cells.length) }, () => worker()))
|
|
225
|
+
return results
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// ── CLI ──────────────────────────────────────────────────────────────────────
|
|
229
|
+
function env(name: string, fallback?: string): string {
|
|
230
|
+
const v = process.env[name] ?? fallback
|
|
231
|
+
if (v === undefined) throw new Error(`missing env ${name}`)
|
|
232
|
+
return v
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
async function main(): Promise<void> {
|
|
236
|
+
const harnesses = env('HARNESSES', 'opencode').split(',').map((s) => s.trim()) as WorkerBackendType[]
|
|
237
|
+
const arms: SearchArm[] = env('ARMS', 'native,you')
|
|
238
|
+
.split(',')
|
|
239
|
+
.map((s) => s.trim())
|
|
240
|
+
.map((s) => (s === 'native' || s === 'off' ? s : { provider: s }))
|
|
241
|
+
const tangleApiKey = env('TANGLE_API_KEY')
|
|
242
|
+
const backend = env('BACKEND', 'sandbox') as 'sandbox' | 'bridge'
|
|
243
|
+
let bridge: BridgeCfg | undefined
|
|
244
|
+
if (backend === 'bridge') {
|
|
245
|
+
bridge = {
|
|
246
|
+
bridgeUrl: env('BRIDGE_URL', 'http://127.0.0.1:3355'),
|
|
247
|
+
bridgeBearer: env('BRIDGE_BEARER'),
|
|
248
|
+
tangleApiKey,
|
|
249
|
+
routerSearchMcp: env('ROUTER_SEARCH_MCP', 'https://router.tangle.tools/v1/search/mcp'),
|
|
250
|
+
bridgeModels: JSON.parse(
|
|
251
|
+
env('BRIDGE_MODELS', '{"claude-code":"claude-code/sonnet","opencode":"opencode/zai-coding-plan/glm-5.1"}'),
|
|
252
|
+
) as Record<string, string>,
|
|
253
|
+
timeoutMs: Number(env('TIMEOUT_MS', '300000')),
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
const taskSet = (process.env.TASK_SET ?? 'fresh') === 'seed' ? seedTasks : freshTasks
|
|
257
|
+
const onlyIds = (process.env.TASK_IDS ?? '').split(',').map((s) => s.trim()).filter(Boolean)
|
|
258
|
+
const tasks = onlyIds.length ? taskSet.filter((t) => onlyIds.includes(t.id)) : taskSet
|
|
259
|
+
if (tasks.length === 0) throw new Error(`no tasks matched TASK_IDS=${process.env.TASK_IDS} in TASK_SET=${process.env.TASK_SET ?? 'fresh'}`)
|
|
260
|
+
const results = await runSearchBench({
|
|
261
|
+
tasks,
|
|
262
|
+
harnesses,
|
|
263
|
+
arms,
|
|
264
|
+
backend,
|
|
265
|
+
...(bridge ? { bridge } : {}),
|
|
266
|
+
model: env('MODEL', 'opencode/deepseek/deepseek-v4-flash'),
|
|
267
|
+
routerBaseUrl: env('ROUTER_BASE_URL', 'https://router.tangle.tools/v1'),
|
|
268
|
+
tangleApiKey,
|
|
269
|
+
sandboxBaseUrl: env('SANDBOX_BASE_URL', 'https://sandbox.tangle.tools'),
|
|
270
|
+
sandboxKey: env('SANDBOX_KEY', tangleApiKey),
|
|
271
|
+
provider: env('PROVIDER', 'openai'),
|
|
272
|
+
outPath: env('OUT', '/tmp/search-bench.jsonl'),
|
|
273
|
+
concurrency: Number(env('CONCURRENCY', '3')),
|
|
274
|
+
timeoutMs: Number(env('TIMEOUT_MS', '300000')),
|
|
275
|
+
})
|
|
276
|
+
const scored = results.filter((r) => r.score !== null)
|
|
277
|
+
console.error(
|
|
278
|
+
`\nDone: ${results.length} cells, ${scored.length} scored, ${results.length - scored.length} infra-errored → ${env('OUT', '/tmp/search-bench.jsonl')}`,
|
|
279
|
+
)
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
if (import.meta.url === `file://${process.argv[1]}`) {
|
|
283
|
+
main().catch((e) => {
|
|
284
|
+
console.error(e)
|
|
285
|
+
process.exit(1)
|
|
286
|
+
})
|
|
287
|
+
}
|