@tangle-network/agent-bench 0.8.18 → 0.8.22

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.
@@ -7,12 +7,16 @@
7
7
  * choose env names, default domain, and fixture file.
8
8
  */
9
9
 
10
+ import { execFile } from 'node:child_process'
10
11
  import { readFile, stat } from 'node:fs/promises'
11
12
  import { resolve } from 'node:path'
13
+ import { promisify } from 'node:util'
12
14
  import type { OutputAdapter } from '@tangle-network/agent-runtime/kernel'
13
15
  import { runVenvPython } from './_harness'
14
16
  import type { BenchmarkAdapter, BenchScore, BenchTask, LoadOptions } from './types'
15
17
 
18
+ const execFileAsync = promisify(execFile)
19
+
16
20
  export interface TauBenchConfig {
17
21
  name: string
18
22
  fixturePath: string
@@ -39,8 +43,84 @@ interface TauMeta {
39
43
  userScenario?: unknown
40
44
  description?: unknown
41
45
  evaluationCriteria?: unknown
46
+ /** Git commit of the upstream checkout the task was loaded from. Official loads always carry it; fixtures never do. */
47
+ upstreamCommit?: string
48
+ /** Installed `tau2` distribution version in the interpreter that loaded the task. */
49
+ upstreamVersion?: string
50
+ }
51
+
52
+ /** The exact upstream identity a tau task or score is bound to. */
53
+ export interface TauUpstreamPin {
54
+ upstreamCommit: string
55
+ upstreamVersion: string
56
+ }
57
+
58
+ /**
59
+ * Resolve the upstream checkout's git identity. Throws when the directory is
60
+ * not a git checkout or holds uncommitted changes, because a commit over dirty
61
+ * state does not identify the code that produced the tasks or the score.
62
+ */
63
+ async function resolveUpstreamCommit(root: string, benchName: string): Promise<string> {
64
+ let head: string
65
+ try {
66
+ const { stdout } = await execFileAsync('git', ['-C', root, 'rev-parse', 'HEAD'])
67
+ head = stdout.trim()
68
+ } catch (err) {
69
+ throw new Error(
70
+ `${benchName}: cannot resolve the upstream commit of ${root} (${err instanceof Error ? err.message : err}). ` +
71
+ 'The bench dir must be a git checkout of https://github.com/sierra-research/tau2-bench.',
72
+ )
73
+ }
74
+ const { stdout: status } = await execFileAsync('git', ['-C', root, 'status', '--porcelain'])
75
+ if (status.trim().length > 0) {
76
+ const dirty = status.trim().split('\n').slice(0, 5).join(', ')
77
+ throw new Error(
78
+ `${benchName}: upstream checkout ${root} has uncommitted changes (${dirty}). ` +
79
+ 'Commit or stash them so the pinned commit identifies the benchmark code.',
80
+ )
81
+ }
82
+ return head
42
83
  }
43
84
 
85
+ /**
86
+ * Refuse to score a task against a checkout that moved after the task was
87
+ * loaded: the reward recomputation would run under a different upstream
88
+ * identity than the one stamped on the task.
89
+ */
90
+ export function assertTauUpstreamPinUnchanged(
91
+ benchName: string,
92
+ taskId: string,
93
+ recorded: Readonly<{ upstreamCommit?: string; upstreamVersion?: string }>,
94
+ live: TauUpstreamPin,
95
+ ): void {
96
+ if (recorded.upstreamCommit !== undefined && recorded.upstreamCommit !== live.upstreamCommit) {
97
+ throw new Error(
98
+ `${benchName} task ${taskId} was loaded from upstream commit ${recorded.upstreamCommit} ` +
99
+ `but the checkout is now at ${live.upstreamCommit}; reload tasks before judging.`,
100
+ )
101
+ }
102
+ if (recorded.upstreamVersion !== undefined && recorded.upstreamVersion !== live.upstreamVersion) {
103
+ throw new Error(
104
+ `${benchName} task ${taskId} was loaded with tau2 ${recorded.upstreamVersion} ` +
105
+ `but the interpreter now holds tau2 ${live.upstreamVersion}; reload tasks before judging.`,
106
+ )
107
+ }
108
+ }
109
+
110
+ const upstreamVersionSnippet = `
111
+ import sys
112
+ sys.dont_write_bytecode = True
113
+ import importlib.metadata
114
+ try:
115
+ upstream_version = importlib.metadata.version("tau2")
116
+ except importlib.metadata.PackageNotFoundError:
117
+ raise SystemExit(
118
+ "installed tau2 distribution not found in this interpreter; "
119
+ "run 'uv sync' inside the tau2-bench checkout and set "
120
+ "AGENT_BENCH_PYTHON to <checkout>/.venv/bin/python3"
121
+ )
122
+ `
123
+
44
124
  export const tauResultsOutput: OutputAdapter<string> = {
45
125
  parse(events) {
46
126
  let text = ''
@@ -70,7 +150,7 @@ function benchDomain(config: TauBenchConfig): string {
70
150
  return process.env[config.domainEnv] ?? config.defaultDomain
71
151
  }
72
152
 
73
- function rowToTask(row: TauRow, config: TauBenchConfig, split?: string): BenchTask {
153
+ function rowToTask(row: TauRow, config: TauBenchConfig, split?: string, pin?: TauUpstreamPin): BenchTask {
74
154
  const meta: TauMeta = {
75
155
  taskId: row.id,
76
156
  domain: row.domain,
@@ -78,6 +158,7 @@ function rowToTask(row: TauRow, config: TauBenchConfig, split?: string): BenchTa
78
158
  userScenario: row.user_scenario,
79
159
  description: row.description,
80
160
  evaluationCriteria: row.evaluation_criteria,
161
+ ...(pin ?? {}),
81
162
  }
82
163
  return {
83
164
  id: row.id,
@@ -103,8 +184,8 @@ function readMeta(task: BenchTask, benchName: string): TauMeta {
103
184
  return md as unknown as TauMeta
104
185
  }
105
186
 
106
- function selectRows(rows: TauRow[], opts: LoadOptions, config: TauBenchConfig, split?: string): BenchTask[] {
107
- let tasks = rows.map((row) => rowToTask(row, config, split))
187
+ function selectRows(rows: TauRow[], opts: LoadOptions, config: TauBenchConfig, split?: string, pin?: TauUpstreamPin): BenchTask[] {
188
+ let tasks = rows.map((row) => rowToTask(row, config, split, pin))
108
189
  if (opts.ids) {
109
190
  const want = new Set(opts.ids)
110
191
  tasks = tasks.filter((task) => want.has(task.id))
@@ -130,6 +211,7 @@ root = Path(sys.argv[1])
130
211
  domain = sys.argv[2]
131
212
  split = sys.argv[3] or None
132
213
  sys.path.insert(0, str(root / "src"))
214
+ ${upstreamVersionSnippet}
133
215
  from tau2.registry import registry
134
216
  loader = registry.get_tasks_loader(domain)
135
217
  tasks = loader(split)
@@ -138,10 +220,16 @@ for task in tasks:
138
220
  row = task.model_dump(mode="json")
139
221
  row["domain"] = domain
140
222
  rows.append(row)
141
- print(json.dumps(rows))
223
+ print(json.dumps({"upstreamVersion": upstream_version, "rows": rows}))
142
224
  `
225
+ const upstreamCommit = await resolveUpstreamCommit(root, config.name)
143
226
  const stdout = await runVenvPython(script, [root, domain, opts.split ?? ''])
144
- return selectRows(JSON.parse(stdout) as TauRow[], opts, config, opts.split)
227
+ const report = JSON.parse(stdout) as { upstreamVersion?: unknown; rows?: unknown }
228
+ if (typeof report.upstreamVersion !== 'string' || report.upstreamVersion.length === 0 || !Array.isArray(report.rows)) {
229
+ throw new Error(`${config.name}: upstream task loader returned no pinned version/rows`)
230
+ }
231
+ const pin: TauUpstreamPin = { upstreamCommit, upstreamVersion: report.upstreamVersion }
232
+ return selectRows(report.rows as TauRow[], opts, config, opts.split, pin)
145
233
  }
146
234
 
147
235
  async function scoreOfficialTrajectory(root: string, meta: TauMeta, artifactPath: string): Promise<Record<string, unknown>> {
@@ -152,6 +240,7 @@ root = Path(sys.argv[1])
152
240
  task_id = sys.argv[2]
153
241
  artifact = Path(sys.argv[3])
154
242
  sys.path.insert(0, str(root / "src"))
243
+ ${upstreamVersionSnippet}
155
244
  from tau2.data_model.simulation import Results
156
245
  from tau2.scripts.evaluate_trajectories import compute_simulation_rewards
157
246
  results = Results.load(artifact)
@@ -162,7 +251,7 @@ for sim in updated.simulations:
162
251
  scores.append(float(sim.reward_info.reward))
163
252
  if not scores:
164
253
  raise SystemExit(f"no scored simulations for task_id={task_id} in {artifact}")
165
- print(json.dumps({"count": len(scores), "score": sum(scores) / len(scores), "scores": scores}))
254
+ print(json.dumps({"count": len(scores), "score": sum(scores) / len(scores), "scores": scores, "upstreamVersion": upstream_version}))
166
255
  `
167
256
  const stdout = await runVenvPython(script, [root, meta.taskId, artifactPath], 0)
168
257
  return JSON.parse(stdout.trim().split('\n').at(-1) ?? '{}') as Record<string, unknown>
@@ -202,12 +291,25 @@ export function createTauBenchAdapter(config: TauBenchConfig): BenchmarkAdapter
202
291
  const meta = readMeta(task, config.name)
203
292
  const artifactPath = resolve(artifact.trim())
204
293
  await assertPath(artifactPath, 'tau results/trajectory artifact', config.name)
294
+ const upstreamCommit = await resolveUpstreamCommit(dir, config.name)
205
295
  const report = await scoreOfficialTrajectory(dir, meta, artifactPath)
296
+ const upstreamVersion = report.upstreamVersion
297
+ if (typeof upstreamVersion !== 'string' || upstreamVersion.length === 0) {
298
+ throw new Error(`${config.name}: reward recomputation returned no pinned tau2 version`)
299
+ }
300
+ const live: TauUpstreamPin = { upstreamCommit, upstreamVersion }
301
+ assertTauUpstreamPinUnchanged(config.name, meta.taskId, meta, live)
206
302
  const score = typeof report.score === 'number' ? report.score : 0
207
303
  return {
208
304
  resolved: score === 1,
209
305
  score,
210
- detail: JSON.stringify({ taskId: meta.taskId, domain: meta.domain, count: report.count }),
306
+ detail: JSON.stringify({
307
+ taskId: meta.taskId,
308
+ domain: meta.domain,
309
+ count: report.count,
310
+ upstreamCommit,
311
+ upstreamVersion,
312
+ }),
211
313
  }
212
314
  },
213
315
  }
@@ -0,0 +1,53 @@
1
+ /**
2
+ * The two tau upstream-pin refusals that protect a benchmark receipt: a score
3
+ * must never be attributed to code the checkout no longer holds.
4
+ */
5
+ import assert from 'node:assert/strict'
6
+ import { execFileSync } from 'node:child_process'
7
+ import { mkdtemp, rm, writeFile } from 'node:fs/promises'
8
+ import { tmpdir } from 'node:os'
9
+ import { join } from 'node:path'
10
+ import test from 'node:test'
11
+ import { assertTauUpstreamPinUnchanged } from './tau-bench-shared'
12
+ import { createTau3BankingAdapter } from './tau3-banking'
13
+
14
+ function git(dir: string, ...args: string[]): string {
15
+ return execFileSync('git', ['-C', dir, ...args], { encoding: 'utf8' }).trim()
16
+ }
17
+
18
+ test('a dirty upstream checkout is refused before any task load', async () => {
19
+ const dir = await mkdtemp(join(tmpdir(), 'tau-pin-'))
20
+ const previous = process.env.TAU3_BENCH_DIR
21
+ const fixtures = process.env.TAU3_FIXTURES
22
+ try {
23
+ await writeFile(join(dir, 'placeholder.txt'), 'committed')
24
+ git(dir, 'init', '--quiet')
25
+ // Fixture repo: keep machine-wide hooks out of the throwaway commit.
26
+ git(dir, 'config', 'core.hooksPath', '/dev/null')
27
+ git(dir, 'add', '-A')
28
+ git(dir, '-c', 'user.email=bench@test', '-c', 'user.name=bench', 'commit', '--quiet', '-m', 'pin fixture')
29
+ await writeFile(join(dir, 'placeholder.txt'), 'edited after the commit')
30
+
31
+ delete process.env.TAU3_FIXTURES
32
+ process.env.TAU3_BENCH_DIR = dir
33
+ await assert.rejects(() => createTau3BankingAdapter().loadTasks({ limit: 1 }), /uncommitted changes/)
34
+ } finally {
35
+ if (previous === undefined) delete process.env.TAU3_BENCH_DIR
36
+ else process.env.TAU3_BENCH_DIR = previous
37
+ if (fixtures !== undefined) process.env.TAU3_FIXTURES = fixtures
38
+ await rm(dir, { recursive: true, force: true })
39
+ }
40
+ })
41
+
42
+ test('a checkout or interpreter that moved after load is refused at judge time', () => {
43
+ const live = { upstreamCommit: 'abc', upstreamVersion: '1.0.1' }
44
+ assertTauUpstreamPinUnchanged('tau3-banking', 'task_001', live, live)
45
+ assert.throws(
46
+ () => assertTauUpstreamPinUnchanged('tau3-banking', 'task_001', { upstreamCommit: 'def' }, live),
47
+ /loaded from upstream commit def/,
48
+ )
49
+ assert.throws(
50
+ () => assertTauUpstreamPinUnchanged('tau3-banking', 'task_001', { upstreamVersion: '0.9.0' }, live),
51
+ /loaded with tau2 0\.9\.0/,
52
+ )
53
+ })
@@ -8,7 +8,7 @@
8
8
  * from the benchmark's published evaluation harness.
9
9
  */
10
10
 
11
- import type { OutputAdapter } from '@tangle-network/agent-runtime/kernel'
11
+ import type { AgentTurnUsage, OutputAdapter } from '@tangle-network/agent-runtime/kernel'
12
12
 
13
13
  export interface BenchTask {
14
14
  /** Stable benchmark instance id. */
@@ -54,6 +54,11 @@ export interface BenchScore {
54
54
  detail?: string
55
55
  /** Present only when the caller explicitly requested durable judge evidence. */
56
56
  judgeArtifacts?: JudgeArtifactReceipt
57
+ /** The judge model turn's exact Runtime usage record, present only when the
58
+ * adapter's judge is itself a model call. `usdKnown: false` and
59
+ * `tokensKnown: false` survive verbatim — an unknown judge cost stays
60
+ * unknown instead of reading as zero. Deterministic judges never set it. */
61
+ judgeUsage?: AgentTurnUsage
57
62
  }
58
63
 
59
64
  export interface LoadOptions {
@@ -358,7 +358,8 @@ async function complete(cfg: ClientCfg, messages: Array<{ role: string; content:
358
358
  model: {
359
359
  provider: 'tangle-router',
360
360
  default: cfg.model,
361
- metadata: { temperature: cfg.temperature, maxTokens: cfg.maxTokens },
361
+ metadata: { temperature: cfg.temperature },
362
+ maxVisibleOutputTokens: cfg.maxTokens,
362
363
  },
363
364
  ...(system ? { prompt: { systemPrompt: system } } : {}),
364
365
  },
@@ -339,7 +339,8 @@ async function complete(cfg: ClientCfg, messages: Array<{ role: string; content:
339
339
  model: {
340
340
  provider: 'tangle-router',
341
341
  default: cfg.model,
342
- metadata: { temperature: cfg.temperature, maxTokens: cfg.maxTokens },
342
+ metadata: { temperature: cfg.temperature },
343
+ maxVisibleOutputTokens: cfg.maxTokens,
343
344
  },
344
345
  ...(system ? { prompt: { systemPrompt: system } } : {}),
345
346
  },
@@ -93,10 +93,10 @@ export function officialOptimizerModel(options: {
93
93
  provider: options.provider ?? new URL(options.baseUrl).hostname,
94
94
  default: options.model,
95
95
  ...(options.reasoningEffort ? { reasoningEffort: options.reasoningEffort } : {}),
96
- metadata: {
97
- maxTokens: options.maxOutputTokensPerRequest,
98
- ...(options.temperature !== undefined ? { temperature: options.temperature } : {}),
99
- },
96
+ maxVisibleOutputTokens: options.maxOutputTokensPerRequest,
97
+ ...(options.temperature !== undefined
98
+ ? { metadata: { temperature: options.temperature } }
99
+ : {}),
100
100
  },
101
101
  }
102
102
  const profileDigest = canonicalAgentProfileDigest(profile)
@@ -69,7 +69,6 @@ export function withBenchProfile(
69
69
  const metadata = {
70
70
  ...(base.model?.metadata ?? {}),
71
71
  ...(settings.temperature !== undefined ? { temperature: settings.temperature } : {}),
72
- ...(settings.maxTokens !== undefined ? { maxTokens: settings.maxTokens } : {}),
73
72
  ...(settings.retry !== undefined ? { retry: settings.retry } : {}),
74
73
  ...(settings.maxTurns !== undefined ? { maxTurns: settings.maxTurns } : {}),
75
74
  ...(settings.seed !== undefined ? { seed: settings.seed } : {}),
@@ -84,6 +83,10 @@ export function withBenchProfile(
84
83
  ...(settings.reasoningEffort !== undefined
85
84
  ? { reasoningEffort: settings.reasoningEffort }
86
85
  : {}),
86
+ // A bench cap bounds the VISIBLE answer, which the Router lowers as `max_tokens`.
87
+ ...(settings.maxTokens !== undefined
88
+ ? { maxVisibleOutputTokens: settings.maxTokens }
89
+ : {}),
87
90
  ...(Object.keys(metadata).length > 0 ? { metadata } : {}),
88
91
  },
89
92
  ...(settings.systemPrompt !== undefined
@@ -307,7 +307,8 @@ async function complete(cfg: ClientCfg, messages: Array<{ role: string; content:
307
307
  model: {
308
308
  provider: 'tangle-router',
309
309
  default: cfg.model,
310
- metadata: { temperature: cfg.temperature, maxTokens: cfg.maxTokens },
310
+ metadata: { temperature: cfg.temperature },
311
+ maxVisibleOutputTokens: cfg.maxTokens,
311
312
  },
312
313
  ...(system ? { prompt: { systemPrompt: system } } : {}),
313
314
  },
@@ -141,7 +141,8 @@ export function httpCapacityProbe(spec: HttpProbeSpec): CapacityProbe {
141
141
  model: {
142
142
  provider: spec.provider,
143
143
  default: spec.model,
144
- metadata: { temperature: 0, maxTokens: spec.maxTokens ?? 8000 },
144
+ metadata: { temperature: 0 },
145
+ maxVisibleOutputTokens: spec.maxTokens ?? 8000,
145
146
  },
146
147
  prompt: { systemPrompt: 'Reply with the single word OK.' },
147
148
  },
@@ -32,7 +32,8 @@ async function main(): Promise<void> {
32
32
  model: {
33
33
  provider: 'tangle-router',
34
34
  default: workerModel,
35
- metadata: { maxTokens: 8000, maxTurns: innerTurns },
35
+ metadata: { maxTurns: innerTurns },
36
+ maxVisibleOutputTokens: 8000,
36
37
  },
37
38
  }
38
39
  const authorProfile = (model: string, name: string): AgentProfile => ({
@@ -41,7 +42,7 @@ async function main(): Promise<void> {
41
42
  model: {
42
43
  provider: 'tangle-router',
43
44
  default: model,
44
- metadata: { maxTokens: 8000 },
45
+ maxVisibleOutputTokens: 8000,
45
46
  },
46
47
  prompt: { systemPrompt: strategyAuthorSystemPrompt },
47
48
  })
@@ -134,7 +134,8 @@ async function chatComplete(
134
134
  model: {
135
135
  provider: 'tangle-router',
136
136
  default: model,
137
- metadata: { temperature: 0, maxTokens },
137
+ metadata: { temperature: 0 },
138
+ maxVisibleOutputTokens: maxTokens,
138
139
  },
139
140
  ...(system ? { prompt: { systemPrompt: system } } : {}),
140
141
  },
@@ -69,7 +69,8 @@ const result = await runBenchRouterTurn(
69
69
  model: {
70
70
  provider: 'tangle-router',
71
71
  default: model,
72
- metadata: { temperature, maxTokens },
72
+ metadata: { temperature },
73
+ maxVisibleOutputTokens: maxTokens,
73
74
  },
74
75
  prompt: { systemPrompt: instruction },
75
76
  },