@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.
Files changed (111) hide show
  1. package/README.md +15 -0
  2. package/package.json +35 -0
  3. package/src/adapters.ts +60 -0
  4. package/src/aec-gate.mts +217 -0
  5. package/src/atom-humaneval.mts +197 -0
  6. package/src/atom-mcp-e2e.mts +223 -0
  7. package/src/benchmarks/_harness.ts +206 -0
  8. package/src/benchmarks/aec-bench.test.mts +53 -0
  9. package/src/benchmarks/aec-bench.ts +319 -0
  10. package/src/benchmarks/appworld.test.mts +45 -0
  11. package/src/benchmarks/appworld.ts +426 -0
  12. package/src/benchmarks/cad-design.ts +429 -0
  13. package/src/benchmarks/cadbench.ts +135 -0
  14. package/src/benchmarks/cadgenbench.ts +121 -0
  15. package/src/benchmarks/commit0.test.mts +71 -0
  16. package/src/benchmarks/commit0.ts +266 -0
  17. package/src/benchmarks/enterpriseops-gym.test.mts +77 -0
  18. package/src/benchmarks/enterpriseops-gym.ts +343 -0
  19. package/src/benchmarks/finsearchcomp.ts +371 -0
  20. package/src/benchmarks/frames.ts +520 -0
  21. package/src/benchmarks/hotpotqa.ts +320 -0
  22. package/src/benchmarks/humaneval.ts +251 -0
  23. package/src/benchmarks/mind2web.ts +311 -0
  24. package/src/benchmarks/programbench.test.mts +60 -0
  25. package/src/benchmarks/programbench.ts +210 -0
  26. package/src/benchmarks/simpleqa.ts +390 -0
  27. package/src/benchmarks/swe-bench.ts +152 -0
  28. package/src/benchmarks/terminal-bench.ts +182 -0
  29. package/src/benchmarks/trata-hedge.ts +496 -0
  30. package/src/benchmarks/types.ts +58 -0
  31. package/src/browser/adapters/bad-design-audit.ts +153 -0
  32. package/src/browser/adapters/bad-design-audit.verify.ts +85 -0
  33. package/src/browser/adapters/bad.ts +165 -0
  34. package/src/browser/agent-adapter.ts +145 -0
  35. package/src/browser/process-adapter.ts +146 -0
  36. package/src/browser/run-to-spans.ts +105 -0
  37. package/src/browser/run-to-spans.verify.ts +89 -0
  38. package/src/browser/ui-reviewer.ts +200 -0
  39. package/src/browser/ui-reviewer.verify.ts +94 -0
  40. package/src/browser/verify.ts +96 -0
  41. package/src/clbench-codebase-gate.mts +314 -0
  42. package/src/clbench-context-gate.mts +305 -0
  43. package/src/cloud-loop.mts +138 -0
  44. package/src/coding-skills/minimal-diff.md +9 -0
  45. package/src/coding-skills/read-before-edit.md +9 -0
  46. package/src/coding-skills/reproduce-first.md +10 -0
  47. package/src/coding-skills/run-tests-after-edit.md +9 -0
  48. package/src/coding-skills/trace-the-failure.md +9 -0
  49. package/src/commit0-env-run.mts +59 -0
  50. package/src/commit0-env.ts +173 -0
  51. package/src/commit0-gate.mts +529 -0
  52. package/src/commit0-prereqs.sh +48 -0
  53. package/src/corpus-replay.mts +300 -0
  54. package/src/corpus-report.mts +504 -0
  55. package/src/corpus.test.mts +273 -0
  56. package/src/corpus.ts +325 -0
  57. package/src/decoder-live.mts +133 -0
  58. package/src/directives.ts +84 -0
  59. package/src/diverse-gate.mjs +112 -0
  60. package/src/egress-probe.mts +26 -0
  61. package/src/eops-skills/address-every-subtask.md +5 -0
  62. package/src/eops-skills/exact-tools-and-args.md +5 -0
  63. package/src/eops-skills/full-sequence-to-goal.md +5 -0
  64. package/src/eops-skills/ground-every-value.md +5 -0
  65. package/src/eops-skills/honor-the-policies.md +5 -0
  66. package/src/examples/README.md +58 -0
  67. package/src/examples/math-demo.mts +110 -0
  68. package/src/examples/strategy-demo.mts +119 -0
  69. package/src/fleet.mts +121 -0
  70. package/src/gate-cli.mts +101 -0
  71. package/src/gate.test.mts +129 -0
  72. package/src/gate.ts +460 -0
  73. package/src/generate-eval/certify.ts +178 -0
  74. package/src/generate-eval/schema.ts +78 -0
  75. package/src/humaneval-gate.mts +204 -0
  76. package/src/humaneval-repair-gate.mts +143 -0
  77. package/src/index.ts +19 -0
  78. package/src/mcp-mount-probe.mts +126 -0
  79. package/src/profile-coordinates.ts +134 -0
  80. package/src/profiles.ts +128 -0
  81. package/src/refine-loop.test.mts +106 -0
  82. package/src/refine-loop.ts +106 -0
  83. package/src/research-gate.mts +132 -0
  84. package/src/research-shot.ts +134 -0
  85. package/src/resolve-client.ts +58 -0
  86. package/src/router-executor.ts +51 -0
  87. package/src/run-pool.ts +48 -0
  88. package/src/runtime-hook-recorder.ts +137 -0
  89. package/src/sandbox-run.ts +125 -0
  90. package/src/search-bench/bridge.ts +124 -0
  91. package/src/search-bench/export.mts +0 -0
  92. package/src/search-bench/parametric-check.mts +63 -0
  93. package/src/search-bench/profiles.ts +98 -0
  94. package/src/search-bench/run.mts +287 -0
  95. package/src/search-bench/tasks-fresh.ts +688 -0
  96. package/src/search-bench/tasks.ts +129 -0
  97. package/src/search-tool.ts +95 -0
  98. package/src/selector.test.mts +189 -0
  99. package/src/selector.ts +366 -0
  100. package/src/skill-sandbox-smoke.mts +100 -0
  101. package/src/stats.mts +90 -0
  102. package/src/terminal-compare.ts +519 -0
  103. package/src/trajectory-assemble.mjs +130 -0
  104. package/src/trata-gate.mts +243 -0
  105. package/src/trata-gepa.mts +434 -0
  106. package/src/worker-blender.ts +230 -0
  107. package/src/worker-browser.ts +102 -0
  108. package/src/worker-build123d.ts +143 -0
  109. package/src/worker-cad.ts +451 -0
  110. package/src/worker.ts +136 -0
  111. 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
+ }