@tangle-network/agent-bench 0.8.23 → 0.9.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.
@@ -35,7 +35,7 @@ import type {
35
35
  Deliverable,
36
36
  OpenSandboxRunOptions,
37
37
  } from '@tangle-network/agent-runtime/kernel'
38
- import { openSandboxRun } from '@tangle-network/agent-runtime/kernel'
38
+ import { openSandboxRun, SandboxRunAbortError, sumSandboxUsage } from '@tangle-network/agent-runtime/kernel'
39
39
  import type { SandboxEvent } from '@tangle-network/sandbox'
40
40
  import { resolveAdapter } from './adapters'
41
41
  import type { BenchmarkAdapter, BenchScore, BenchTask } from './benchmarks/types'
@@ -60,8 +60,17 @@ export interface BenchCell {
60
60
  readonly profile?: AgentProfile
61
61
  }
62
62
 
63
- /** Runs one (adapter, task, cell) shot and returns the deliverable text. The default uses
64
- * `openSandboxRun`; tests inject a deterministic stub so the matrix runs offline. */
63
+ /** A worker's artifact and observed execution evidence, before external grading. */
64
+ export interface BenchShotResult {
65
+ readonly artifact: string
66
+ readonly ok: boolean
67
+ readonly detail?: string
68
+ /** Provider observations, including explicit unknown counters. Omitted when the shot reports none. */
69
+ readonly usage?: ReturnType<typeof sumSandboxUsage>
70
+ readonly events?: readonly SandboxEvent[]
71
+ }
72
+
73
+ /** Runs one (adapter, task, cell) shot. Defaults to `openSandboxRun`. */
65
74
  export type BenchShot = (input: {
66
75
  readonly adapter: BenchmarkAdapter
67
76
  readonly task: BenchTask
@@ -72,12 +81,15 @@ export type BenchShot = (input: {
72
81
  readonly attempt?: number
73
82
  readonly routerBaseUrl: string
74
83
  readonly routerKey: string
84
+ /** Optional inference credential for the box; routerKey continues to authorize sandbox control. */
85
+ readonly modelApiKey?: string
75
86
  readonly bridgeUrl?: string
76
87
  readonly bridgeBearer?: string
77
88
  readonly sandboxBaseUrl?: string
78
89
  readonly timeoutMs?: number
90
+ readonly signal?: AbortSignal
79
91
  readonly resolveClient?: typeof resolveBenchClient
80
- }) => Promise<{ artifact: string; ok: boolean; detail?: string }>
92
+ }) => Promise<BenchShotResult>
81
93
 
82
94
  export interface RunBenchmarksOptions {
83
95
  /** Registry keys (`resolveAdapter`) — the benchmark subset to run. */
@@ -86,6 +98,8 @@ export interface RunBenchmarksOptions {
86
98
  readonly cells: readonly BenchCell[]
87
99
  readonly routerBaseUrl: string
88
100
  readonly routerKey: string
101
+ /** Optional inference credential for the box; never used for sandbox creation or deletion. */
102
+ readonly modelApiKey?: string
89
103
  readonly bridgeUrl?: string
90
104
  readonly bridgeBearer?: string
91
105
  readonly sandboxBaseUrl?: string
@@ -99,6 +113,8 @@ export interface RunBenchmarksOptions {
99
113
  readonly concurrency?: number
100
114
  /** Per-shot wall-clock (ms). */
101
115
  readonly timeoutMs?: number
116
+ /** Cancels active shots and prevents queued shots from starting. */
117
+ readonly signal?: AbortSignal
102
118
  /** Test seam: resolve the runtime transport. Defaults to `resolveBenchClient`. */
103
119
  readonly resolveClient?: typeof resolveBenchClient
104
120
  /** Max attempts per (benchmark × cell × task). Default 1. Attempts after the first receive
@@ -126,6 +142,11 @@ export interface BenchCellTaskResult {
126
142
  readonly ok: boolean
127
143
  readonly detail?: string
128
144
  readonly wallMs: number
145
+ /** Exact bytes given to the benchmark judge, retained even when judging fails. */
146
+ readonly artifact?: string
147
+ readonly usage?: ReturnType<typeof sumSandboxUsage>
148
+ /** Worker events only; benchmark grading remains outside this trace. */
149
+ readonly events?: readonly SandboxEvent[]
129
150
  }
130
151
 
131
152
  export interface BenchLeaderboardRow {
@@ -173,7 +194,8 @@ function finalText(events: readonly SandboxEvent[]): string {
173
194
 
174
195
  /** The default real-agent shot: one `openSandboxRun` over the cell's harness+model, deliverable
175
196
  * extracted by the adapter's parser (or final text), abortable on `timeoutMs`. */
176
- const openSandboxShot: BenchShot = async ({ adapter, task, cell, prompt, routerBaseUrl, routerKey, bridgeUrl, bridgeBearer, sandboxBaseUrl, timeoutMs, resolveClient }) => {
197
+ const openSandboxShot: BenchShot = async ({ adapter, task, cell, prompt, routerBaseUrl, routerKey, modelApiKey, bridgeUrl, bridgeBearer, sandboxBaseUrl, timeoutMs, signal, resolveClient }) => {
198
+ signal?.throwIfAborted()
177
199
  const client = (resolveClient ?? resolveBenchClient)({
178
200
  backend: cell.backend ?? 'router',
179
201
  routerBaseUrl,
@@ -206,7 +228,7 @@ const openSandboxShot: BenchShot = async ({ adapter, task, cell, prompt, routerB
206
228
  sandboxOverrides: {
207
229
  name: `bench-${adapter.name}-${task.id}-${uniq}`.replace(/[^a-zA-Z0-9_.-]/g, '_').slice(0, 60),
208
230
  environment: 'universal',
209
- backend: { type: harness as never, model: { provider: profileProvider, model: cell.model, baseUrl: routerBaseUrl } },
231
+ backend: { type: harness as never, model: { provider: profileProvider, model: cell.model, baseUrl: routerBaseUrl, ...(modelApiKey === undefined ? {} : { apiKey: modelApiKey }) } },
210
232
  },
211
233
  }
212
234
  const deliverable: Deliverable<string> = {
@@ -217,7 +239,7 @@ const openSandboxShot: BenchShot = async ({ adapter, task, cell, prompt, routerB
217
239
  const timer = timeoutMs ? setTimeout(() => controller.abort(), timeoutMs) : undefined
218
240
  const runOptions: OpenSandboxRunOptions = {
219
241
  agentRun,
220
- signal: controller.signal,
242
+ signal: signal ? AbortSignal.any([controller.signal, signal]) : controller.signal,
221
243
  runId: `bench:${adapter.name}:${task.id}:${uniq}`,
222
244
  scenarioId: task.id,
223
245
  }
@@ -236,9 +258,12 @@ const openSandboxShot: BenchShot = async ({ adapter, task, cell, prompt, routerB
236
258
  )
237
259
  }
238
260
  }
239
- const run = await openSandboxRun(client, runOptions, deliverable)
261
+ let run: Awaited<ReturnType<typeof openSandboxRun<string>>> | undefined
262
+ let result: BenchShotResult = { artifact: '', ok: false }
240
263
  try {
264
+ run = await openSandboxRun(client, runOptions, deliverable)
241
265
  const turn = await run.start(prompt ?? task.prompt)
266
+ result = { artifact: '', ok: false, usage: sumSandboxUsage(turn.events), events: turn.events }
242
267
  // Event-stream deliverable (adapter.output ?? finalText) — the FALLBACK.
243
268
  let artifact = (turn.out ?? '').trim()
244
269
  let boxExtractError: string | undefined
@@ -289,7 +314,9 @@ const openSandboxShot: BenchShot = async ({ adapter, task, cell, prompt, routerB
289
314
  }
290
315
  }
291
316
  const detail =
292
- turn.readError !== undefined
317
+ !turn.outcome.success
318
+ ? turn.outcome.error ?? `agent ended with status ${turn.outcome.status}`
319
+ : turn.readError !== undefined
293
320
  ? `read: ${turn.readError.slice(0, 160)}`
294
321
  : boxExtractError !== undefined
295
322
  ? `boxExtract: ${boxExtractError}`
@@ -306,15 +333,30 @@ const openSandboxShot: BenchShot = async ({ adapter, task, cell, prompt, routerB
306
333
  // debug-only; never fail the shot on a dump error
307
334
  }
308
335
  }
309
- return {
336
+ result = {
310
337
  artifact,
311
- ok: artifact.length > 0,
338
+ ok: turn.outcome.success && artifact.length > 0 && turn.readError === undefined && boxExtractError === undefined,
339
+ usage: result.usage,
340
+ events: turn.events,
312
341
  ...(detail ? { detail } : {}),
313
342
  }
343
+ } catch (err) {
344
+ result = {
345
+ ...result,
346
+ ok: false,
347
+ detail: err instanceof Error ? err.message : String(err),
348
+ ...(err instanceof SandboxRunAbortError ? { usage: sumSandboxUsage(err.events), events: err.events } : {}),
349
+ }
314
350
  } finally {
315
351
  if (timer) clearTimeout(timer)
316
- await run.close()
352
+ try {
353
+ await run?.close()
354
+ } catch (err) {
355
+ const cleanup = `cleanup: ${err instanceof Error ? err.message : String(err)}`
356
+ result = { ...result, detail: combineDetails(result.detail, cleanup) }
357
+ }
317
358
  }
359
+ return result
318
360
  }
319
361
 
320
362
  function parseMaybeJson(value: string): unknown {
@@ -380,23 +422,44 @@ async function loopedShot(
380
422
  input: Parameters<BenchShot>[0],
381
423
  shot: BenchShot,
382
424
  attempts: number,
383
- ): Promise<{ artifact: string; ok: boolean; detail?: string }> {
425
+ ): Promise<BenchShotResult> {
384
426
  const scores = new Map<number, BenchScore>()
385
- const result = await runRefineLoop<string>({
386
- rounds: attempts,
387
- prompt: (round, history) => (round === 1 ? input.task.prompt : retryPrompt(input.task, history, scores)),
388
- runShot: async (prompt, round) => {
389
- const out = await shot({ ...input, prompt, attempt: round })
390
- return { artifact: out.artifact, note: out.detail }
391
- },
392
- judge: async (artifact, round) => {
393
- const score = await input.adapter.judge(input.task, artifact)
394
- scores.set(round, score)
395
- return { valid: score.resolved, score: score.score }
396
- },
397
- })
427
+ const shots = new Map<number, BenchShotResult>()
428
+ let pendingShot = false
429
+ let result: Awaited<ReturnType<typeof runRefineLoop<string>>>
430
+ try {
431
+ result = await runRefineLoop<string>({
432
+ rounds: attempts,
433
+ prompt: (round, history) => (round === 1 ? input.task.prompt : retryPrompt(input.task, history, scores)),
434
+ runShot: async (prompt, round) => {
435
+ input.signal?.throwIfAborted()
436
+ pendingShot = true
437
+ const out = await shot({ ...input, prompt, attempt: round })
438
+ shots.set(round, out)
439
+ pendingShot = false
440
+ return { artifact: out.artifact, note: out.detail }
441
+ },
442
+ judge: async (artifact, round) => {
443
+ const score = await input.adapter.judge(input.task, artifact)
444
+ scores.set(round, score)
445
+ const succeeded = shots.get(round)?.ok === true
446
+ return { valid: succeeded && score.resolved, score: succeeded ? score.score : 0 }
447
+ },
448
+ })
449
+ } catch (err) {
450
+ const completed = [...shots.values()]
451
+ return {
452
+ artifact: completed.at(-1)?.artifact ?? '',
453
+ ok: false,
454
+ usage: combinedUsage(pendingShot ? [...completed, { artifact: '', ok: false }] : completed),
455
+ events: completed.flatMap((shot) => shot.events ?? []),
456
+ detail: err instanceof Error ? err.message : String(err),
457
+ }
458
+ }
398
459
 
399
460
  const best = result.rounds.reduce((winner, candidate) => {
461
+ if (shots.get(candidate.round)?.ok !== true) return winner
462
+ if (shots.get(winner.round)?.ok !== true) return candidate
400
463
  const a = scores.get(winner.round)
401
464
  const b = scores.get(candidate.round)
402
465
  if (!a) return candidate
@@ -408,7 +471,9 @@ async function loopedShot(
408
471
  const bestScore = scores.get(best.round)
409
472
  return {
410
473
  artifact: best.artifact,
411
- ok: best.artifact.trim().length > 0,
474
+ ok: shots.get(best.round)?.ok === true && best.artifact.trim().length > 0,
475
+ usage: combinedUsage([...shots.values()]),
476
+ events: [...shots.values()].flatMap((shot) => shot.events ?? []),
412
477
  detail: JSON.stringify({
413
478
  mode: 'refine-loop',
414
479
  attempts: result.rounds.length,
@@ -425,6 +490,30 @@ async function loopedShot(
425
490
  }
426
491
  }
427
492
 
493
+ function combinedUsage(shots: readonly BenchShotResult[]): ReturnType<typeof sumSandboxUsage> {
494
+ const usage = { input: 0, output: 0, costUsd: 0 }
495
+ let tokensKnown = shots.length > 0
496
+ let usdKnown = shots.length > 0
497
+ let estimate: number | undefined
498
+ let unknownReason: string | undefined
499
+ for (const shot of shots) {
500
+ usage.input += shot.usage?.input ?? 0
501
+ usage.output += shot.usage?.output ?? 0
502
+ usage.costUsd += shot.usage?.costUsd ?? 0
503
+ tokensKnown &&= shot.usage !== undefined && shot.usage.tokensKnown !== false
504
+ usdKnown &&= shot.usage !== undefined && shot.usage.usdKnown !== false
505
+ unknownReason ??= shot.usage?.tokensUnknownReason
506
+ if (shot.usage?.estimatedCostUsd !== undefined) estimate = (estimate ?? 0) + shot.usage.estimatedCostUsd
507
+ }
508
+ return {
509
+ ...usage,
510
+ ...(tokensKnown ? {} : { tokensKnown: false as const }),
511
+ ...(usdKnown ? {} : { usdKnown: false as const }),
512
+ ...(estimate === undefined ? {} : { estimatedCostUsd: estimate }),
513
+ ...(unknownReason === undefined ? {} : { tokensUnknownReason: unknownReason }),
514
+ }
515
+ }
516
+
428
517
  function combineDetails(runDetail: string | undefined, scoreDetail: string | undefined): string | undefined {
429
518
  if (runDetail && scoreDetail) {
430
519
  return JSON.stringify({ run: parseMaybeJson(runDetail), score: parseMaybeJson(scoreDetail) })
@@ -481,6 +570,7 @@ async function prepareBenchmarks(
481
570
  }
482
571
 
483
572
  export async function runBenchmarks(opts: RunBenchmarksOptions): Promise<RunBenchmarksReport> {
573
+ opts.signal?.throwIfAborted()
484
574
  if (opts.benchmarks.length === 0) throw new Error('runBenchmarks: no benchmarks selected')
485
575
  if (opts.cells.length === 0) throw new Error('runBenchmarks: no cells to run')
486
576
  const reps = Math.max(1, opts.reps ?? 1)
@@ -497,20 +587,24 @@ export async function runBenchmarks(opts: RunBenchmarksOptions): Promise<RunBenc
497
587
  await runPool(jobs, Math.max(1, opts.concurrency ?? 4), async (job, index) => {
498
588
  const startedAt = Date.now()
499
589
  let result: BenchCellTaskResult
590
+ let out: BenchShotResult | undefined
500
591
  try {
592
+ opts.signal?.throwIfAborted()
501
593
  const shotInput = {
502
594
  adapter: job.adapter,
503
595
  task: job.task,
504
596
  cell: job.cell,
505
597
  routerBaseUrl: opts.routerBaseUrl,
506
598
  routerKey: opts.routerKey,
599
+ ...(opts.modelApiKey === undefined ? {} : { modelApiKey: opts.modelApiKey }),
507
600
  ...(opts.bridgeUrl ? { bridgeUrl: opts.bridgeUrl } : {}),
508
601
  ...(opts.bridgeBearer ? { bridgeBearer: opts.bridgeBearer } : {}),
509
602
  ...(opts.sandboxBaseUrl ? { sandboxBaseUrl: opts.sandboxBaseUrl } : {}),
510
603
  ...(opts.timeoutMs ? { timeoutMs: opts.timeoutMs } : {}),
604
+ ...(opts.signal ? { signal: opts.signal } : {}),
511
605
  ...(opts.resolveClient ? { resolveClient: opts.resolveClient } : {}),
512
606
  }
513
- const out = loopAttempts > 1 ? await loopedShot(shotInput, shot, loopAttempts) : await shot(shotInput)
607
+ out = loopAttempts > 1 ? await loopedShot(shotInput, shot, loopAttempts) : await shot(shotInput)
514
608
  const score: BenchScore = await job.adapter.judge(job.task, out.artifact)
515
609
  result = {
516
610
  benchmark: job.benchmark,
@@ -522,6 +616,9 @@ export async function runBenchmarks(opts: RunBenchmarksOptions): Promise<RunBenc
522
616
  ok: out.ok,
523
617
  ...(out.detail ?? score.detail ? { detail: combineDetails(out.detail, score.detail) } : {}),
524
618
  wallMs: Date.now() - startedAt,
619
+ artifact: out.artifact,
620
+ ...(out.usage === undefined ? {} : { usage: out.usage }),
621
+ ...(out.events === undefined ? {} : { events: out.events }),
525
622
  }
526
623
  } catch (err) {
527
624
  // A thrown shot/judge is infra error for THIS cell-task: ok=false excludes it from the
@@ -536,6 +633,9 @@ export async function runBenchmarks(opts: RunBenchmarksOptions): Promise<RunBenc
536
633
  ok: false,
537
634
  detail: err instanceof Error ? err.message.slice(0, 200) : String(err),
538
635
  wallMs: Date.now() - startedAt,
636
+ ...(out === undefined ? {} : { artifact: out.artifact }),
637
+ ...(out?.usage === undefined ? {} : { usage: out.usage }),
638
+ ...(out?.events === undefined ? {} : { events: out.events }),
539
639
  }
540
640
  }
541
641
  void index
@@ -14,7 +14,8 @@ import {
14
14
  type AgentRunSpec,
15
15
  type OutputAdapter,
16
16
  } from '@tangle-network/agent-runtime/kernel'
17
- import { parseExactAgentProfile } from '@tangle-network/agent-runtime'
17
+ import { parseExactAgentProfile } from '@tangle-network/agent-runtime/candidate-execution'
18
+
18
19
  // `BackendType` is the sandbox SDK's harness union and its canonical home. Runtime consumes it
19
20
  // from there too; benchmark profiles use the same values as their exact harness identity.
20
21
  import type { BackendType } from '@tangle-network/sandbox'
@@ -211,6 +211,7 @@ describe('activation-predicate prefilter', () => {
211
211
  loopsRepo = await mkdtemp(join(tmpdir(), 'act-repo-'))
212
212
  outDir = await mkdtemp(join(tmpdir(), 'act-out-'))
213
213
  await runOk('git', ['init', '-q', '-b', 'main', loopsRepo])
214
+ await runOk('git', ['-C', loopsRepo, 'config', 'core.hooksPath', '/dev/null'])
214
215
  await runOk('git', ['-C', loopsRepo, 'config', 'user.email', 't@t.dev'])
215
216
  await runOk('git', ['-C', loopsRepo, 'config', 'user.name', 'T'])
216
217
  await writeFile(join(loopsRepo, 'src.ts'), 'base\n')
@@ -152,6 +152,7 @@ describe('briefing text', () => {
152
152
  const repo = await mkdtemp(join(tmpdir(), 'briefing-repo-'))
153
153
  try {
154
154
  await runOk('git', ['init', '-q', '-b', 'main', repo])
155
+ await runOk('git', ['-C', repo, 'config', 'core.hooksPath', '/dev/null'])
155
156
  await runOk('git', ['-C', repo, 'config', 'user.email', 't@t.dev'])
156
157
  await runOk('git', ['-C', repo, 'config', 'user.name', 'T'])
157
158
  await writeFile(join(repo, 'base.txt'), 'x\n')
@@ -344,6 +344,7 @@ describe('arms: patch extraction (real git, no docker)', () => {
344
344
  const ws = await scratch('swe-arena-git-')
345
345
  const git = (...argv: string[]) => runOk('git', ['-C', ws, ...argv])
346
346
  await runOk('git', ['init', '-q', ws])
347
+ await runOk('git', ['-C', ws, 'config', 'core.hooksPath', '/dev/null'])
347
348
  await git('config', 'user.email', 't@t')
348
349
  await git('config', 'user.name', 't')
349
350
  await writeFile(join(ws, 'lib.py'), 'x = 1\n')
@@ -904,7 +905,7 @@ describe('serialized-judge', () => {
904
905
  const dir = await scratch('swe-arena-judge-flock-stale-bytes-')
905
906
  const lockFile = join(dir, 'lock')
906
907
  await writeFile(lockFile, 'half-written-or-dead-owner\n')
907
- await expect(withJudgeLock(lockFile, async () => 'acquired', { timeoutMs: 1_000 })).resolves.toBe('acquired')
908
+ await expect(withJudgeLock(lockFile, async () => 'acquired', { timeoutMs: 5_000 })).resolves.toBe('acquired')
908
909
  expect(await readFile(lockFile, 'utf8')).toBe('half-written-or-dead-owner\n')
909
910
  })
910
911
 
@@ -926,7 +927,7 @@ describe('serialized-judge', () => {
926
927
 
927
928
  await expect(result).rejects.toThrow('cancelled active judge')
928
929
  expect((await readFile(attempts, 'utf8')).trim().split('\n')).toEqual(['attempt'])
929
- await expect(withJudgeLock(lockFile, async () => 'released', { timeoutMs: 1_000 })).resolves.toBe('released')
930
+ await expect(withJudgeLock(lockFile, async () => 'released', { timeoutMs: 5_000 })).resolves.toBe('released')
930
931
  }, 15_000)
931
932
 
932
933
  it('bounds stale-container cleanup before starting the judge child', async () => {
@@ -246,6 +246,7 @@ async function makeSyntheticMirror(root: string): Promise<SyntheticMirror> {
246
246
  const mirror = join(root, 'mirror')
247
247
  await mkdir(mirror, { recursive: true })
248
248
  await runOk('git', ['-C', mirror, 'init', '-q', '-b', 'main'])
249
+ await runOk('git', ['-C', mirror, 'config', 'core.hooksPath', '/dev/null'])
249
250
  await writeFile(join(mirror, 'README.md'), '# synthetic\n')
250
251
  await writeFile(
251
252
  join(mirror, 'package.json'),
@@ -359,7 +360,8 @@ describe('parseVitestSummary', () => {
359
360
  })
360
361
  })
361
362
 
362
- describe('factory command credential isolation', () => {
363
+ // The isolated Docker controller uses the Linux daemon socket, never personal Docker contexts.
364
+ describe.skipIf(process.platform !== 'linux')('factory command credential isolation', () => {
363
365
  it('blocks arbitrary env, auth sockets, npm config, and home credentials from a package lifecycle script', async () => {
364
366
  const ambientHome = join(root, 'ambient-home')
365
367
  const ambientXdg = join(root, 'ambient-xdg')
@@ -506,7 +508,7 @@ describe('loadFactoryInstance', () => {
506
508
  // Judge child pipeline on the synthetic fixture.
507
509
  // ---------------------------------------------------------------------------
508
510
 
509
- describe('judgeFactoryPatch', () => {
511
+ describe.skipIf(process.platform !== 'linux')('judgeFactoryPatch', () => {
510
512
  it('gold (impl-only PR diff) resolves with full score', async () => {
511
513
  const { result } = await judgeFactoryPatch(goodInst, await goldImplPatch(goodInst))
512
514
  expect(result).toMatchObject({ resolved: true, score: 1, passed: 2, total: 2 })
@@ -597,7 +599,7 @@ describe('materializeFactoryWorkspace', () => {
597
599
  // Calibration admission gate — both rejection directions.
598
600
  // ---------------------------------------------------------------------------
599
601
 
600
- describe('calibrateFactoryInstance', () => {
602
+ describe.skipIf(process.platform !== 'linux')('calibrateFactoryInstance', () => {
601
603
  it('admits a well-formed instance (gold passes, base fails)', async () => {
602
604
  const r = await calibrateFactoryInstance(goodInst)
603
605
  expect(r).toMatchObject({
@@ -535,6 +535,7 @@ describe('fanOutLoopsGenerator with the gepa seat', () => {
535
535
  loopsRepo = await mkdtemp(join(tmpdir(), 'gepa-repo-'))
536
536
  outDir = await mkdtemp(join(tmpdir(), 'gepa-out-'))
537
537
  await runOk('git', ['init', '-q', '-b', 'main', loopsRepo])
538
+ await runOk('git', ['-C', loopsRepo, 'config', 'core.hooksPath', '/dev/null'])
538
539
  await git(['config', 'user.email', 't@t.dev'], loopsRepo)
539
540
  await git(['config', 'user.name', 'T'], loopsRepo)
540
541
  await mkdir(join(loopsRepo, 'extensions', 'pi', 'prompts'), { recursive: true })
@@ -995,6 +996,7 @@ describe('integration: real adapter roundtrip', () => {
995
996
  const outDir = await mkdtemp(join(tmpdir(), 'gepa-int-out-'))
996
997
  try {
997
998
  await runOk('git', ['init', '-q', '-b', 'main', loopsRepo])
999
+ await runOk('git', ['-C', loopsRepo, 'config', 'core.hooksPath', '/dev/null'])
998
1000
  await runOk('git', ['-C', loopsRepo, 'config', 'user.email', 't@t.dev'])
999
1001
  await runOk('git', ['-C', loopsRepo, 'config', 'user.name', 'T'])
1000
1002
  await mkdir(join(loopsRepo, 'extensions', 'pi', 'prompts'), { recursive: true })
@@ -182,6 +182,7 @@ describe('loadFullCampaignCells + incumbentSurfaceHash (real fs/git)', () => {
182
182
 
183
183
  it('computes the same incumbent hash for the unchanged tip (baseCommit == candidateCommit, empty patch)', async () => {
184
184
  await runOk('git', ['init', '-q', '-b', 'main', repo])
185
+ await runOk('git', ['-C', repo, 'config', 'core.hooksPath', '/dev/null'])
185
186
  await runOk('git', ['-C', repo, 'config', 'user.email', 't@t.dev'])
186
187
  await runOk('git', ['-C', repo, 'config', 'user.name', 'T'])
187
188
  await writeFile(join(repo, 'a.txt'), 'x\n')
@@ -65,18 +65,16 @@ describe('run process-group timeout', () => {
65
65
  const dir = await mkdtemp(join(tmpdir(), 'swe-proc-tree-'))
66
66
  dirs.push(dir)
67
67
  const pidFile = join(dir, 'grandchild.pid')
68
- const grandchild = `process.on('SIGTERM',()=>{}); setInterval(()=>{},1000)`
68
+ const grandchild = `process.on('SIGTERM',()=>{}); require('node:fs').writeFileSync(process.argv[1],String(process.pid)); setInterval(()=>{},1000)`
69
69
  const parent = [
70
70
  `const {spawn}=require('node:child_process')`,
71
- `const fs=require('node:fs')`,
72
- `const child=spawn(process.execPath,['-e',${JSON.stringify(grandchild)}],{stdio:'ignore'})`,
73
- `fs.writeFileSync(process.argv[1],String(child.pid))`,
71
+ `spawn(process.execPath,['-e',${JSON.stringify(grandchild)},process.argv[1]],{stdio:'ignore'})`,
74
72
  `process.on('SIGTERM',()=>process.exit(0))`,
75
73
  `setInterval(()=>{},1000)`,
76
74
  ].join(';')
77
75
 
78
76
  const result = await run(process.execPath, ['-e', parent, pidFile], {
79
- timeoutMs: 100,
77
+ timeoutMs: 2_000,
80
78
  killGraceMs: 50,
81
79
  })
82
80
  const grandchildPid = Number(await readFile(pidFile, 'utf8'))
@@ -358,6 +358,7 @@ describe('fanOutLoopsGenerator', () => {
358
358
  loopsRepo = await mkdtemp(join(tmpdir(), 'fanout-repo-'))
359
359
  outDir = await mkdtemp(join(tmpdir(), 'fanout-out-'))
360
360
  await runOk('git', ['init', '-q', '-b', 'main', loopsRepo])
361
+ await runOk('git', ['-C', loopsRepo, 'config', 'core.hooksPath', '/dev/null'])
361
362
  await git(['config', 'user.email', 't@t.dev'], loopsRepo)
362
363
  await git(['config', 'user.name', 'T'], loopsRepo)
363
364
  await writeFile(join(loopsRepo, 'src.ts'), 'base\n')
@@ -399,6 +399,8 @@ export async function materializeFactoryWorkspace(
399
399
  await writeFile(join(dest, 'SPEC.md'), inst.spec)
400
400
 
401
401
  await runOk('git', ['-C', dest, 'init', '-q', '-b', 'work'])
402
+ // This synthetic benchmark repository must not execute the host's personal Git hooks.
403
+ await runOk('git', ['-C', dest, 'config', 'core.hooksPath', '/dev/null'])
402
404
  await runOk('git', ['-C', dest, 'config', 'user.email', 'factory-bench@local'])
403
405
  await runOk('git', ['-C', dest, 'config', 'user.name', 'factory-bench'])
404
406
  await runOk('git', ['-C', dest, 'add', '-A'])
@@ -21,6 +21,7 @@ describe('scratch worktrees', () => {
21
21
  const output = await mkdtemp(join(tmpdir(), 'scratch-worktree-out-'))
22
22
  roots.push(repository, output)
23
23
  await runOk('git', ['init', '-q', '-b', 'main', repository])
24
+ await runOk('git', ['-C', repository, 'config', 'core.hooksPath', '/dev/null'])
24
25
  await runOk('git', ['-C', repository, 'config', 'user.email', 'test@example.com'])
25
26
  await runOk('git', ['-C', repository, 'config', 'user.name', 'Test'])
26
27
  await writeFile(join(repository, 'seed.txt'), 'seed\n')
@@ -51,5 +52,5 @@ describe('scratch worktrees', () => {
51
52
  await runOk('git', ['-C', repository, 'worktree', 'list', '--porcelain'])
52
53
  ).stdout
53
54
  expect(listed.match(/^worktree /gmu)).toHaveLength(1)
54
- })
55
+ }, 60_000)
55
56
  })
@@ -93,9 +93,13 @@ describe('SWE worker prompts', () => {
93
93
 
94
94
  describe('SWE temporary directory', () => {
95
95
  it('stays absolute when model temperature is configured through TEMPERATURE', () => {
96
+ const priorTmpdir = process.env.TMPDIR
97
+ const priorTmp = process.env.TMP
96
98
  const priorTemp = process.env.TEMP
97
99
  const priorTemperature = process.env.TEMPERATURE
98
100
  try {
101
+ delete process.env.TMPDIR
102
+ delete process.env.TMP
99
103
  delete process.env.TEMP
100
104
  process.env.TEMPERATURE = '0.8'
101
105
  assert.equal(isAbsolute(absoluteSweTempDir()), true)
@@ -103,6 +107,10 @@ describe('SWE temporary directory', () => {
103
107
  process.env.TEMP = '0.8'
104
108
  assert.throws(() => absoluteSweTempDir(), /must be absolute.*TEMPERATURE/)
105
109
  } finally {
110
+ if (priorTmpdir === undefined) delete process.env.TMPDIR
111
+ else process.env.TMPDIR = priorTmpdir
112
+ if (priorTmp === undefined) delete process.env.TMP
113
+ else process.env.TMP = priorTmp
106
114
  if (priorTemp === undefined) delete process.env.TEMP
107
115
  else process.env.TEMP = priorTemp
108
116
  if (priorTemperature === undefined) delete process.env.TEMPERATURE
@@ -197,7 +205,7 @@ describe('isInsideJail (realpath containment)', () => {
197
205
  symlinkSync('/etc', link)
198
206
  // `resolveInJail` does `realpathSync(join(ws.dir, relPath))` then this containment check.
199
207
  const real = realpathSync(join(dir, 'escape/passwd'))
200
- assert.equal(real, '/etc/passwd')
208
+ assert.equal(real, realpathSync('/etc/passwd'))
201
209
  assert.equal(isInsideJail(jailRoot, real), false)
202
210
  })
203
211
 
@@ -89,7 +89,10 @@ printf 'argv:%s\\n' "$*"
89
89
  assert.match(meteredResult.out.command, /'tangle-router\/deepseek-v4-flash'/)
90
90
  assert.match(
91
91
  meteredResult.out.stdout,
92
- /argv:exec -i --workdir \/work -e OPENAI_BASE_URL=http:\/\/router\.test cid \/bin\/sh -c exec 'opencode' 'run' 'echo hello' '-m' 'tangle-router\/deepseek-v4-flash'/,
92
+ // `--auto` is opencode's permission bypass, emitted for an unattended container run the
93
+ // same way claude-code and codex emit theirs. Without it the harness denies writes outside
94
+ // the working directory and reports "The user rejected permission".
95
+ /argv:exec -i --workdir \/work -e OPENAI_BASE_URL=http:\/\/router\.test cid \/bin\/sh -c exec 'opencode' 'run' 'echo hello' '--auto' '-m' 'tangle-router\/deepseek-v4-flash'/,
93
96
  )
94
97
  assert.deepEqual(meteredResult.spent.tokens, { input: 7, output: 11 })
95
98
  assert.equal(meteredResult.spent.usd, 0.004)
@@ -113,6 +113,7 @@ async function main(): Promise<void> {
113
113
  blobs,
114
114
  makeWorkerAgent,
115
115
  perWorker: { maxIterations: 40, maxTokens: 200_000 },
116
+ toolNames: ['spawn_worker', 'observe_agent', 'await_event', 'stop'],
116
117
  host: '0.0.0.0',
117
118
  onEvent: (event) => logEvent('bus', event),
118
119
  })
@@ -200,11 +200,11 @@ class OpenCodeSupervisorAgent(OpenCodeRouterAgent):
200
200
  try:
201
201
  supervised_instruction = (
202
202
  "You are the supervisor. You have an MCP server named \"coordination\" with "
203
- "tools including spawn_agent(profile, task), observe_agent(workerId), "
203
+ "tools including spawn_worker(profile, task), observe_agent(workerId), "
204
204
  "await_event(), and stop(). A spawned worker runs a full coding agent INSIDE "
205
205
  "THIS SAME container and its file changes persist here, so you may delegate "
206
206
  "concrete sub-tasks (e.g. \"install perl and 7z\", \"crack the hash with john\") "
207
- "to workers via spawn_agent, then observe_agent/await_event for their results. "
207
+ "to workers via spawn_worker, then observe_agent/await_event for their results. "
208
208
  "Delegate independent or heavy sub-tasks to workers when useful; do the rest "
209
209
  "yourself. Complete the task fully.\n\n"
210
210
  f"TASK:\n{instruction}"