@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,134 @@
1
+ /**
2
+ * One research rollout as a reusable primitive: 2-step RAG — (1) provider-pinned web
3
+ * search via the router's proven `/v1/search?provider=<id>` + `web_fetch` of the top-K
4
+ * result pages, (2) answer with that evidence via `routerChatWithUsage` (no tools on the
5
+ * answer call → `content` always present, so a search arm differs from the parametric
6
+ * control ONLY by the evidence). Pure router HTTP (bearer `TANGLE_API_KEY`).
7
+ *
8
+ * Shared by the off-sandbox RAG leaderboard (`research-gate.mts`) and the router-backed
9
+ * loop executor (`router-executor.ts`), so both score the identical retrieve→answer body
10
+ * — the only difference is who drives the rounds (a flat best-of-k pool vs the real
11
+ * `runLoop` kernel with analyst steering).
12
+ */
13
+ import { routerChatWithUsage } from '@tangle-network/agent-runtime/loops'
14
+
15
+ export interface ShotCfg {
16
+ model: string
17
+ /** search provider id: 'default'/'off'/'none' = no search (parametric control); else a router provider. */
18
+ search: string
19
+ maxResults: number
20
+ /** how many top search URLs to web_fetch full page content for (0 = snippets only). */
21
+ fetchTopK: number
22
+ temperature: number
23
+ routerBaseUrl: string
24
+ routerKey: string
25
+ timeoutMs: number
26
+ }
27
+
28
+ export interface Shot {
29
+ taskId: string
30
+ attempt: number
31
+ answer: string
32
+ ok: boolean
33
+ detail?: string
34
+ wallMs: number
35
+ /** count of search hits retrieved (0 ⇒ no search happened / it failed). */
36
+ searches: number
37
+ }
38
+
39
+ /** Fetch a URL's extracted page text via the router web_fetch MCP tool. Returns '' on any failure. */
40
+ export async function fetchPage(url: string, cfg: ShotCfg): Promise<string> {
41
+ try {
42
+ const res = await fetch(`${cfg.routerBaseUrl}/search/mcp?provider=${encodeURIComponent(cfg.search)}`, {
43
+ method: 'POST',
44
+ headers: { 'content-type': 'application/json', authorization: `Bearer ${cfg.routerKey}` },
45
+ body: JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'tools/call', params: { name: 'web_fetch', arguments: { url } } }),
46
+ ...(cfg.timeoutMs ? { signal: AbortSignal.timeout(Math.min(cfg.timeoutMs, 60_000)) } : {}),
47
+ })
48
+ if (!res.ok) return ''
49
+ const body = (await res.json()) as { result?: { content?: Array<{ text?: string }> } }
50
+ const text = body.result?.content?.[0]?.text ?? ''
51
+ // The tool returns a JSON string {url,title,content}; pull `content` if parseable, else the raw text.
52
+ try {
53
+ const parsed = JSON.parse(text) as { content?: string }
54
+ return (parsed.content ?? text).slice(0, 2500)
55
+ } catch {
56
+ return text.slice(0, 2500)
57
+ }
58
+ } catch {
59
+ return ''
60
+ }
61
+ }
62
+
63
+ /**
64
+ * One research rollout, 2-step RAG against `prompt` (the task question, possibly with a
65
+ * steer appended): (1) provider-pinned web search + web_fetch of the top-K pages, (2)
66
+ * answer with that evidence. The search query is the clean question (first non-empty
67
+ * line) — appended worker-contract / steer boilerplate pollutes the query and returns 0
68
+ * hits — while the ANSWER sees the full `prompt` so a steer round can act on it. The
69
+ * COMMIT prompt stops the model deferring ("may I search?"), which otherwise scores 0.
70
+ * Fault-isolated: a flaky call → a NO-ANSWER `Shot`, never a throw.
71
+ */
72
+ export async function runResearchShot(prompt: string, taskId: string, attempt: number, cfg: ShotCfg): Promise<Shot> {
73
+ const startedAt = Date.now()
74
+ const useSearch = cfg.search !== 'default' && cfg.search !== 'off' && cfg.search !== 'none'
75
+ let searches = 0
76
+ try {
77
+ // 1) Provider-pinned web search (proven /v1/search). The control arm skips this.
78
+ let context = ''
79
+ if (useSearch) {
80
+ const query = (prompt.split('\n').find((l) => l.trim().length > 0) ?? prompt).slice(0, 300)
81
+ const sres = await fetch(`${cfg.routerBaseUrl}/search?provider=${encodeURIComponent(cfg.search)}`, {
82
+ method: 'POST',
83
+ headers: { 'content-type': 'application/json', authorization: `Bearer ${cfg.routerKey}` },
84
+ body: JSON.stringify({ query, count: cfg.maxResults }),
85
+ ...(cfg.timeoutMs ? { signal: AbortSignal.timeout(cfg.timeoutMs) } : {}),
86
+ })
87
+ if (!sres.ok) {
88
+ // Surface, never silently degrade to parametric — a failed search must be visible.
89
+ console.warn(` [search FAIL ${taskId}#${attempt}] HTTP ${sres.status}: ${(await sres.text()).slice(0, 140)}`)
90
+ } else {
91
+ const sb = (await sres.json()) as { data?: Array<{ title?: string; url?: string; snippet?: string }> }
92
+ const hits = sb.data ?? []
93
+ searches = hits.length
94
+ // Fetch the full page text of the top-K results (snippets rarely carry exact figures).
95
+ const fetched = await Promise.all(hits.slice(0, cfg.fetchTopK).map((h) => (h.url ? fetchPage(h.url, cfg) : Promise.resolve(''))))
96
+ context = hits
97
+ .map((h, i) => `[${i + 1}] ${h.title ?? ''}\n${h.snippet ?? ''}\n${h.url ?? ''}${fetched[i] ? `\nPAGE CONTENT:\n${fetched[i]}` : ''}`)
98
+ .join('\n\n')
99
+ }
100
+ }
101
+
102
+ // 2) Answer — no tools (content always present), COMMIT (no deferral), via the shared router primitive.
103
+ const commit =
104
+ 'You have no further tools and cannot ask questions or request more research. ' +
105
+ 'Output a SINGLE, FINAL answer to the question, leading with the value in the exact units and precision requested ' +
106
+ '(e.g. "Answer: -47.9 billion USD"). ' +
107
+ (useSearch
108
+ ? 'Use the WEB SEARCH RESULTS below (snippets + fetched page content) as your primary evidence; cite the source. '
109
+ : 'Answer from your own knowledge. ') +
110
+ 'If you are not fully certain, still COMMIT to your single best estimate — never refuse, defer, or reply with a question.'
111
+ const userContent = useSearch && context ? `${prompt}\n\n=== WEB SEARCH RESULTS (provider: ${cfg.search}) ===\n${context}` : prompt
112
+ const { content } = await routerChatWithUsage(
113
+ { routerBaseUrl: cfg.routerBaseUrl, routerKey: cfg.routerKey, model: cfg.model },
114
+ [
115
+ { role: 'system', content: commit },
116
+ { role: 'user', content: userContent },
117
+ ],
118
+ { temperature: cfg.temperature, ...(cfg.timeoutMs ? { signal: AbortSignal.timeout(cfg.timeoutMs) } : {}) },
119
+ )
120
+ const answer = content.trim()
121
+ const ok = answer.length > 0
122
+ return { taskId, attempt, answer, ok, searches, wallMs: Date.now() - startedAt, ...(ok ? {} : { detail: `empty answer (searches=${searches})` }) }
123
+ } catch (err) {
124
+ return {
125
+ taskId,
126
+ attempt,
127
+ answer: '',
128
+ ok: false,
129
+ searches,
130
+ wallMs: Date.now() - startedAt,
131
+ detail: `rollout error: ${(err instanceof Error ? err.message : String(err)).slice(0, 200)}`,
132
+ }
133
+ }
134
+ }
@@ -0,0 +1,58 @@
1
+ /**
2
+ * The ONE harness → worker-client mapping for the bench layer. Every bench entrypoint
3
+ * (rsi.ts, run.ts, …) derives the `SandboxClient` the kernel's `runLoop` drives from a single
4
+ * selector instead of hand-rolling the branch:
5
+ *
6
+ * - `router` → OFF-BOX: a router chat-completion as the leaf executor, presented
7
+ * as a SandboxClient (no sandbox dependency). For deployable-checker
8
+ * domains whose worker is a completion, or where box egress is blocked.
9
+ * - `router` + searchProvider → OFF-BOX `router-tools`: the same off-box loop with a live
10
+ * `web_search` tool (the capability axis research benches need).
11
+ * - anything else (`sandbox`/a BackendType) → IN-BOX: a real `Sandbox`. The in-box backend
12
+ * TYPE (opencode/codex/…) is set separately on the `AgentRunSpec`;
13
+ * this only decides off-box-vs-in-box transport for `runLoop`.
14
+ *
15
+ * Centralizing it here means a new entrypoint gets the full off-box/in-box matrix for free and
16
+ * the mapping can't drift between callers.
17
+ */
18
+ import { createExecutor, inlineSandboxClient, type SandboxClient } from '@tangle-network/agent-runtime/loops'
19
+ import { Sandbox } from '@tangle-network/sandbox'
20
+ import { makeSearchExecutor, webSearchTool } from './search-tool'
21
+
22
+ export interface ResolveBenchClientOptions {
23
+ /** The selector (the `BACKEND` env value): `router` = off-box; anything else = in-box Sandbox. */
24
+ backend: string
25
+ routerBaseUrl: string
26
+ routerKey: string
27
+ model: string
28
+ /** When set on the `router` backend, the off-box worker becomes a `router-tools` agentic loop
29
+ * with a live `web_search` tool backed by this provider (`you`/`exa`/…). */
30
+ searchProvider?: string
31
+ sandboxBaseUrl?: string
32
+ /** In-box sandbox timeout (ms). */
33
+ timeoutMs?: number
34
+ }
35
+
36
+ export function resolveBenchClient(opts: ResolveBenchClientOptions): SandboxClient {
37
+ const { backend, routerBaseUrl, routerKey, model, searchProvider } = opts
38
+ if (backend === 'router') {
39
+ if (searchProvider) {
40
+ return inlineSandboxClient(
41
+ createExecutor({
42
+ backend: 'router-tools',
43
+ routerBaseUrl,
44
+ routerKey,
45
+ model,
46
+ tools: [webSearchTool],
47
+ executeToolCall: makeSearchExecutor({ routerBaseUrl, routerKey, provider: searchProvider }),
48
+ }),
49
+ )
50
+ }
51
+ return inlineSandboxClient(createExecutor({ backend: 'router', routerBaseUrl, routerKey, model }))
52
+ }
53
+ return new Sandbox({
54
+ baseUrl: opts.sandboxBaseUrl ?? 'https://sandbox.tangle.tools',
55
+ apiKey: routerKey,
56
+ timeoutMs: opts.timeoutMs ?? 1_200_000,
57
+ } as never)
58
+ }
@@ -0,0 +1,51 @@
1
+ /**
2
+ * Router-backed research executor — the "router" cost-dial backend (experiment.ts:
3
+ * "backend = the injected SandboxClient (router / bridge / sandbox)"). Each run is
4
+ * ONE research shot (router web-search + answer, off-sandbox); its answer is the
5
+ * terminal `finalText` the kernel's `answerOutput` parses.
6
+ *
7
+ * Why off-sandbox: research is retrieval, not in-box code execution — it never
8
+ * needed a box, and a real sandbox box reaches only the router (egress allowlist,
9
+ * ops-board #976), so it cannot web-search natively anyway. Driving the loop with
10
+ * this executor instead of a box gives the REAL `runLoop` kernel full `rounds` +
11
+ * analyst steering (the depth regime), search working, no sandbox dependency.
12
+ *
13
+ * This is a BYO `Executor` over `runResearchShot`; `inlineSandboxClient` supplies
14
+ * the one shared box-shell so this file owns only the research-shot specifics
15
+ * (no hand-rolled create/streamPrompt/delete). A fresh executor per round ⇒ no
16
+ * fork/live session; statefulness is the across-round steer, not a box.
17
+ */
18
+ import {
19
+ type ExecutorFactory,
20
+ type ExecutorResult,
21
+ inlineSandboxClient,
22
+ type SandboxClient,
23
+ } from '@tangle-network/agent-runtime/loops'
24
+ import { runResearchShot, type ShotCfg } from './research-shot'
25
+
26
+ export function routerSandboxClient(cfg: ShotCfg): SandboxClient {
27
+ let seq = 0
28
+ const factory: ExecutorFactory<unknown> = () => {
29
+ const id = `router-research-${seq++}`
30
+ let artifact: ExecutorResult<unknown> | undefined
31
+ return {
32
+ runtime: 'router',
33
+ async execute(task): Promise<ExecutorResult<unknown>> {
34
+ const started = Date.now()
35
+ const shot = await runResearchShot(String(task), id, 0, cfg)
36
+ artifact = {
37
+ outRef: `router-research:${id}`,
38
+ out: { content: shot.answer },
39
+ spent: { iterations: 1, tokens: { input: 0, output: 0 }, usd: 0, ms: Date.now() - started },
40
+ }
41
+ return artifact
42
+ },
43
+ teardown: () => Promise.resolve({ destroyed: true }),
44
+ resultArtifact() {
45
+ if (!artifact) throw new Error('routerSandboxClient: resultArtifact() read before execute()')
46
+ return artifact
47
+ },
48
+ }
49
+ }
50
+ return inlineSandboxClient(factory)
51
+ }
@@ -0,0 +1,48 @@
1
+ /**
2
+ * Bounded-concurrency pool — the ONE pool every batch runner uses.
3
+ *
4
+ * Every batch-* command, the finsearch loop, and terminal-compare hand-rolled the
5
+ * same shape: a shared `next` cursor, `Math.min(concurrency, items.length)` drain
6
+ * workers, and `Promise.all` over them. That boilerplate lives here once.
7
+ * Aggregation and console output stay at each call site — the pool owns scheduling,
8
+ * nothing else. Item-agnostic on purpose: a pool shouldn't know about BenchTask
9
+ * (terminal-compare pools over task-id strings), so it's generic in the item type.
10
+ *
11
+ * A worker that throws yields `{ ok:false, error }` for THAT item; the batch never
12
+ * aborts because one item failed. Results are returned in item order (indexed by
13
+ * the item's position), not completion order.
14
+ */
15
+
16
+ export interface PoolOutcome<T, R> {
17
+ index: number
18
+ item: T
19
+ ok: boolean
20
+ value?: R
21
+ error?: string
22
+ }
23
+
24
+ export async function runPool<T, R>(
25
+ items: T[],
26
+ concurrency: number,
27
+ worker: (item: T, index: number) => Promise<R>,
28
+ opts?: { onResult?: (o: PoolOutcome<T, R>) => void },
29
+ ): Promise<PoolOutcome<T, R>[]> {
30
+ const results: PoolOutcome<T, R>[] = new Array(items.length)
31
+ let next = 0
32
+ const drain = async (): Promise<void> => {
33
+ while (next < items.length) {
34
+ const index = next++
35
+ const item = items[index] as T
36
+ let outcome: PoolOutcome<T, R>
37
+ try {
38
+ outcome = { index, item, ok: true, value: await worker(item, index) }
39
+ } catch (err) {
40
+ outcome = { index, item, ok: false, error: err instanceof Error ? err.message : String(err) }
41
+ }
42
+ results[index] = outcome
43
+ opts?.onResult?.(outcome)
44
+ }
45
+ }
46
+ await Promise.all(Array.from({ length: Math.min(concurrency, items.length) }, drain))
47
+ return results
48
+ }
@@ -0,0 +1,137 @@
1
+ export type BenchRuntimeHookPhase = 'before' | 'after' | 'error' | 'event'
2
+
3
+ export interface BenchRuntimeHookEvent<Payload = unknown> {
4
+ id: string
5
+ runId: string
6
+ scenarioId?: string
7
+ target: string
8
+ phase: BenchRuntimeHookPhase
9
+ timestamp: number
10
+ stepIndex?: number
11
+ parentId?: string
12
+ payload?: Payload
13
+ metadata?: Record<string, unknown>
14
+ }
15
+
16
+ export interface BenchRuntimeDecisionEvidenceRef {
17
+ source: string
18
+ id: string
19
+ detail?: string
20
+ metadata?: Record<string, unknown>
21
+ }
22
+
23
+ export interface BenchRuntimeDecisionPoint {
24
+ id: string
25
+ runId: string
26
+ scenarioId?: string
27
+ stepIndex: number
28
+ kind: string
29
+ candidateActions: string[]
30
+ context?: string
31
+ evidence: BenchRuntimeDecisionEvidenceRef[]
32
+ metadata?: Record<string, unknown>
33
+ }
34
+
35
+ export interface BenchRuntimeHooks {
36
+ onEvent?: (
37
+ event: BenchRuntimeHookEvent,
38
+ context: { signal?: AbortSignal },
39
+ ) => void | Promise<void>
40
+ onDecisionPoint?: (
41
+ point: BenchRuntimeDecisionPoint,
42
+ context: { signal?: AbortSignal },
43
+ ) => void | Promise<void>
44
+ }
45
+
46
+ export interface RuntimeHookRecorder {
47
+ readonly events: BenchRuntimeHookEvent[]
48
+ readonly decisionPoints: BenchRuntimeDecisionPoint[]
49
+ readonly hooks: BenchRuntimeHooks
50
+ }
51
+
52
+ const MAX_STRING_LENGTH = 12_000
53
+ const MAX_CONTEXT_LENGTH = 20_000
54
+ const MAX_EVIDENCE_DETAIL_LENGTH = 2_000
55
+ const MAX_CANDIDATE_ACTIONS = 50
56
+ const MAX_EVIDENCE_REFS = 50
57
+ const MAX_METADATA_DEPTH = 4
58
+ const MAX_METADATA_KEYS = 100
59
+ const SENSITIVE_KEY_RE = /(?:authorization|api[_-]?key|token|secret|password|cookie|credential|bearer)/i
60
+ const SENSITIVE_VALUE_RES = [
61
+ /\bBearer\s+[A-Za-z0-9._~+/=-]+/gi,
62
+ /\b(?:sk|gh[pousr])_[A-Za-z0-9_]{20,}\b/g,
63
+ /\b(?:sk|ghp|gho|ghu|ghs|ghr)-[A-Za-z0-9_-]{20,}\b/g,
64
+ ]
65
+ const SENSITIVE_ASSIGNMENT_RE =
66
+ /\b(api[_-]?key|token|secret|password|cookie)\s*[:=]\s*["']?[^"'\s,;}]+/gi
67
+
68
+ function sanitizeString(value: string, maxLength: number): string {
69
+ let sanitized = value
70
+ for (const pattern of SENSITIVE_VALUE_RES) {
71
+ sanitized = sanitized.replace(pattern, '[REDACTED]')
72
+ }
73
+ sanitized = sanitized.replace(SENSITIVE_ASSIGNMENT_RE, (_match, key: string) => `${key}=[REDACTED]`)
74
+ if (sanitized.length <= maxLength) return sanitized
75
+ return sanitized.slice(0, maxLength)
76
+ }
77
+
78
+ function sanitizeMetadata(value: unknown, depth = 0): unknown {
79
+ if (value == null) return value
80
+ if (typeof value === 'string') return sanitizeString(value, MAX_STRING_LENGTH)
81
+ if (typeof value === 'number' || typeof value === 'boolean') return value
82
+ if (Array.isArray(value)) {
83
+ if (depth >= MAX_METADATA_DEPTH) return '[MaxDepth]'
84
+ return value.slice(0, MAX_METADATA_KEYS).map((item) => sanitizeMetadata(item, depth + 1))
85
+ }
86
+ if (typeof value !== 'object') return undefined
87
+ if (depth >= MAX_METADATA_DEPTH) return '[MaxDepth]'
88
+
89
+ const sanitized: Record<string, unknown> = {}
90
+ for (const [key, nested] of Object.entries(value).slice(0, MAX_METADATA_KEYS)) {
91
+ sanitized[key] = SENSITIVE_KEY_RE.test(key) ? '[REDACTED]' : sanitizeMetadata(nested, depth + 1)
92
+ }
93
+ return sanitized
94
+ }
95
+
96
+ function sanitizeMetadataRecord(metadata: Record<string, unknown> | undefined): Record<string, unknown> | undefined {
97
+ if (!metadata) return undefined
98
+ const sanitized = sanitizeMetadata(metadata)
99
+ if (!sanitized || typeof sanitized !== 'object' || Array.isArray(sanitized)) return undefined
100
+ return sanitized as Record<string, unknown>
101
+ }
102
+
103
+ function snapshotDecisionPoint(point: BenchRuntimeDecisionPoint): BenchRuntimeDecisionPoint {
104
+ return {
105
+ id: point.id,
106
+ runId: point.runId,
107
+ scenarioId: point.scenarioId,
108
+ stepIndex: point.stepIndex,
109
+ kind: point.kind,
110
+ candidateActions: point.candidateActions.slice(0, MAX_CANDIDATE_ACTIONS).map((action) => sanitizeString(action, MAX_STRING_LENGTH)),
111
+ context: typeof point.context === 'string' ? sanitizeString(point.context, MAX_CONTEXT_LENGTH) : undefined,
112
+ evidence: point.evidence.slice(0, MAX_EVIDENCE_REFS).map((ref) => ({
113
+ source: sanitizeString(ref.source, MAX_STRING_LENGTH),
114
+ id: sanitizeString(ref.id, MAX_STRING_LENGTH),
115
+ detail: typeof ref.detail === 'string' ? sanitizeString(ref.detail, MAX_EVIDENCE_DETAIL_LENGTH) : undefined,
116
+ metadata: sanitizeMetadataRecord(ref.metadata),
117
+ })),
118
+ metadata: sanitizeMetadataRecord(point.metadata),
119
+ }
120
+ }
121
+
122
+ export function createRuntimeHookRecorder(): RuntimeHookRecorder {
123
+ const events: BenchRuntimeHookEvent[] = []
124
+ const decisionPoints: BenchRuntimeDecisionPoint[] = []
125
+ return {
126
+ events,
127
+ decisionPoints,
128
+ hooks: {
129
+ onEvent: (event) => {
130
+ events.push(event)
131
+ },
132
+ onDecisionPoint: (point) => {
133
+ decisionPoints.push(snapshotDecisionPoint(point))
134
+ },
135
+ },
136
+ }
137
+ }
@@ -0,0 +1,125 @@
1
+ /**
2
+ * Shared sandbox-rollout helpers for the bench harnesses.
3
+ *
4
+ * The worker plumbing every sandbox-backed bench needs, independent of how the
5
+ * loop is driven: build the standard `AgentRunSpec` (`sandboxAgentRun`), parse
6
+ * the agent's final answer from the event stream (`answerOutput`), name the
7
+ * cost-dial backend (`WorkerBackendType`), and run a single-model "review the
8
+ * prior attempt" analyst (`llmAnalyst`/`AnalystFn`). These are pure profile /
9
+ * backend / parsing plumbing — no experiment shell, no topology arms.
10
+ */
11
+
12
+ import {
13
+ type AgentProfile,
14
+ type AgentRunSpec,
15
+ type OutputAdapter,
16
+ routerChatWithUsage,
17
+ } from '@tangle-network/agent-runtime/loops'
18
+ // `BackendType` is the sandbox SDK's harness union — its canonical home. agent-runtime consumes
19
+ // it from there too; it is not re-exported from the loops barrel.
20
+ import type { BackendType } from '@tangle-network/sandbox'
21
+
22
+ /** Parse the agent's final answer from the event stream (harness-agnostic).
23
+ * The default deliverable; a benchmark whose artifact is a file overrides via
24
+ * its own `OutputAdapter` that reads from the run. */
25
+ export const answerOutput: OutputAdapter<string> = {
26
+ parse(events) {
27
+ let answer = ''
28
+ for (const ev of events) {
29
+ const d = (ev as { data?: Record<string, unknown> })?.data
30
+ const t = d?.finalText ?? d?.text ?? d?.result
31
+ if (typeof t === 'string' && t.length > 0) answer = t
32
+ }
33
+ return answer
34
+ },
35
+ }
36
+
37
+ /** What an analyst sees of each prior attempt: its output, its verdict, and its
38
+ * raw trace events. The events are the trace an analyst reads. */
39
+ export type SteerHistory = ReadonlyArray<{
40
+ output?: string
41
+ verdict?: { valid?: boolean; score?: number; notes?: string }
42
+ events?: readonly unknown[]
43
+ }>
44
+
45
+ /**
46
+ * The investigation: read the prior attempt's trace, return targeted feedback for
47
+ * the next one. It observes BEHAVIOR (output, trace), never the judge's verdict —
48
+ * the selector != judge firewall.
49
+ */
50
+ export type AnalystFn = (history: SteerHistory, task?: string) => Promise<string>
51
+
52
+ /** Simple analyst: ONE model call reads the public task plus a bounded view of the
53
+ * last attempt (its output + a tail of its trace events) and returns a concrete
54
+ * correction. Selector != judge firewall: it NEVER reads the held-out judge's
55
+ * verdict or failure detail — that would be a non-deployable oracle gradient
56
+ * toward the reference answer. A deployable steerer must locate the fault from the
57
+ * task and the agent's own behavior alone. */
58
+ export const llmAnalyst = (cfg: { routerBaseUrl: string; routerKey: string; model: string }): AnalystFn =>
59
+ async (history, task) => {
60
+ const last = history.at(-1)
61
+ const traceTail = (last?.events ?? [])
62
+ .slice(-12)
63
+ .map((e) => (typeof e === 'string' ? e : JSON.stringify(e)))
64
+ .join('\n')
65
+ .slice(-2000)
66
+ const { content } = await routerChatWithUsage(cfg, [
67
+ {
68
+ role: 'system',
69
+ content:
70
+ "You review an AI agent's previous attempt at a task. From the task, the attempt's output, and its execution trace ALONE, judge whether it correctly and completely solved the task. If you find a specific fault — a wrong value, a guessed API signature, a missing step, a misread requirement — name it and give the concrete correction in 1-3 sentences. Reply exactly 'no change needed' if the attempt looks correct and complete.",
71
+ },
72
+ {
73
+ role: 'user',
74
+ content: `Task:\n${task ?? '(task unavailable)'}\n\nPrevious answer:\n${last?.output ?? '(none)'}\n\nTrace tail:\n${traceTail}`,
75
+ },
76
+ ])
77
+ return content
78
+ }
79
+
80
+ /** Cost-dial backend = the SDK's canonical `BackendType` (single source of truth; no local
81
+ * literal copy that drifts from the harness set). `hermes` = the inference-router agent (the
82
+ * cheap "router llm-call" dial); the rest are agent CLIs. The ONLY knob that changes which
83
+ * agent runs — no per-backend worker. */
84
+ export type WorkerBackendType = BackendType
85
+
86
+ /** Build the standard sandbox `AgentRunSpec` for a benchmark — the worker the
87
+ * kernel injects. `backendType` is the cost dial. Model auth is the BOX'S OWN
88
+ * provisioned credential: `backend.model` pins provider/model/baseUrl only, and
89
+ * the platform generates the in-box provider config keyed to
90
+ * `{env:OPENCODE_MODEL_API_KEY}`. Never pass an external router key into the
91
+ * box — the egress proxy rejects foreign credentials (403, empty output). */
92
+ export function sandboxAgentRun(opts: {
93
+ model: string
94
+ routerBaseUrl: string
95
+ backendType?: WorkerBackendType
96
+ /** In-box model provider. Default `openai` (registered models like gpt-4.1).
97
+ * Cheap router models (deepseek/kimi/glm) are not in opencode's `openai`
98
+ * registry and 404 in-box — pass `openai-compat` (generic passthrough). */
99
+ provider?: string
100
+ name?: string
101
+ taskToPrompt?: (task: string) => string
102
+ /** Extra box-level env (e.g. `TANGLE_SEARCH_DEFAULT_PROVIDER` to pin the in-box
103
+ * agent's web-search provider, provider keys like EXA_API_KEY). Allowlisted
104
+ * keys only reach the spawned CLI. Must NOT carry router/model credentials. */
105
+ env?: Record<string, string>
106
+ /** The developer's AgentProfile — the one knob for "which agent" (prompt / model /
107
+ * tools / mcp). Spread through verbatim; the backend cost-dial is tagged into
108
+ * metadata. Omitted ⇒ a minimal worker profile. */
109
+ profile?: AgentProfile
110
+ }): AgentRunSpec<string> {
111
+ const backendType = opts.backendType ?? 'opencode'
112
+ const name = opts.profile?.name ?? opts.name ?? `${backendType}-worker`
113
+ return {
114
+ profile: { ...opts.profile, name, metadata: { ...opts.profile?.metadata, backendType } },
115
+ name,
116
+ taskToPrompt: opts.taskToPrompt ?? ((t) => t),
117
+ sandboxOverrides: {
118
+ ...(opts.env ? { env: opts.env } : {}),
119
+ backend: {
120
+ type: backendType,
121
+ model: { provider: opts.provider ?? 'openai', model: opts.model, baseUrl: opts.routerBaseUrl },
122
+ },
123
+ },
124
+ }
125
+ }
@@ -0,0 +1,124 @@
1
+ /**
2
+ * cli-bridge cell executor — the unblocked path to the head-to-head numbers
3
+ * while the sandbox sidecar image is pending #1810.
4
+ *
5
+ * Same arms, same deterministic oracle, same export. The only difference from
6
+ * the sandbox path is HOW the harness runs: a single OpenAI-compatible chat call
7
+ * to the local cli-bridge (`/v1/chat/completions`) with an `agent_profile` that
8
+ * (a) disables native web tools via `metadata.disallowedTools` and (b) adds the
9
+ * provider search MCP via `mcp` — both PROVEN to work on the bridge. Native arm
10
+ * leaves the harness untouched.
11
+ *
12
+ * The bridge model id IS the harness selector (e.g. `claude-code/sonnet`,
13
+ * `opencode/zai-coding-plan/glm-5.1`), so `harness` here is just the label.
14
+ */
15
+ import { createExecutor } from '@tangle-network/agent-runtime/loops'
16
+ import type { SearchArm } from './profiles'
17
+ import { armLabel } from './profiles'
18
+ import type { SearchCellResult } from './run.mts'
19
+ import { type SearchTask, scoreTask, taskToPrompt } from './tasks'
20
+
21
+ const nativeWebDisallowed = ['WebSearch', 'WebFetch', 'web_search', 'web_fetch', 'websearch', 'webfetch', 'fetch']
22
+
23
+ /** Build the cli-bridge `agent_profile` for one arm (bridge dialect: disable via
24
+ * `metadata.disallowedTools`, search MCP via `mcp.<name>.transport:'http'`). */
25
+ function bridgeProfile(arm: SearchArm, routerSearchMcp: string, tangleApiKey: string, label: string): Record<string, unknown> {
26
+ if (arm === 'native') return { name: `search-bench-${label}` }
27
+ const base = { name: `search-bench-${label}`, metadata: { disallowedTools: nativeWebDisallowed } }
28
+ if (arm === 'off') return base
29
+ return {
30
+ ...base,
31
+ mcp: {
32
+ tangle_search: {
33
+ transport: 'http',
34
+ url: `${routerSearchMcp}?provider=${encodeURIComponent(arm.provider)}`,
35
+ headers: { Authorization: `Bearer ${tangleApiKey}` },
36
+ enabled: true,
37
+ },
38
+ },
39
+ }
40
+ }
41
+
42
+ const urlRe = /https?:\/\/[^\s)\]}"'<>]+/gi
43
+ function citationsOf(answer: string): string[] {
44
+ return [...new Set((answer.match(urlRe) ?? []).map((u) => u.replace(/[.,;]+$/, '')))]
45
+ }
46
+
47
+ export interface BridgeCfg {
48
+ bridgeUrl: string
49
+ bridgeBearer: string
50
+ tangleApiKey: string
51
+ /** Router search-MCP endpoint, e.g. https://router.tangle.tools/v1/search/mcp */
52
+ routerSearchMcp: string
53
+ /** harness label → cli-bridge model id (the harness selector). */
54
+ bridgeModels: Record<string, string>
55
+ timeoutMs?: number
56
+ }
57
+
58
+ export async function runBridgeCell(
59
+ cfg: BridgeCfg,
60
+ task: SearchTask,
61
+ harness: string,
62
+ arm: SearchArm,
63
+ ): Promise<SearchCellResult> {
64
+ const startedAt = Date.now()
65
+ const armId = armLabel(arm)
66
+ const base = {
67
+ taskId: task.id,
68
+ domain: task.domain,
69
+ harness,
70
+ arm: armId,
71
+ model: cfg.bridgeModels[harness] ?? harness,
72
+ ts: new Date(startedAt).toISOString(),
73
+ }
74
+ const controller = new AbortController()
75
+ const timer = setTimeout(() => controller.abort(), cfg.timeoutMs ?? 300_000)
76
+ try {
77
+ // One harness turn through the unified bridge executor — same backend the
78
+ // loop path uses; this cell scorer just adds oracle scoring + citations.
79
+ const exec = createExecutor({
80
+ backend: 'bridge',
81
+ bridgeUrl: cfg.bridgeUrl,
82
+ bridgeBearer: cfg.bridgeBearer,
83
+ model: cfg.bridgeModels[harness] ?? harness,
84
+ agentProfile: bridgeProfile(arm, cfg.routerSearchMcp, cfg.tangleApiKey, `${harness}-${armId}`),
85
+ timeoutMs: cfg.timeoutMs ?? 300_000,
86
+ })({ profile: { name: `${harness}-${armId}` }, harness: null }, { signal: controller.signal, seams: {} })
87
+ // bridgeExecutor is one-shot (async execute resolves an ExecutorResult).
88
+ const artifact = (await exec.execute(taskToPrompt(task), controller.signal)) as {
89
+ out: unknown
90
+ spent: { tokens: { input: number; output: number }; usd: number }
91
+ }
92
+ const out = artifact.out as { content?: string; toolCalls?: string[] }
93
+ const answer = out.content ?? ''
94
+ const names = out.toolCalls ?? []
95
+ const { score, reasons } = scoreTask(task, answer)
96
+ return {
97
+ ...base,
98
+ score,
99
+ reasons,
100
+ ...(artifact.spent.usd ? { costUsd: artifact.spent.usd } : {}),
101
+ ...(artifact.spent.tokens.input ? { tokensIn: artifact.spent.tokens.input } : {}),
102
+ ...(artifact.spent.tokens.output ? { tokensOut: artifact.spent.tokens.output } : {}),
103
+ wallMs: Date.now() - startedAt,
104
+ toolCalls: names.length,
105
+ toolNames: [...new Set(names)],
106
+ citations: citationsOf(answer),
107
+ answer,
108
+ }
109
+ } catch (err) {
110
+ return {
111
+ ...base,
112
+ score: null,
113
+ reasons: [],
114
+ wallMs: Date.now() - startedAt,
115
+ toolCalls: 0,
116
+ toolNames: [],
117
+ citations: [],
118
+ answer: '',
119
+ infraError: err instanceof Error ? err.message : String(err),
120
+ }
121
+ } finally {
122
+ clearTimeout(timer)
123
+ }
124
+ }
Binary file