@tangle-network/agent-bench 0.7.0 → 0.8.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 (131) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/HARNESS.md +1 -1
  3. package/dist/adapters.js +2 -2
  4. package/dist/benchmarks/appworld.d.ts +61 -2
  5. package/dist/benchmarks/appworld.js +111 -40
  6. package/dist/benchmarks/appworld.js.map +1 -1
  7. package/dist/benchmarks/cadbench.js +1 -1
  8. package/dist/benchmarks/cadgenbench.js +1 -1
  9. package/dist/benchmarks/finresearchbench.js +16 -22
  10. package/dist/benchmarks/finresearchbench.js.map +1 -1
  11. package/dist/benchmarks/finsearchcomp.js +16 -22
  12. package/dist/benchmarks/finsearchcomp.js.map +1 -1
  13. package/dist/benchmarks/frames.js +18 -20
  14. package/dist/benchmarks/frames.js.map +1 -1
  15. package/dist/benchmarks/simpleqa.js +15 -19
  16. package/dist/benchmarks/simpleqa.js.map +1 -1
  17. package/dist/benchmarks/trata-hedge.js +20 -23
  18. package/dist/benchmarks/trata-hedge.js.map +1 -1
  19. package/dist/{cadbench-BrpwOU6A.js → cadbench-BLSyxR1N.js} +21 -20
  20. package/dist/cadbench-BLSyxR1N.js.map +1 -0
  21. package/dist/{cadgenbench-DF7hYHdl.js → cadgenbench-x2OFkf8y.js} +2 -2
  22. package/dist/cadgenbench-x2OFkf8y.js.map +1 -0
  23. package/dist/index.d.ts +0 -1
  24. package/dist/index.js +10 -9
  25. package/dist/index.js.map +1 -1
  26. package/dist/router-turn-C2wMiDoo.js +31 -0
  27. package/dist/router-turn-C2wMiDoo.js.map +1 -0
  28. package/package.json +7 -7
  29. package/scripts/appworld_driver.py +1 -170
  30. package/scripts/run-package-tests.mjs +56 -36
  31. package/scripts/run-package-tests.test.mjs +33 -0
  32. package/scripts/trata-hedge/README.md +3 -3
  33. package/scripts/trata-hedge/run.sh +6 -2
  34. package/src/aec-gate.mts +30 -9
  35. package/src/agent-graphs-gen2.mts +523 -0
  36. package/src/agent-graphs-gen3.mts +660 -0
  37. package/src/agent-graphs-improve/offline-seams.mts +128 -0
  38. package/src/agent-graphs-improve.mts +747 -0
  39. package/src/atom-humaneval.mts +76 -55
  40. package/src/atom-mcp-e2e.mts +27 -13
  41. package/src/benchmarks/appworld.test.mts +119 -1
  42. package/src/benchmarks/appworld.ts +156 -33
  43. package/src/benchmarks/cadbench.ts +21 -8
  44. package/src/benchmarks/finresearchbench.ts +20 -16
  45. package/src/benchmarks/finsearchcomp.ts +20 -20
  46. package/src/benchmarks/frames.ts +19 -18
  47. package/src/benchmarks/simpleqa.ts +19 -17
  48. package/src/benchmarks/trata-hedge.ts +26 -18
  49. package/src/browser/ui-reviewer.ts +1 -1
  50. package/src/clbench-context-gate.mts +45 -9
  51. package/src/cloud-loop.mts +24 -6
  52. package/src/commit0-env-run.mts +6 -3
  53. package/src/commit0-env.ts +2 -3
  54. package/src/commit0-gate.mts +53 -24
  55. package/src/corpus.ts +3 -3
  56. package/src/david-attribution.mts +28 -9
  57. package/src/david-goliath.mts +31 -12
  58. package/src/egress-probe.mts +3 -3
  59. package/src/examples/lean-proof-gate.mts +10 -3
  60. package/src/examples/math-demo.mts +10 -3
  61. package/src/examples/strategy-demo.mts +10 -4
  62. package/src/fleet.mts +30 -8
  63. package/src/gate-cli.mts +9 -4
  64. package/src/gate.test.mts +7 -2
  65. package/src/gate.ts +23 -75
  66. package/src/generate-eval/certify.ts +13 -5
  67. package/src/hev-eval.mts +39 -10
  68. package/src/hev-improve.mts +28 -16
  69. package/src/hev-structural.mts +28 -20
  70. package/src/humaneval-gate.mts +24 -8
  71. package/src/humaneval-object-ablation.mts +52 -14
  72. package/src/humaneval-repair-gate.mts +53 -14
  73. package/src/mbpp-structural.mts +28 -20
  74. package/src/mcp-mount-probe.mts +23 -11
  75. package/src/official-optimizer-config.mts +61 -12
  76. package/src/official-optimizer-config.test.mts +72 -3
  77. package/src/quant-arena/profiles/quant-researcher.profile.json +6 -1
  78. package/src/quant-arena/quant-loop.mts +88 -61
  79. package/src/research-shot.ts +21 -11
  80. package/src/resolve-client.ts +6 -8
  81. package/src/rollout-ledger/backfill-swe-arena.test.mts +7 -2
  82. package/src/router-executor.ts +1 -1
  83. package/src/router-turn.ts +135 -0
  84. package/src/run-benchmarks.ts +7 -2
  85. package/src/sandbox-run.ts +23 -41
  86. package/src/search-bench/bridge.ts +66 -8
  87. package/src/search-bench/parametric-check.mts +19 -4
  88. package/src/search-bench/profiles.ts +24 -9
  89. package/src/search-bench/run.mts +3 -4
  90. package/src/smoke-structural-rollout.mts +21 -5
  91. package/src/supervisor-arena.mts +28 -20
  92. package/src/swe-arena/arms.ts +73 -15
  93. package/src/swe-arena/capacity.ts +44 -41
  94. package/src/swe-arena/diagnosis-ensemble.ts +51 -48
  95. package/src/swe-arena/execution.test.mts +12 -12
  96. package/src/swe-arena/gepa-seat.mts +12 -5
  97. package/src/swe-arena/gepa-seat.test.mts +45 -33
  98. package/src/swe-arena/outer-loop.mts +94 -70
  99. package/src/swe-arena/premeasured-from-cells.mts +16 -1
  100. package/src/swe-arena/premeasured-from-cells.test.mts +21 -1
  101. package/src/swe-arena/profiles/deepseek-author.profile.json +12 -0
  102. package/src/swe-arena/profiles/default-author.profile.json +9 -1
  103. package/src/swe-arena/proposer-fanout.mts +52 -99
  104. package/src/swe-arena/proposer-fanout.test.mts +92 -57
  105. package/src/swe-arena/proposer-provenance.mts +1 -1
  106. package/src/swe-code-improve.mts +30 -52
  107. package/src/swe-emit-patch.mts +26 -5
  108. package/src/swe-improve.mts +27 -8
  109. package/src/swe-jail.test.ts +128 -0
  110. package/src/swe-jail.ts +106 -22
  111. package/src/swe-local-proof.mts +27 -5
  112. package/src/swe-repro-calibrate.mts +5 -0
  113. package/src/swe-self-improve.mts +45 -10
  114. package/src/swe-stream.mts +68 -6
  115. package/src/swe-structural.mts +46 -7
  116. package/src/tb-container-executor.mts +38 -6
  117. package/src/tb-container-executor.test.mts +11 -3
  118. package/src/tb-supervisor-sidecar.mts +11 -27
  119. package/src/terminal-compare.ts +38 -18
  120. package/src/trata-gate.mts +26 -26
  121. package/src/trata-gepa.mts +25 -16
  122. package/src/trata-hedge-solve.mts +80 -0
  123. package/src/worker-blender.ts +21 -11
  124. package/src/worker-browser.ts +18 -5
  125. package/src/worker-build123d.ts +17 -8
  126. package/src/worker-cad.ts +31 -12
  127. package/src/worker.ts +1 -1
  128. package/tb_agents/opencode_router_agent.py +84 -42
  129. package/dist/cadbench-BrpwOU6A.js.map +0 -1
  130. package/dist/cadgenbench-DF7hYHdl.js.map +0 -1
  131. package/scripts/trata-hedge/solve.py +0 -83
@@ -21,20 +21,23 @@ import {
21
21
  type AgentProfile,
22
22
  type AgentSpec,
23
23
  contentAddress,
24
- type DriverAgentOptions,
25
- driverAgent,
24
+ createExecutor,
26
25
  createExecutorRegistry,
27
26
  createSupervisor,
28
- type Executor,
29
- type ExecutorResult,
30
27
  gateOnDeliverable,
31
28
  InMemoryResultBlobStore,
32
29
  InMemorySpawnJournal,
33
- type RouterConfig,
34
- routerBrain,
35
- routerChatWithUsage,
30
+ mapExecutorResult,
31
+ supervisorAgent,
36
32
  } from '../../src/runtime/index'
37
33
  import { basePrompt, extractCode, type HumanEvalTask, loadHumanEval, runChecker } from './benchmarks/humaneval'
34
+ import {
35
+ benchProfileModel,
36
+ benchRouterProfile,
37
+ type BenchRouterTarget,
38
+ runBenchRouterTurn,
39
+ withBenchProfile,
40
+ } from './router-turn'
38
41
 
39
42
  function must(k: string): string {
40
43
  const v = process.env[k]
@@ -47,45 +50,48 @@ const K = Number(process.env.K ?? 3)
47
50
  const OFFSET = Number(process.env.OFFSET ?? 0)
48
51
  const WORKER_TEMP = Number(process.env.WORKER_TEMP ?? 0.7)
49
52
 
50
- const cfg: RouterConfig = {
53
+ const cfg: BenchRouterTarget = {
51
54
  routerBaseUrl: process.env.ROUTER_BASE ?? 'https://router.tangle.tools/v1',
52
55
  routerKey: must('TANGLE_API_KEY'),
53
- model: process.env.WORKER_MODEL ?? 'deepseek-v4-flash',
56
+ profile: benchRouterProfile(
57
+ 'humaneval-worker',
58
+ process.env.WORKER_MODEL ?? 'deepseek-v4-flash',
59
+ { temperature: WORKER_TEMP },
60
+ ),
61
+ }
62
+ const driverCfg: BenchRouterTarget = {
63
+ ...cfg,
64
+ profile: benchRouterProfile(
65
+ 'humaneval-driver',
66
+ process.env.DRIVER_MODEL ?? benchProfileModel(cfg.profile),
67
+ { maxTurns: K + 4 },
68
+ ),
54
69
  }
55
- const driverCfg: RouterConfig = { ...cfg, model: process.env.DRIVER_MODEL ?? cfg.model }
56
-
57
- // The driver-LLM brain is the SHARED `routerBrain` (the canonical ToolLoopChat seam) — it forwards
58
- // usage/costUsd, so this bench's driver arms meter their own inference into the conserved pool.
59
70
 
60
71
  // ── A gated router worker: one router call → candidate code, settled valid ⟺ the tests pass ──
61
72
  function humanEvalWorker(task: HumanEvalTask, label: string): Agent<unknown, unknown> {
62
- let artifact: ExecutorResult<unknown> | undefined
63
- const inner: Executor<unknown> = {
64
- runtime: 'router',
65
- async execute(_t, signal) {
66
- const res = await routerChatWithUsage(cfg, [{ role: 'user', content: basePrompt(task) }], {
67
- temperature: WORKER_TEMP,
68
- ...(signal ? { signal } : {}),
69
- })
70
- const code = extractCode(res.content)
71
- artifact = {
72
- outRef: contentAddress(code),
73
- out: code,
74
- spent: { iterations: 1, tokens: res.usage ?? { input: 0, output: 0 }, usd: res.costUsd ?? 0, ms: 0 },
75
- }
76
- return artifact
77
- },
78
- teardown: () => Promise.resolve({ destroyed: true }),
79
- resultArtifact: () => {
80
- if (!artifact) throw new Error('resultArtifact read before execute')
81
- return artifact
82
- },
83
- }
84
- const gated = gateOnDeliverable(inner, {
85
- check: async (out) => (await runChecker(task, String(out))).pass === 1,
86
- describe: `${task.taskId}: the provided test suite passes`,
73
+ const profile: AgentProfile = withBenchProfile(cfg.profile, {
74
+ name: label,
75
+ systemPrompt: basePrompt(task),
87
76
  })
88
- const spec: AgentSpec = { profile: { name: label } as AgentProfile, harness: null, executor: gated }
77
+ const routerFactory = createExecutor({
78
+ backend: 'router',
79
+ routerBaseUrl: cfg.routerBaseUrl,
80
+ routerKey: cfg.routerKey,
81
+ })
82
+ const executorFactory = (spec: AgentSpec, ctx: Parameters<typeof routerFactory>[1]) => {
83
+ const inner = routerFactory(spec, ctx)
84
+ const mapped = mapExecutorResult(inner, (result) => {
85
+ const raw = result.out as { content?: unknown }
86
+ const code = extractCode(typeof raw?.content === 'string' ? raw.content : '')
87
+ return { outRef: contentAddress(code), out: code }
88
+ })
89
+ return gateOnDeliverable(mapped, {
90
+ check: async (out) => (await runChecker(task, String(out))).pass === 1,
91
+ describe: `${task.taskId}: the provided test suite passes`,
92
+ })
93
+ }
94
+ const spec: AgentSpec = { profile, harness: null, executorFactory }
89
95
  return { name: label, act: async () => '', executorSpec: spec } as Agent<unknown, unknown> & {
90
96
  executorSpec: AgentSpec
91
97
  }
@@ -113,16 +119,21 @@ async function driveTask(
113
119
  spawns += 1
114
120
  return w
115
121
  }
116
- const opts: DriverAgentOptions = {
117
- name: `drv-${task.taskId}`,
118
- brain: routerBrain(driverCfg),
119
- blobs,
120
- makeWorkerAgent: makeWorker,
121
- perWorker: { maxIterations: 2, maxTokens: 4000 },
122
- systemPrompt: driverSystem,
123
- maxTurns: K + 4,
124
- }
125
- const root = driverAgent(opts)
122
+ const root = supervisorAgent(
123
+ withBenchProfile(driverCfg.profile, {
124
+ name: `drv-${task.taskId}`,
125
+ systemPrompt: driverSystem,
126
+ }),
127
+ {
128
+ router: {
129
+ routerBaseUrl: driverCfg.routerBaseUrl,
130
+ routerKey: driverCfg.routerKey,
131
+ },
132
+ blobs,
133
+ makeWorkerAgent: makeWorker,
134
+ perWorker: { maxIterations: 2, maxTokens: 4000 },
135
+ },
136
+ )
126
137
  const runId = `he-${task.taskId.replace('/', '-')}`
127
138
  const result = await createSupervisor<unknown, unknown>().run(root, basePrompt(task), {
128
139
  budget: { maxIterations: 100, maxTokens: 400_000 },
@@ -145,21 +156,31 @@ async function blindTask(task: HumanEvalTask): Promise<boolean> {
145
156
  for (let i = 0; i < K; i += 1) {
146
157
  // A transient router error is a FAILED attempt, not a crash — the driver arm already types
147
158
  // an executor throw into a `down` settlement, so the blind arm must match (fair comparison).
148
- let res: { content: string }
159
+ let content = ''
149
160
  try {
150
- res = await routerChatWithUsage(cfg, [{ role: 'user', content: basePrompt(task) }], {
151
- temperature: WORKER_TEMP,
152
- })
161
+ const res = await runBenchRouterTurn(
162
+ {
163
+ routerBaseUrl: cfg.routerBaseUrl,
164
+ routerKey: cfg.routerKey,
165
+ profile: withBenchProfile(cfg.profile, {
166
+ name: 'humaneval-blind-atom-worker',
167
+ }),
168
+ },
169
+ basePrompt(task),
170
+ )
171
+ content = res.finalText
153
172
  } catch {
154
173
  continue
155
174
  }
156
- if ((await runChecker(task, extractCode(res.content))).pass === 1) return true
175
+ if ((await runChecker(task, extractCode(content))).pass === 1) return true
157
176
  }
158
177
  return false
159
178
  }
160
179
 
161
180
  async function main(): Promise<void> {
162
- console.log(`atom-humaneval: N=${N} K=${K} offset=${OFFSET} worker=${cfg.model} driver=${driverCfg.model}`)
181
+ console.log(
182
+ `atom-humaneval: N=${N} K=${K} offset=${OFFSET} worker=${benchProfileModel(cfg.profile)} driver=${benchProfileModel(driverCfg.profile)}`,
183
+ )
163
184
  const tasks = await loadHumanEval(N, OFFSET)
164
185
  const outcomes: TaskOutcome[] = []
165
186
  for (const task of tasks) {
@@ -21,7 +21,9 @@ import {
21
21
  type Agent,
22
22
  type AgentProfile,
23
23
  type AgentSpec,
24
+ collectAgentTurn,
24
25
  contentAddress,
26
+ createExecutor,
25
27
  createExecutorRegistry,
26
28
  createSupervisor,
27
29
  type Executor,
@@ -31,6 +33,7 @@ import {
31
33
  InMemorySpawnJournal,
32
34
  runInWorkspace,
33
35
  type Scope,
36
+ streamAgentTurn,
34
37
  type Workspace,
35
38
  } from '../../src/runtime/index'
36
39
  import { asAuthoredProfile } from '../../src/runtime/supervise/authoring'
@@ -83,19 +86,30 @@ async function bridgeChat(opts: {
83
86
  cwd?: string
84
87
  mcpUrl?: string
85
88
  }): Promise<string> {
86
- const r = await fetch(`${BRIDGE}/chat/completions`, {
87
- method: 'POST',
88
- headers: { authorization: `Bearer ${BEARER}`, 'content-type': 'application/json' },
89
- body: JSON.stringify({
90
- model: MODEL,
91
- messages: opts.messages,
92
- ...(opts.cwd ? { cwd: opts.cwd } : {}),
93
- ...(opts.mcpUrl ? { mcp: { mcpServers: { coordination: { type: 'http', url: opts.mcpUrl } } } } : {}),
94
- }),
89
+ if (!BEARER) throw new Error('TANGLE_API_KEY is required')
90
+ const profile: AgentProfile = {
91
+ name: opts.mcpUrl ? 'atom-mcp-supervisor-turn' : 'atom-mcp-worker-turn',
92
+ model: { default: MODEL },
93
+ ...(opts.mcpUrl
94
+ ? { mcp: { coordination: { transport: 'http', url: opts.mcpUrl } } }
95
+ : {}),
96
+ }
97
+ const factory = createExecutor({
98
+ backend: 'bridge',
99
+ bridgeUrl: BRIDGE.replace(/\/v1$/u, ''),
100
+ bridgeBearer: BEARER,
101
+ ...(opts.cwd ? { cwd: opts.cwd } : {}),
95
102
  })
96
- if (!r.ok) return `(bridge HTTP ${r.status}: ${(await r.text()).slice(0, 200)})`
97
- const j = (await r.json()) as { choices?: Array<{ message?: { content?: string } }> }
98
- return j.choices?.[0]?.message?.content ?? ''
103
+ const turn = await collectAgentTurn(
104
+ streamAgentTurn(
105
+ { kind: 'executor', factory, profile },
106
+ opts.messages.map((message) => message.content).join('\n\n'),
107
+ ),
108
+ )
109
+ if (turn.status !== 'completed') {
110
+ throw new Error(turn.error?.message ?? `bridge turn ended with ${turn.status}`)
111
+ }
112
+ return turn.finalText
99
113
  }
100
114
 
101
115
  const transcripts: Array<{ who: string; said: string; delivered?: boolean }> = []
@@ -109,7 +123,7 @@ function makeWorker(rawProfile: unknown, ws: Workspace, n: number): Agent<unknow
109
123
  const inner: Executor<unknown> = {
110
124
  runtime: 'router',
111
125
  async execute() {
112
- const sys = p?.systemPrompt ?? TASK
126
+ const sys = p?.prompt.systemPrompt ?? TASK
113
127
  const run = await runInWorkspace(
114
128
  ws,
115
129
  async (cwd) => {
@@ -8,7 +8,13 @@
8
8
  */
9
9
  import assert from 'node:assert/strict'
10
10
  import { test } from 'node:test'
11
- import { appworldSolutionOutput, createAppWorldAdapter } from './appworld'
11
+ import {
12
+ appworldReactResultWithUsage,
13
+ appworldReactUsageEvent,
14
+ appworldSolutionOutput,
15
+ appworldToolLoopClient,
16
+ createAppWorldAdapter,
17
+ } from './appworld'
12
18
 
13
19
  type Events = Parameters<typeof appworldSolutionOutput.parse>[0]
14
20
  const stream = (text: string): Events => [{ data: { finalText: text } }] as unknown as Events
@@ -49,3 +55,115 @@ test('loadTasks either enumerates live engine rows or FAILS LOUD without fabrica
49
55
  assert.match((err as Error).message, /appworld driver failed|appworld import failed/)
50
56
  }
51
57
  })
58
+
59
+ test('successful react episode survives unknown catalog dollars without fabricating billed cost', () => {
60
+ const result = appworldReactResultWithUsage(
61
+ { success: true, passes: 3, fails: 0, num_tests: 3 },
62
+ {
63
+ input: 120,
64
+ output: 30,
65
+ tokensKnown: true,
66
+ costUsd: 0.0042,
67
+ usdKnown: false,
68
+ },
69
+ 2,
70
+ 'completed task',
71
+ )
72
+
73
+ assert.equal(result.success, true)
74
+ assert.equal(result.input_tokens, 120)
75
+ assert.equal(result.output_tokens, 30)
76
+ assert.equal(result.cost_usd, undefined)
77
+ const event = appworldReactUsageEvent(result, 'deepseek-v4-flash')
78
+ assert.deepEqual(event?.data, {
79
+ model: 'deepseek-v4-flash',
80
+ tokensIn: 120,
81
+ tokensOut: 30,
82
+ })
83
+ assert.equal(Object.hasOwn(event?.data ?? {}, 'costUsd'), false)
84
+ })
85
+
86
+ interface TestToolLoopBox {
87
+ streamPrompt(
88
+ prompt: string,
89
+ opts?: { signal?: AbortSignal },
90
+ ): AsyncGenerator<Record<string, unknown>>
91
+ }
92
+
93
+ async function testToolLoopBox(client: unknown): Promise<TestToolLoopBox> {
94
+ return (client as { create(): Promise<TestToolLoopBox> }).create()
95
+ }
96
+
97
+ async function drain(stream: AsyncGenerator<Record<string, unknown>>): Promise<void> {
98
+ for await (const _event of stream) {
99
+ // drain
100
+ }
101
+ }
102
+
103
+ test('react client refuses an already-aborted round before a Python session or model call', async () => {
104
+ let sessionCalls = 0
105
+ let modelCalls = 0
106
+ const controller = new AbortController()
107
+ controller.abort(new Error('already stopped'))
108
+ const client = appworldToolLoopClient({
109
+ model: 'offline-model',
110
+ routerBaseUrl: 'https://router.invalid',
111
+ routerKey: 'offline',
112
+ runWorldSession: async () => {
113
+ sessionCalls += 1
114
+ throw new Error('unexpected world session')
115
+ },
116
+ complete: async () => {
117
+ modelCalls += 1
118
+ return {}
119
+ },
120
+ })
121
+ const box = await testToolLoopBox(client)
122
+
123
+ await assert.rejects(
124
+ drain(box.streamPrompt('@appworld-react task-1 test_normal\n', { signal: controller.signal })),
125
+ /already stopped/,
126
+ )
127
+ assert.equal(sessionCalls, 0)
128
+ assert.equal(modelCalls, 0)
129
+ })
130
+
131
+ test('react client threads late abort to both the world session and Router call', async () => {
132
+ const controller = new AbortController()
133
+ let sessionSignal: AbortSignal | undefined
134
+ let modelSignal: AbortSignal | undefined
135
+ let sessionStopped = false
136
+ const client = appworldToolLoopClient({
137
+ model: 'offline-model',
138
+ routerBaseUrl: 'https://router.invalid',
139
+ routerKey: 'offline',
140
+ runWorldSession: async (_taskId, _split, signal, fn) => {
141
+ sessionSignal = signal
142
+ try {
143
+ return await fn(async () => ({ success: true, num_tests: 1, passes: 1 }), 'offline task')
144
+ } finally {
145
+ sessionStopped = signal.aborted
146
+ }
147
+ },
148
+ complete: async (_body, request) => {
149
+ modelSignal = request?.signal
150
+ return new Promise((_resolve, reject) => {
151
+ request?.signal?.addEventListener(
152
+ 'abort',
153
+ () => reject(request.signal?.reason ?? new Error('aborted')),
154
+ { once: true },
155
+ )
156
+ })
157
+ },
158
+ })
159
+ const box = await testToolLoopBox(client)
160
+ const running = drain(
161
+ box.streamPrompt('@appworld-react task-1 test_normal\n', { signal: controller.signal }),
162
+ )
163
+ setTimeout(() => controller.abort(new Error('late stop')), 0)
164
+
165
+ await assert.rejects(running, /aborted|late stop/i)
166
+ assert.equal(sessionSignal, controller.signal)
167
+ assert.equal(modelSignal?.aborted, true)
168
+ assert.equal(sessionStopped, true)
169
+ })
@@ -22,7 +22,13 @@
22
22
  import { spawn } from 'node:child_process'
23
23
  import { join } from 'node:path'
24
24
  import { createInterface } from 'node:readline'
25
- import { type OutputAdapter, routerToolLoop, type ToolSpec } from '@tangle-network/agent-runtime/kernel'
25
+ import {
26
+ collectAgentTurn,
27
+ createExecutor,
28
+ type OutputAdapter,
29
+ streamAgentTurn,
30
+ type ToolSpec,
31
+ } from '@tangle-network/agent-runtime/kernel'
26
32
  import { benchRoot, preflightVenvImports, runVenvScriptStdin, venvPython } from './_harness'
27
33
  import type { BenchmarkAdapter, BenchScore, BenchTask, LoadOptions } from './types'
28
34
 
@@ -171,7 +177,7 @@ export function createAppWorldAdapter(): BenchmarkAdapter {
171
177
 
172
178
  /**
173
179
  * AppWorld in its NATIVE protocol, run by OUR runtime: the worker is
174
- * `routerToolLoop` (the runtime's off-box agentic tool loop) with one tool —
180
+ * Runtime's profile-bound `router-tools` executor with one tool —
175
181
  * `execute_python` — bound to a persistent AppWorld world session. The driver's
176
182
  * `session` subcommand is a dumb world shim (stdin JSONL: execute → output,
177
183
  * evaluate → verdict); every inference turn, the metering, and the typed
@@ -190,7 +196,7 @@ export function createAppWorldAdapter(): BenchmarkAdapter {
190
196
  * (AppWorld's evaluator ran in-world); judge() parses it, never re-executes.
191
197
  */
192
198
 
193
- interface ReactResult {
199
+ export interface ReactResult {
194
200
  success?: boolean
195
201
  passes?: number
196
202
  fails?: number
@@ -199,9 +205,61 @@ interface ReactResult {
199
205
  turns?: number
200
206
  input_tokens?: number
201
207
  output_tokens?: number
208
+ cost_usd?: number
202
209
  transcript?: string
203
210
  }
204
211
 
212
+ interface ReactRuntimeUsage {
213
+ input: number
214
+ output: number
215
+ costUsd?: number
216
+ tokensKnown?: boolean
217
+ usdKnown?: boolean
218
+ }
219
+
220
+ /** Preserve a completed scientific/task result even when one accounting dimension is incomplete.
221
+ * Unknown usage fields stay absent; later comparison/reporting can refuse a cost claim without
222
+ * discarding the episode's task evidence. */
223
+ export function appworldReactResultWithUsage(
224
+ verdict: ReactResult,
225
+ usage: ReactRuntimeUsage,
226
+ turns: number | undefined,
227
+ transcript: string,
228
+ ): ReactResult {
229
+ return {
230
+ ...verdict,
231
+ ...(turns !== undefined ? { turns } : {}),
232
+ ...(usage.tokensKnown === false
233
+ ? {}
234
+ : { input_tokens: usage.input, output_tokens: usage.output }),
235
+ ...(usage.usdKnown === false || usage.costUsd === undefined
236
+ ? {}
237
+ : { cost_usd: usage.costUsd }),
238
+ transcript,
239
+ }
240
+ }
241
+
242
+ /** Emit only usage the Runtime actually knows. Catalog estimates never become observed dollars. */
243
+ export function appworldReactUsageEvent(
244
+ result: ReactResult,
245
+ model: string,
246
+ ): { type: 'llm_call'; data: Record<string, unknown> } | undefined {
247
+ const hasTokens =
248
+ typeof result.input_tokens === 'number' && typeof result.output_tokens === 'number'
249
+ const hasCost = typeof result.cost_usd === 'number'
250
+ if (!hasTokens && !hasCost) return undefined
251
+ return {
252
+ type: 'llm_call',
253
+ data: {
254
+ model,
255
+ ...(hasTokens
256
+ ? { tokensIn: result.input_tokens, tokensOut: result.output_tokens }
257
+ : {}),
258
+ ...(hasCost ? { costUsd: result.cost_usd } : {}),
259
+ },
260
+ }
261
+ }
262
+
205
263
  const REACT_HEADER = /^@appworld-react (\S+) (\S+)\n?/
206
264
 
207
265
  const SESSION_SYSTEM = [
@@ -231,11 +289,17 @@ const EXECUTE_TOOL: ToolSpec = {
231
289
  async function withWorldSession<T>(
232
290
  taskId: string,
233
291
  split: string,
292
+ signal: AbortSignal,
234
293
  fn: (call: (cmd: Record<string, unknown>) => Promise<Record<string, unknown>>, instruction: string) => Promise<T>,
235
294
  ): Promise<T> {
295
+ signal.throwIfAborted()
236
296
  const child = spawn(venvPython, [DRIVER, 'session', '--task-id', taskId, '--split', split], {
237
297
  cwd: benchRoot,
238
298
  })
299
+ const stopChild = (): void => {
300
+ if (!child.killed) child.kill('SIGTERM')
301
+ }
302
+ signal.addEventListener('abort', stopChild, { once: true })
239
303
  const rl = createInterface({ input: child.stdout })
240
304
  const pending: Array<(line: string) => void> = []
241
305
  const backlog: string[] = []
@@ -250,6 +314,10 @@ async function withWorldSession<T>(
250
314
  })
251
315
  const nextLine = (timeoutMs: number): Promise<string> =>
252
316
  new Promise((resolve, reject) => {
317
+ if (signal.aborted) {
318
+ reject(signal.reason)
319
+ return
320
+ }
253
321
  const fromBacklog = backlog.shift()
254
322
  if (fromBacklog !== undefined) return resolve(fromBacklog)
255
323
  const t = setTimeout(
@@ -260,14 +328,29 @@ async function withWorldSession<T>(
260
328
  // cap) — remove it on the resolve path.
261
329
  const onExit = (code: number | null): void => {
262
330
  clearTimeout(t)
263
- reject(new Error(`appworld session exited (${code}); stderr: ${stderr.slice(-400)}`))
331
+ signal.removeEventListener('abort', onAbort)
332
+ reject(
333
+ signal.aborted
334
+ ? signal.reason
335
+ : new Error(`appworld session exited (${code}); stderr: ${stderr.slice(-400)}`),
336
+ )
264
337
  }
265
- pending.push((l) => {
338
+ const onAbort = (): void => {
266
339
  clearTimeout(t)
267
340
  child.removeListener('exit', onExit)
268
- resolve(l)
269
- })
341
+ const index = pending.indexOf(onLine)
342
+ if (index >= 0) pending.splice(index, 1)
343
+ reject(signal.reason)
344
+ }
345
+ const onLine = (line: string): void => {
346
+ clearTimeout(t)
347
+ child.removeListener('exit', onExit)
348
+ signal.removeEventListener('abort', onAbort)
349
+ resolve(line)
350
+ }
351
+ pending.push(onLine)
270
352
  child.once('exit', onExit)
353
+ signal.addEventListener('abort', onAbort, { once: true })
271
354
  })
272
355
  try {
273
356
  const ready = JSON.parse(await nextLine(120_000)) as { ready?: boolean; instruction?: string; error?: string }
@@ -280,26 +363,43 @@ async function withWorldSession<T>(
280
363
  }
281
364
  return await fn(call, ready.instruction ?? '')
282
365
  } finally {
366
+ signal.removeEventListener('abort', stopChild)
283
367
  child.stdin.end()
284
- child.kill('SIGTERM')
368
+ stopChild()
285
369
  }
286
370
  }
287
371
 
288
- /** SandboxClient whose leaf is OUR routerToolLoop driving a persistent world session. */
372
+ type AppWorldWorldSession = typeof withWorldSession
373
+ type AppWorldComplete = (
374
+ body: Record<string, unknown>,
375
+ request?: {
376
+ readonly headers: Readonly<Record<string, string>>
377
+ readonly signal?: AbortSignal
378
+ },
379
+ ) => Promise<unknown>
380
+
381
+ /** SandboxClient whose leaf is Runtime's profile-bound Router executor driving a world session. */
289
382
  export function appworldToolLoopClient(cfg: {
290
383
  model: string
291
384
  routerBaseUrl: string
292
385
  routerKey: string
293
386
  maxTurns?: number
387
+ /** Offline-test seam; production always uses the Python AppWorld session above. */
388
+ runWorldSession?: AppWorldWorldSession
389
+ /** Offline-test seam; production uses Runtime's Router HTTP transport. */
390
+ complete?: AppWorldComplete
294
391
  }): unknown {
295
392
  const maxTurns = cfg.maxTurns ?? Number(process.env.REACT_MAX_TURNS ?? 40)
393
+ const runWorldSession = cfg.runWorldSession ?? withWorldSession
296
394
  let seq = 0
297
395
  return {
298
396
  async create() {
299
397
  const id = `appworld-toolloop-${seq++}`
300
398
  return {
301
399
  id,
302
- async *streamPrompt(prompt: string) {
400
+ async *streamPrompt(prompt: string, promptOpts?: { signal?: AbortSignal }) {
401
+ const signal = promptOpts?.signal ?? new AbortController().signal
402
+ signal.throwIfAborted()
303
403
  const m = prompt.match(REACT_HEADER)
304
404
  if (!m) {
305
405
  throw new Error(
@@ -308,42 +408,65 @@ export function appworldToolLoopClient(cfg: {
308
408
  }
309
409
  const [, taskId, split] = m
310
410
  const directive = prompt.replace(REACT_HEADER, '').trim()
311
- const out = await withWorldSession(taskId as string, split as string, async (call, instruction) => {
411
+ const out = await runWorldSession(taskId as string, split as string, signal, async (call, instruction) => {
312
412
  const system = directive ? `${SESSION_SYSTEM}\n\n${directive}` : SESSION_SYSTEM
313
- const loop = await routerToolLoop(
314
- { routerBaseUrl: cfg.routerBaseUrl, routerKey: cfg.routerKey, model: cfg.model },
315
- system,
316
- `Task: ${instruction}`,
317
- [EXECUTE_TOOL],
318
- async (name, args) => {
413
+ const transcriptSteps: Array<{ args: string; result: string }> = []
414
+ const profile = {
415
+ name: 'appworld-react-worker',
416
+ harness: 'cli-base' as const,
417
+ model: {
418
+ provider: 'tangle-router',
419
+ default: cfg.model,
420
+ metadata: { maxTurns },
421
+ },
422
+ prompt: { systemPrompt: system },
423
+ tools: { execute_python: true },
424
+ }
425
+ const factory = createExecutor({
426
+ backend: 'router-tools',
427
+ routerBaseUrl: cfg.routerBaseUrl,
428
+ routerKey: cfg.routerKey,
429
+ ...(cfg.complete ? { complete: cfg.complete } : {}),
430
+ tools: [EXECUTE_TOOL],
431
+ executeToolCall: async (name, args) => {
319
432
  if (name !== 'execute_python') return `error: unknown tool ${name}`
320
433
  const res = await call({ op: 'execute', code: String(args.code ?? '') })
321
434
  const done = res.task_completed === true
322
- return `${String(res.output ?? '')}${done ? '\n\n[TASK MARKED COMPLETE — reply with a final summary and do not call the tool again]' : ''}`
435
+ const result = `${String(res.output ?? '')}${done ? '\n\n[TASK MARKED COMPLETE — reply with a final summary and do not call the tool again]' : ''}`
436
+ transcriptSteps.push({ args: JSON.stringify(args), result })
437
+ return result
323
438
  },
324
- { maxTurns },
439
+ })
440
+ const loop = await collectAgentTurn(
441
+ streamAgentTurn(
442
+ { kind: 'executor', factory, profile },
443
+ `Task: ${instruction}`,
444
+ { signal },
445
+ ),
325
446
  )
447
+ if (loop.status !== 'completed') {
448
+ throw new Error(loop.error?.message ?? `AppWorld turn ended with ${loop.status}`)
449
+ }
326
450
  const verdict = (await call({ op: 'evaluate' })) as unknown as ReactResult
327
- const transcript = loop.toolTrace
451
+ const transcript = transcriptSteps
328
452
  .slice(-3)
329
453
  .map((t) => `CODE:\n${t.args.slice(0, 600)}\nOUTPUT:\n${t.result.slice(0, 600)}`)
330
454
  .join('\n---\n')
331
455
  .slice(0, 1600)
332
- return {
333
- ...verdict,
334
- turns: loop.turns,
335
- input_tokens: loop.usage.input,
336
- output_tokens: loop.usage.output,
456
+ const finalEvent = loop.events.at(-1)
457
+ const resultMetadata =
458
+ finalEvent?.type === 'final' && finalEvent.metadata?.result
459
+ ? (finalEvent.metadata.result as { spent?: { iterations?: number } })
460
+ : undefined
461
+ return appworldReactResultWithUsage(
462
+ verdict,
463
+ loop.usage,
464
+ resultMetadata?.spent?.iterations,
337
465
  transcript,
338
- } satisfies ReactResult
466
+ )
339
467
  })
340
- // Real usage from the episode — flat llm_call so the kernel meters it.
341
- if (out.input_tokens || out.output_tokens) {
342
- yield {
343
- type: 'llm_call',
344
- data: { tokensIn: out.input_tokens ?? 0, tokensOut: out.output_tokens ?? 0, model: cfg.model },
345
- }
346
- }
468
+ const usageEvent = appworldReactUsageEvent(out, cfg.model)
469
+ if (usageEvent) yield usageEvent
347
470
  yield { type: 'result', data: { finalText: JSON.stringify(out) } }
348
471
  },
349
472
  async delete() {},