@namzu/sdk 6.0.0 → 6.2.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 (107) hide show
  1. package/CHANGELOG.md +254 -0
  2. package/dist/agents/SupervisorAgent.d.ts.map +1 -1
  3. package/dist/agents/SupervisorAgent.js +18 -0
  4. package/dist/agents/SupervisorAgent.js.map +1 -1
  5. package/dist/agents/__tests__/supervisor-coordinator-registration.test.js +31 -0
  6. package/dist/agents/__tests__/supervisor-coordinator-registration.test.js.map +1 -1
  7. package/dist/gateway/__tests__/completion-inbox.test.d.ts +2 -0
  8. package/dist/gateway/__tests__/completion-inbox.test.d.ts.map +1 -0
  9. package/dist/gateway/__tests__/completion-inbox.test.js +275 -0
  10. package/dist/gateway/__tests__/completion-inbox.test.js.map +1 -0
  11. package/dist/gateway/completion-inbox.d.ts +113 -0
  12. package/dist/gateway/completion-inbox.d.ts.map +1 -0
  13. package/dist/gateway/completion-inbox.js +225 -0
  14. package/dist/gateway/completion-inbox.js.map +1 -0
  15. package/dist/public-runtime.d.ts +1 -0
  16. package/dist/public-runtime.d.ts.map +1 -1
  17. package/dist/public-runtime.js +4 -0
  18. package/dist/public-runtime.js.map +1 -1
  19. package/dist/registry/tool/__tests__/dialect.test.js +8 -5
  20. package/dist/registry/tool/__tests__/dialect.test.js.map +1 -1
  21. package/dist/registry/tool/dialect.js +11 -4
  22. package/dist/registry/tool/dialect.js.map +1 -1
  23. package/dist/registry/tool/execute.d.ts.map +1 -1
  24. package/dist/registry/tool/execute.js +34 -0
  25. package/dist/registry/tool/execute.js.map +1 -1
  26. package/dist/runtime/query/__tests__/completion-notification.test.d.ts +2 -0
  27. package/dist/runtime/query/__tests__/completion-notification.test.d.ts.map +1 -0
  28. package/dist/runtime/query/__tests__/completion-notification.test.js +168 -0
  29. package/dist/runtime/query/__tests__/completion-notification.test.js.map +1 -0
  30. package/dist/runtime/query/__tests__/run-survives-its-own-park.proc-test.d.ts +2 -0
  31. package/dist/runtime/query/__tests__/run-survives-its-own-park.proc-test.d.ts.map +1 -0
  32. package/dist/runtime/query/__tests__/run-survives-its-own-park.proc-test.js +122 -0
  33. package/dist/runtime/query/__tests__/run-survives-its-own-park.proc-test.js.map +1 -0
  34. package/dist/runtime/query/__tests__/step-allow-list.test.d.ts +2 -0
  35. package/dist/runtime/query/__tests__/step-allow-list.test.d.ts.map +1 -0
  36. package/dist/runtime/query/__tests__/step-allow-list.test.js +171 -0
  37. package/dist/runtime/query/__tests__/step-allow-list.test.js.map +1 -0
  38. package/dist/runtime/query/executor.d.ts +14 -0
  39. package/dist/runtime/query/executor.d.ts.map +1 -1
  40. package/dist/runtime/query/executor.js +20 -1
  41. package/dist/runtime/query/executor.js.map +1 -1
  42. package/dist/runtime/query/index.d.ts +9 -0
  43. package/dist/runtime/query/index.d.ts.map +1 -1
  44. package/dist/runtime/query/index.js +1 -0
  45. package/dist/runtime/query/index.js.map +1 -1
  46. package/dist/runtime/query/iteration/index.d.ts.map +1 -1
  47. package/dist/runtime/query/iteration/index.js +74 -1
  48. package/dist/runtime/query/iteration/index.js.map +1 -1
  49. package/dist/runtime/query/iteration/phases/context.d.ts +9 -0
  50. package/dist/runtime/query/iteration/phases/context.d.ts.map +1 -1
  51. package/dist/runtime/query/iteration/phases/context.js +43 -13
  52. package/dist/runtime/query/iteration/phases/context.js.map +1 -1
  53. package/dist/tools/builtins/__tests__/bash.proc-test.d.ts +2 -0
  54. package/dist/tools/builtins/__tests__/bash.proc-test.d.ts.map +1 -0
  55. package/dist/tools/builtins/__tests__/bash.proc-test.js +115 -0
  56. package/dist/tools/builtins/__tests__/bash.proc-test.js.map +1 -0
  57. package/dist/tools/builtins/__tests__/bash.test.d.ts +2 -0
  58. package/dist/tools/builtins/__tests__/bash.test.d.ts.map +1 -0
  59. package/dist/tools/builtins/__tests__/bash.test.js +65 -0
  60. package/dist/tools/builtins/__tests__/bash.test.js.map +1 -0
  61. package/dist/tools/builtins/bash.d.ts.map +1 -1
  62. package/dist/tools/builtins/bash.js +89 -17
  63. package/dist/tools/builtins/bash.js.map +1 -1
  64. package/dist/tools/coordinator/__tests__/allow-delegation.test.d.ts +2 -0
  65. package/dist/tools/coordinator/__tests__/allow-delegation.test.d.ts.map +1 -0
  66. package/dist/tools/coordinator/__tests__/allow-delegation.test.js +94 -0
  67. package/dist/tools/coordinator/__tests__/allow-delegation.test.js.map +1 -0
  68. package/dist/tools/coordinator/__tests__/completion-delivery.test.d.ts +2 -0
  69. package/dist/tools/coordinator/__tests__/completion-delivery.test.d.ts.map +1 -0
  70. package/dist/tools/coordinator/__tests__/completion-delivery.test.js +255 -0
  71. package/dist/tools/coordinator/__tests__/completion-delivery.test.js.map +1 -0
  72. package/dist/tools/coordinator/__tests__/task-list.test.js +13 -7
  73. package/dist/tools/coordinator/__tests__/task-list.test.js.map +1 -1
  74. package/dist/tools/coordinator/index.d.ts +44 -0
  75. package/dist/tools/coordinator/index.d.ts.map +1 -1
  76. package/dist/tools/coordinator/index.js +239 -16
  77. package/dist/tools/coordinator/index.js.map +1 -1
  78. package/dist/types/agent/supervisor.d.ts +27 -0
  79. package/dist/types/agent/supervisor.d.ts.map +1 -1
  80. package/dist/types/tool/index.d.ts +13 -0
  81. package/dist/types/tool/index.d.ts.map +1 -1
  82. package/dist/types/tool/index.js.map +1 -1
  83. package/package.json +3 -2
  84. package/src/agents/SupervisorAgent.ts +19 -0
  85. package/src/agents/__tests__/supervisor-coordinator-registration.test.ts +37 -0
  86. package/src/gateway/__tests__/completion-inbox.test.ts +343 -0
  87. package/src/gateway/completion-inbox.ts +241 -0
  88. package/src/public-runtime.ts +4 -0
  89. package/src/registry/tool/__tests__/dialect.test.ts +8 -5
  90. package/src/registry/tool/dialect.ts +11 -4
  91. package/src/registry/tool/execute.ts +35 -0
  92. package/src/runtime/query/__tests__/completion-notification.test.ts +196 -0
  93. package/src/runtime/query/__tests__/run-survives-its-own-park.proc-test.ts +127 -0
  94. package/src/runtime/query/__tests__/step-allow-list.test.ts +205 -0
  95. package/src/runtime/query/executor.ts +21 -1
  96. package/src/runtime/query/index.ts +11 -0
  97. package/src/runtime/query/iteration/index.ts +77 -1
  98. package/src/runtime/query/iteration/phases/context.ts +51 -15
  99. package/src/tools/builtins/__tests__/bash.proc-test.ts +142 -0
  100. package/src/tools/builtins/__tests__/bash.test.ts +79 -0
  101. package/src/tools/builtins/bash.ts +100 -18
  102. package/src/tools/coordinator/__tests__/allow-delegation.test.ts +120 -0
  103. package/src/tools/coordinator/__tests__/completion-delivery.test.ts +347 -0
  104. package/src/tools/coordinator/__tests__/task-list.test.ts +13 -7
  105. package/src/tools/coordinator/index.ts +278 -21
  106. package/src/types/agent/supervisor.ts +28 -0
  107. package/src/types/tool/index.ts +13 -0
@@ -5,6 +5,7 @@ import {
5
5
  DEFAULT_STRUCTURED_OUTPUT_RETRIES,
6
6
  STRUCTURED_OUTPUT_REPROMPT,
7
7
  } from '../../../constants/tools/index.js'
8
+ import { formatCompletionNotification } from '../../../gateway/completion-inbox.js'
8
9
  import { renderSkillsSection } from '../../../persona/assembler.js'
9
10
  import { collect } from '../../../provider/collect.js'
10
11
  import {
@@ -61,6 +62,16 @@ export type { ToolReviewOutcome } from './phases/index.js'
61
62
  */
62
63
  const DEFAULT_ANSWER_REVIEW_LIMIT = 3
63
64
 
65
+ /**
66
+ * How long a finishing run waits for a background worker it launched.
67
+ *
68
+ * Long enough to be worth having — a delegated worker doing real work takes
69
+ * minutes — and bounded because a worker that never finishes must not hold
70
+ * the run open forever. `maxIterations` bounds how many times this can
71
+ * happen, and the run's own timeout bounds the whole thing regardless.
72
+ */
73
+ const BACKGROUND_TASK_GRACE_MS = 120_000
74
+
64
75
  export class IterationOrchestrator {
65
76
  private ctx: IterationContext
66
77
  /** Rejections so far. See {@link DEFAULT_ANSWER_REVIEW_LIMIT}. */
@@ -207,7 +218,13 @@ export class IterationOrchestrator {
207
218
  // supplied no hook.
208
219
  const step = await this.prepareStep(iterationNum)
209
220
 
210
- const llmTools = this.ctx.tools.toLLMTools(step.allowedTools ?? this.ctx.allowedTools)
221
+ const stepAllowedTools = step.allowedTools ?? this.ctx.allowedTools
222
+ const llmTools = this.ctx.tools.toLLMTools(stepAllowedTools)
223
+ // The same list the request was built from now also bounds what
224
+ // may run. Narrowing only the request left the restriction
225
+ // presentational — the model was shown fewer tools and could
226
+ // still call any of them by name.
227
+ this.ctx.toolExecutor.setStepAllowedTools(stepAllowedTools)
211
228
  const enforceToolInputSchema = enforcedModelInputToolNames(this.ctx.tools, llmTools)
212
229
  const stepModel = step.model ?? model
213
230
 
@@ -551,6 +568,36 @@ export class IterationOrchestrator {
551
568
  }
552
569
  }
553
570
 
571
+ // A background worker is still out there, and this turn was
572
+ // about to end the run.
573
+ //
574
+ // Settling here would throw away the very thing the launch
575
+ // existed to produce: the supervisor said "launched", the
576
+ // worker had not finished, and the run closed over it. So
577
+ // the run is held open — bounded by the deadline below and
578
+ // by `maxIterations` above, so a worker that never finishes
579
+ // cannot keep it open forever — and the completion arrives
580
+ // as a notification the next turn reads.
581
+ if (!forceFinalize && this.ctx.completionInbox?.hasPendingWork) {
582
+ this.ctx.log.info('Holding the run open for a background task', {
583
+ runId: runMgr.id,
584
+ iteration: iterationNum,
585
+ })
586
+ await this.ctx.completionInbox.waitForArrival(BACKGROUND_TASK_GRACE_MS)
587
+ const arrived = this.ctx.completionInbox.drain()
588
+ if (arrived.length > 0) {
589
+ runMgr.pushMessage(createUserMessage(formatCompletionNotification(arrived)))
590
+ await this.ctx.emitEvent({
591
+ type: 'iteration_completed',
592
+ runId: runMgr.id,
593
+ iteration: iterationNum,
594
+ hasToolCalls: false,
595
+ })
596
+ yield* this.ctx.drainPending()
597
+ continue
598
+ }
599
+ }
600
+
554
601
  if (!hasContent && !forceFinalize) {
555
602
  this.ctx.log.warn('Empty completion detected — requesting final summary', {
556
603
  iteration: iterationNum,
@@ -674,6 +721,35 @@ export class IterationOrchestrator {
674
721
  return
675
722
  }
676
723
 
724
+ // Workers that finished with nobody listening.
725
+ //
726
+ // A completion normally reaches the supervisor as the
727
+ // `tool_result` of the `create_task` that launched it. Two
728
+ // cases have no such call: a launch made in the background on
729
+ // purpose, and a blocking launch whose deadline passed — the
730
+ // model was told "timed out, it may still be running" and the
731
+ // worker then finished, holding a result nothing would read.
732
+ //
733
+ // This is the channel that was removed in `dc16d58` because it
734
+ // double-delivered: it fired for completions the blocking tool
735
+ // had already handed over, so the supervisor saw each result
736
+ // twice. The inbox restores it with the distinction that was
737
+ // missing — a tool that delivers a completion claims it, and
738
+ // only unclaimed ones arrive here.
739
+ //
740
+ // Placed beside the advisory phase deliberately: that is the
741
+ // established seam for putting a user message in after tool
742
+ // results and before the next turn.
743
+ const unheard = this.ctx.completionInbox?.drain() ?? []
744
+ if (unheard.length > 0) {
745
+ this.ctx.log.info('Delivering unawaited task completions', {
746
+ runId: runMgr.id,
747
+ iteration: iterationNum,
748
+ tasks: unheard.map((h) => h.taskId),
749
+ })
750
+ runMgr.pushMessage(createUserMessage(formatCompletionNotification(unheard)))
751
+ }
752
+
677
753
  await runAdvisoryPhase(this.ctx, iterationNum, response)
678
754
 
679
755
  if (this.ctx.pluginManager) {
@@ -3,6 +3,7 @@ import type { AgentBus } from '../../../../bus/index.js'
3
3
  import type { WorkingStateManager } from '../../../../compaction/manager.js'
4
4
  import type { ContextReducer } from '../../../../compaction/reducer.js'
5
5
  import type { CompactionConfig } from '../../../../config/runtime.js'
6
+ import type { CompletionInbox } from '../../../../gateway/completion-inbox.js'
6
7
  import type { PlanManager } from '../../../../manager/plan/lifecycle.js'
7
8
  import type { RunPersistence } from '../../../../manager/run/persistence.js'
8
9
  import type { ActivityStore } from '../../../../store/activity/memory.js'
@@ -103,6 +104,15 @@ export interface IterationContext {
103
104
 
104
105
  readonly taskGateway?: TaskGateway
105
106
 
107
+ /**
108
+ * Completions no call is waiting for, on their way to the transcript.
109
+ *
110
+ * Absent means the loop behaves exactly as it did before this existed:
111
+ * a blocking `create_task` still delivers its own result, and a
112
+ * completion nobody awaited is simply never mentioned.
113
+ */
114
+ readonly completionInbox?: CompletionInbox
115
+
106
116
  readonly taskStore?: TaskStore
107
117
 
108
118
  readonly launchedTasks: Map<TaskId, LaunchedTaskMeta>
@@ -199,16 +209,51 @@ export async function awaitDecisionDurably(
199
209
  }
200
210
  }
201
211
 
202
- const recordIfSlow = (async (): Promise<void> => {
203
- await sleep(delay)
204
- if (settled) return
205
- await record()
206
- })()
212
+ // The wait for "is this park slow enough to be worth writing down", and
213
+ // the reason it is a cancellable timer rather than a slept-through one.
214
+ //
215
+ // It used to `await sleep(delay)` where `sleep` created its timer and
216
+ // UNREF'D it, so a pending recorder could never hold a process open after
217
+ // the run settled. That is a real hazard and the intent was right, but the
218
+ // scope was wrong: this promise is awaited *during* the run, below, on
219
+ // every park. An unref'd timer does not keep Node's event loop alive — so
220
+ // once the decision resolved and the run sat here waiting out the rest of
221
+ // the delay, the loop had nothing ref'd left in it and the process exited.
222
+ // Mid-turn. Exit code 0. Nothing written, no error, no terminal event.
223
+ //
224
+ // That shipped, and it made the headless surfaces unable to finish a turn
225
+ // at all: the first tool call would complete and the process would end.
226
+ // Every test passed because a test runner holds the loop open for the
227
+ // whole file, which is exactly the kind of prop that hides this.
228
+ //
229
+ // Cancelling gets both properties. The timer is ref'd, so the run cannot
230
+ // be killed by its own wait; and it is cleared the moment the decision
231
+ // arrives, so nothing dangles past the run either.
232
+ let parkTimer: ReturnType<typeof setTimeout> | undefined
233
+ // Set SYNCHRONOUSLY when the write begins, because `recorded` only turns
234
+ // true after it finishes — waiting on that instead would skip a write that
235
+ // is still in flight and let the unpark below race it.
236
+ let recording = false
237
+ const recordIfSlow = new Promise<void>((resolve) => {
238
+ parkTimer = setTimeout(() => {
239
+ if (settled) {
240
+ resolve()
241
+ return
242
+ }
243
+ recording = true
244
+ record().then(resolve, resolve)
245
+ }, delay)
246
+ })
207
247
 
208
248
  try {
209
249
  const decision = await decisionPromise
210
250
  settled = true
211
- await recordIfSlow
251
+ // Cancel the wait rather than sitting through it. If the timer already
252
+ // fired, `recordIfSlow` is the park write and is worth awaiting so the
253
+ // unpark below cannot race it; if it has not, there is nothing to wait
254
+ // for and clearing it is what lets the turn continue immediately.
255
+ if (parkTimer !== undefined) clearTimeout(parkTimer)
256
+ if (recording) await recordIfSlow
212
257
 
213
258
  // `pause` is not an answer — it is "I am not answering now, hold
214
259
  // this". It therefore ALWAYS gets recorded, even when it arrived too
@@ -239,15 +284,6 @@ export async function awaitDecisionDurably(
239
284
  }
240
285
  }
241
286
 
242
- function sleep(ms: number): Promise<void> {
243
- return new Promise((resolve) => {
244
- const timer = setTimeout(resolve, ms)
245
- // A pending park recorder must never be the reason a process stays
246
- // alive after the run settles.
247
- ;(timer as { unref?: () => void }).unref?.()
248
- })
249
- }
250
-
251
287
  /**
252
288
  * Await a HITL `resumeHandler` decision, but RACE it against the run's abort
253
289
  * signal. A Stop that arrives while the run is parked on a tool-review or
@@ -0,0 +1,142 @@
1
+ import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'
2
+ import { tmpdir } from 'node:os'
3
+ import { join } from 'node:path'
4
+ import { afterEach, describe, expect, it } from 'vitest'
5
+
6
+ import type { ToolContext } from '../../../types/tool/index.js'
7
+ import { BashTool } from '../bash.js'
8
+
9
+ /**
10
+ * What a shell command actually tells the model, measured by running one.
11
+ *
12
+ * The only builtin that runs a shell had no test at all, and what that cost
13
+ * is visible in the code it shipped: the host path called `exec` with no
14
+ * `catch`, and `exec` REJECTS on a non-zero exit. So the two things an agent
15
+ * runs a shell for most — a test run and a build — both threw, and the
16
+ * registry turned the throw into "the tool failed" with none of the stdout,
17
+ * stderr or exit code that explains why. The sandbox path beside it reported
18
+ * all three, so the same command told the model two different amounts
19
+ * depending on where it happened to run.
20
+ *
21
+ * These spawn real processes, so they live in the `proc-test` suite rather
22
+ * than the unit one — measured, running them beside 2594 unit tests flaked
23
+ * four unrelated timing-sensitive tests. The assertions that need no shell
24
+ * stayed behind in `bash.test.ts`.
25
+ *
26
+ * Commands are written to behave identically under `cmd.exe` and `sh`,
27
+ * because `exec` picks the platform shell and a test that only passes on one
28
+ * of them is a test that fails for whoever is on the other.
29
+ */
30
+
31
+ const dirs: string[] = []
32
+ afterEach(() => {
33
+ for (const dir of dirs) {
34
+ // A killed child can still hold its working directory for a moment —
35
+ // on Windows that surfaces as EBUSY, and it failed the timeout test
36
+ // from the cleanup rather than the assertion, which is the most
37
+ // misleading way for a test to go red. Retry, then let it go: a temp
38
+ // directory that outlives the run is the operating system's problem,
39
+ // not a result worth reporting.
40
+ try {
41
+ rmSync(dir, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 })
42
+ } catch {
43
+ // Deliberately swallowed. See above.
44
+ }
45
+ }
46
+ dirs.length = 0
47
+ })
48
+
49
+ function ctx(): ToolContext {
50
+ const workingDirectory = mkdtempSync(join(tmpdir(), 'namzu-bash-'))
51
+ dirs.push(workingDirectory)
52
+ return { workingDirectory } as ToolContext
53
+ }
54
+
55
+ async function run(
56
+ input: Record<string, unknown>,
57
+ context: ToolContext = ctx(),
58
+ ): Promise<{ success: boolean; output: string; error?: string; data?: Record<string, unknown> }> {
59
+ const parsed = BashTool.inputSchema.parse(input)
60
+ return (await BashTool.execute(parsed as never, context)) as never
61
+ }
62
+
63
+ describe('a command that succeeds', () => {
64
+ it('returns its stdout', async () => {
65
+ const result = await run({ command: 'echo hello' })
66
+
67
+ expect(result.success).toBe(true)
68
+ expect(result.output).toContain('hello')
69
+ expect(result.data?.exitCode).toBe(0)
70
+ })
71
+
72
+ it('runs in the working directory it was given', async () => {
73
+ const context = ctx()
74
+ writeFileSync(join(context.workingDirectory, 'marker.txt'), 'x')
75
+
76
+ const result = await run(
77
+ { command: `node -e "console.log(require('fs').readdirSync('.'))"` },
78
+ context,
79
+ )
80
+
81
+ expect(result.output).toContain('marker.txt')
82
+ })
83
+
84
+ it('says so rather than returning an empty string', async () => {
85
+ const result = await run({ command: 'node -e ""' })
86
+
87
+ expect(result.output).toBe('(no output)')
88
+ })
89
+ })
90
+
91
+ describe('a command that fails still says what happened', () => {
92
+ it('reports the exit code instead of throwing', async () => {
93
+ // The whole defect: this used to reject out of `execute`.
94
+ const result = await run({ command: 'node -e "process.exit(3)"' })
95
+
96
+ expect(result.success).toBe(false)
97
+ expect(result.data?.exitCode).toBe(3)
98
+ expect(result.error).toContain('exited with code 3')
99
+ })
100
+
101
+ it('keeps the output a failing command produced', async () => {
102
+ // The reason a model runs a shell at all: a failing test prints WHY it
103
+ // failed, on stdout, before exiting non-zero.
104
+ const result = await run({
105
+ command: `node -e "console.log('3 tests failed'); process.exit(1)"`,
106
+ })
107
+
108
+ expect(result.success).toBe(false)
109
+ expect(result.output, 'the failure output was discarded').toContain('3 tests failed')
110
+ })
111
+
112
+ it('keeps stderr too', async () => {
113
+ const result = await run({
114
+ command: `node -e "console.error('compiler said no'); process.exit(2)"`,
115
+ })
116
+
117
+ expect(result.output).toContain('compiler said no')
118
+ expect(result.data?.exitCode).toBe(2)
119
+ })
120
+
121
+ it('reports a missing command as a failure, not as success', async () => {
122
+ const result = await run({ command: 'definitely-not-a-real-command-xyz' })
123
+
124
+ expect(result.success).toBe(false)
125
+ expect(result.error).toBeTruthy()
126
+ })
127
+ })
128
+
129
+ describe('a command that runs out of time', () => {
130
+ it('says it timed out rather than that it exited', async () => {
131
+ // "Ran out of time" and "exited 1" are different diagnoses and lead to
132
+ // different next moves, so the message has to distinguish them.
133
+ const result = await run({
134
+ command: `node -e "setTimeout(() => {}, 10000)"`,
135
+ timeout: 300,
136
+ })
137
+
138
+ expect(result.success).toBe(false)
139
+ expect(result.data?.timedOut).toBe(true)
140
+ expect(result.error).toContain('timed out')
141
+ }, 20_000)
142
+ })
@@ -0,0 +1,79 @@
1
+ import { describe, expect, it } from 'vitest'
2
+
3
+ import { BashTool } from '../bash.js'
4
+
5
+ /**
6
+ * What `bash` promises before it runs anything.
7
+ *
8
+ * These need no shell, so they stay in the unit suite. The ones that actually
9
+ * execute a command live in `bash.proc-test.ts` — spawning real processes
10
+ * beside 2594 unit tests flaked four unrelated timing-sensitive ones, so the
11
+ * process suite is separate and has its own CI step.
12
+ */
13
+
14
+ describe('the two clocks agree', () => {
15
+ it('declares a deadline of its own', () => {
16
+ // The executor reads a tool's `timeoutMs` before falling back to its
17
+ // generic default. With none declared, `bash` inherited that default —
18
+ // the same two minutes as its OWN default — so the two agreed by
19
+ // coincidence and diverged the moment a model asked for longer because
20
+ // it knew a build was slow. It got two minutes, from a clock it had not
21
+ // been told about, reported as an abandoned tool rather than as a
22
+ // command that ran out of time.
23
+ expect(BashTool.timeoutMs).toBeDefined()
24
+ })
25
+
26
+ it('puts that deadline above the longest the model may request', () => {
27
+ // So this tool's own clock is the one that fires, and the executor's is
28
+ // a backstop rather than a second clock racing it.
29
+ const accepted = BashTool.inputSchema.safeParse({ command: 'true', timeout: 10 * 60 * 1000 })
30
+
31
+ expect(accepted.success).toBe(true)
32
+ expect(BashTool.timeoutMs as number).toBeGreaterThan(10 * 60 * 1000)
33
+ })
34
+
35
+ it('refuses an over-long request rather than silently shortening it', () => {
36
+ // Refuse, do not degrade. A number the model was not told had changed
37
+ // is how it learns to distrust its own arguments.
38
+ const overCeiling = BashTool.inputSchema.safeParse({
39
+ command: 'true',
40
+ timeout: 60 * 60 * 1000,
41
+ })
42
+
43
+ expect(overCeiling.success, 'the ceiling is not enforced').toBe(false)
44
+ })
45
+
46
+ it('refuses a nonsensical deadline', () => {
47
+ expect(BashTool.inputSchema.safeParse({ command: 'true', timeout: 0 }).success).toBe(false)
48
+ expect(BashTool.inputSchema.safeParse({ command: 'true', timeout: -1 }).success).toBe(false)
49
+ })
50
+
51
+ it('applies its default when none is given', () => {
52
+ const parsed = BashTool.inputSchema.parse({ command: 'true' })
53
+
54
+ expect(parsed.timeout).toBeGreaterThan(0)
55
+ })
56
+ })
57
+
58
+ describe('the input is closed before a shell ever sees it', () => {
59
+ it('refuses an empty command', () => {
60
+ expect(BashTool.inputSchema.safeParse({ command: '' }).success).toBe(false)
61
+ })
62
+
63
+ it('accepts a numeric timeout sent as a string', () => {
64
+ // Providers do this, and the coercion is deliberate.
65
+ const parsed = BashTool.inputSchema.parse({ command: 'true', timeout: '5000' })
66
+
67
+ expect(parsed.timeout).toBe(5000)
68
+ })
69
+ })
70
+
71
+ describe('the danger flag reads the command', () => {
72
+ it('marks a destructive command destructive', () => {
73
+ expect(BashTool.isDestructive?.({ command: 'rm -rf /', timeout: 1000 } as never)).toBe(true)
74
+ })
75
+
76
+ it('leaves an ordinary command alone', () => {
77
+ expect(BashTool.isDestructive?.({ command: 'ls -la', timeout: 1000 } as never)).toBe(false)
78
+ })
79
+ })
@@ -22,6 +22,25 @@ const DEFAULT_BASH_MAX_BUFFER_BYTES = readPositiveIntEnv(
22
22
  100 * 1024 * 1024,
23
23
  )
24
24
 
25
+ /**
26
+ * The longest deadline this tool will accept from the model.
27
+ *
28
+ * There are two clocks on a bash call and until now only one of them was
29
+ * declared. This tool enforces `input.timeout` itself; the EXECUTOR enforces
30
+ * a separate per-tool deadline, and with none declared here it fell back to
31
+ * its own generic default — also two minutes. The two agreed by coincidence,
32
+ * so a model that asked for five minutes because it knew the build was slow
33
+ * got two, from a clock it had not been told about, reported as an abandoned
34
+ * tool rather than as a command that ran out of time.
35
+ *
36
+ * So the tool declares a ceiling and the executor is given a deadline above
37
+ * it (see `timeoutMs` on the definition), which makes this the only clock
38
+ * that can fire in practice. A request past the ceiling is REFUSED rather
39
+ * than quietly clamped: the model asked for something specific, and silently
40
+ * giving it a different number is how it learns to distrust the answer.
41
+ */
42
+ const MAX_BASH_TIMEOUT_MS = readPositiveIntEnv('NAMZU_BASH_MAX_TIMEOUT_MS', 10 * 60 * 1000)
43
+
25
44
  const inputSchema = z.object({
26
45
  command: z
27
46
  .string()
@@ -32,9 +51,11 @@ const inputSchema = z.object({
32
51
  timeout: z
33
52
  .preprocess(
34
53
  (v) => (typeof v === 'string' ? Number(v) : v),
35
- z.number().default(DEFAULT_BASH_TIMEOUT_MS),
54
+ z.number().positive().max(MAX_BASH_TIMEOUT_MS).default(DEFAULT_BASH_TIMEOUT_MS),
36
55
  )
37
- .describe(`Command timeout in milliseconds. Default: ${DEFAULT_BASH_TIMEOUT_MS}`),
56
+ .describe(
57
+ `Command timeout in milliseconds. Default: ${DEFAULT_BASH_TIMEOUT_MS}, maximum: ${MAX_BASH_TIMEOUT_MS}. For work that legitimately runs longer than the maximum, start it in the background and poll, rather than holding the turn open.`,
58
+ ),
38
59
  })
39
60
 
40
61
  type BashInput = z.infer<typeof inputSchema>
@@ -53,6 +74,12 @@ export const BashTool = defineTool({
53
74
  readOnly: false,
54
75
  destructive: (input: BashInput) => isDangerousCommand(input.command),
55
76
  concurrencySafe: false,
77
+ // Above the ceiling the input schema accepts, so the executor's deadline
78
+ // is a backstop rather than a second clock racing this tool's own. It used
79
+ // to be undefined, which meant the executor's generic default applied —
80
+ // the same two minutes as this tool's DEFAULT, so they agreed by accident
81
+ // and diverged the moment a model asked for longer.
82
+ timeoutMs: MAX_BASH_TIMEOUT_MS + 30_000,
56
83
 
57
84
  async execute(input, context) {
58
85
  if (isDangerousCommand(input.command)) {
@@ -133,26 +160,81 @@ export const BashTool = defineTool({
133
160
  // a Stop tore down the model stream and left the command running,
134
161
  // and the executor's deadline could only ever DETACH from the tool
135
162
  // rather than end the work it started.
136
- const { stdout, stderr } = await execAsync(input.command, {
137
- cwd: context.workingDirectory,
138
- timeout: input.timeout,
139
- env: { ...process.env, ...context.env },
140
- maxBuffer: DEFAULT_BASH_MAX_BUFFER_BYTES,
141
- signal: context.abortSignal,
142
- })
143
-
144
- const output = [stdout ? `STDOUT:\n${stdout}` : '', stderr ? `STDERR:\n${stderr}` : '']
145
- .filter(Boolean)
146
- .join('\n\n')
147
-
148
- return {
149
- success: true,
150
- output: output || '(no output)',
151
- data: { exitCode: 0 },
163
+ // `exec` REJECTS on a non-zero exit, on its own timeout, and on a
164
+ // kill — and the rejection carries `stdout`, `stderr`, `code` and
165
+ // `killed`. Letting it propagate threw all of that away: the registry
166
+ // turned the throw into a structured failure, so the model was told a
167
+ // command failed and not one word about how.
168
+ //
169
+ // That is the common case, not an edge one. A failing test run and a
170
+ // failing build are the two things an agent runs bash for most, and
171
+ // both exit non-zero WITH the output that explains why. The sandbox
172
+ // branch above already reports all of it; this branch did not, so the
173
+ // same command told the model two different amounts depending on where
174
+ // it happened to run.
175
+ try {
176
+ const { stdout, stderr } = await execAsync(input.command, {
177
+ cwd: context.workingDirectory,
178
+ timeout: input.timeout,
179
+ env: { ...process.env, ...context.env },
180
+ maxBuffer: DEFAULT_BASH_MAX_BUFFER_BYTES,
181
+ signal: context.abortSignal,
182
+ })
183
+
184
+ return {
185
+ success: true,
186
+ output: formatShellOutput(stdout, stderr) || '(no output)',
187
+ data: { exitCode: 0 },
188
+ }
189
+ } catch (err) {
190
+ const failure = err as NodeJS.ErrnoException & {
191
+ stdout?: string
192
+ stderr?: string
193
+ code?: number | string
194
+ killed?: boolean
195
+ signal?: string
196
+ }
197
+
198
+ // A caller-owned Stop is the caller's, not a command failure.
199
+ if (context.abortSignal?.aborted) throw err
200
+
201
+ // `exec` reports its own timeout as a kill, and the distinction
202
+ // matters to the model: "ran out of time" is a different next move
203
+ // from "exited 1".
204
+ const timedOut = failure.killed === true && failure.signal === 'SIGTERM'
205
+ const exitCode = typeof failure.code === 'number' ? failure.code : undefined
206
+ const output = formatShellOutput(failure.stdout, failure.stderr)
207
+
208
+ return {
209
+ success: false,
210
+ output: output || '(no output)',
211
+ data: {
212
+ ...(exitCode !== undefined ? { exitCode } : {}),
213
+ timedOut,
214
+ ...(failure.signal ? { signal: failure.signal } : {}),
215
+ },
216
+ error: timedOut
217
+ ? `Command timed out after ${input.timeout}ms. Any output it produced before the deadline is above.`
218
+ : exitCode !== undefined
219
+ ? `Command exited with code ${exitCode}`
220
+ : `Command failed: ${failure.message}`,
221
+ }
152
222
  }
153
223
  },
154
224
  })
155
225
 
226
+ /**
227
+ * The two streams, labelled, with empty ones left out.
228
+ *
229
+ * Shared by the success and failure paths so a command tells the model the
230
+ * same shape either way — the failure path used to tell it nothing at all.
231
+ */
232
+ function formatShellOutput(stdout: string | undefined, stderr: string | undefined): string {
233
+ return [stdout ? `STDOUT:\n${stdout}` : '', stderr ? `STDERR:\n${stderr}` : '']
234
+ .filter(Boolean)
235
+ .join('\n\n')
236
+ }
237
+
156
238
  function readPositiveIntEnv(key: string, fallback: number): number {
157
239
  const value = process.env[key]?.trim()
158
240
  if (!value) return fallback