@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.
- package/CHANGELOG.md +254 -0
- package/dist/agents/SupervisorAgent.d.ts.map +1 -1
- package/dist/agents/SupervisorAgent.js +18 -0
- package/dist/agents/SupervisorAgent.js.map +1 -1
- package/dist/agents/__tests__/supervisor-coordinator-registration.test.js +31 -0
- package/dist/agents/__tests__/supervisor-coordinator-registration.test.js.map +1 -1
- package/dist/gateway/__tests__/completion-inbox.test.d.ts +2 -0
- package/dist/gateway/__tests__/completion-inbox.test.d.ts.map +1 -0
- package/dist/gateway/__tests__/completion-inbox.test.js +275 -0
- package/dist/gateway/__tests__/completion-inbox.test.js.map +1 -0
- package/dist/gateway/completion-inbox.d.ts +113 -0
- package/dist/gateway/completion-inbox.d.ts.map +1 -0
- package/dist/gateway/completion-inbox.js +225 -0
- package/dist/gateway/completion-inbox.js.map +1 -0
- package/dist/public-runtime.d.ts +1 -0
- package/dist/public-runtime.d.ts.map +1 -1
- package/dist/public-runtime.js +4 -0
- package/dist/public-runtime.js.map +1 -1
- package/dist/registry/tool/__tests__/dialect.test.js +8 -5
- package/dist/registry/tool/__tests__/dialect.test.js.map +1 -1
- package/dist/registry/tool/dialect.js +11 -4
- package/dist/registry/tool/dialect.js.map +1 -1
- package/dist/registry/tool/execute.d.ts.map +1 -1
- package/dist/registry/tool/execute.js +34 -0
- package/dist/registry/tool/execute.js.map +1 -1
- package/dist/runtime/query/__tests__/completion-notification.test.d.ts +2 -0
- package/dist/runtime/query/__tests__/completion-notification.test.d.ts.map +1 -0
- package/dist/runtime/query/__tests__/completion-notification.test.js +168 -0
- package/dist/runtime/query/__tests__/completion-notification.test.js.map +1 -0
- package/dist/runtime/query/__tests__/run-survives-its-own-park.proc-test.d.ts +2 -0
- package/dist/runtime/query/__tests__/run-survives-its-own-park.proc-test.d.ts.map +1 -0
- package/dist/runtime/query/__tests__/run-survives-its-own-park.proc-test.js +122 -0
- package/dist/runtime/query/__tests__/run-survives-its-own-park.proc-test.js.map +1 -0
- package/dist/runtime/query/__tests__/step-allow-list.test.d.ts +2 -0
- package/dist/runtime/query/__tests__/step-allow-list.test.d.ts.map +1 -0
- package/dist/runtime/query/__tests__/step-allow-list.test.js +171 -0
- package/dist/runtime/query/__tests__/step-allow-list.test.js.map +1 -0
- package/dist/runtime/query/executor.d.ts +14 -0
- package/dist/runtime/query/executor.d.ts.map +1 -1
- package/dist/runtime/query/executor.js +20 -1
- package/dist/runtime/query/executor.js.map +1 -1
- package/dist/runtime/query/index.d.ts +9 -0
- package/dist/runtime/query/index.d.ts.map +1 -1
- package/dist/runtime/query/index.js +1 -0
- package/dist/runtime/query/index.js.map +1 -1
- package/dist/runtime/query/iteration/index.d.ts.map +1 -1
- package/dist/runtime/query/iteration/index.js +74 -1
- package/dist/runtime/query/iteration/index.js.map +1 -1
- package/dist/runtime/query/iteration/phases/context.d.ts +9 -0
- package/dist/runtime/query/iteration/phases/context.d.ts.map +1 -1
- package/dist/runtime/query/iteration/phases/context.js +43 -13
- package/dist/runtime/query/iteration/phases/context.js.map +1 -1
- package/dist/tools/builtins/__tests__/bash.proc-test.d.ts +2 -0
- package/dist/tools/builtins/__tests__/bash.proc-test.d.ts.map +1 -0
- package/dist/tools/builtins/__tests__/bash.proc-test.js +115 -0
- package/dist/tools/builtins/__tests__/bash.proc-test.js.map +1 -0
- package/dist/tools/builtins/__tests__/bash.test.d.ts +2 -0
- package/dist/tools/builtins/__tests__/bash.test.d.ts.map +1 -0
- package/dist/tools/builtins/__tests__/bash.test.js +65 -0
- package/dist/tools/builtins/__tests__/bash.test.js.map +1 -0
- package/dist/tools/builtins/bash.d.ts.map +1 -1
- package/dist/tools/builtins/bash.js +89 -17
- package/dist/tools/builtins/bash.js.map +1 -1
- package/dist/tools/coordinator/__tests__/allow-delegation.test.d.ts +2 -0
- package/dist/tools/coordinator/__tests__/allow-delegation.test.d.ts.map +1 -0
- package/dist/tools/coordinator/__tests__/allow-delegation.test.js +94 -0
- package/dist/tools/coordinator/__tests__/allow-delegation.test.js.map +1 -0
- package/dist/tools/coordinator/__tests__/completion-delivery.test.d.ts +2 -0
- package/dist/tools/coordinator/__tests__/completion-delivery.test.d.ts.map +1 -0
- package/dist/tools/coordinator/__tests__/completion-delivery.test.js +255 -0
- package/dist/tools/coordinator/__tests__/completion-delivery.test.js.map +1 -0
- package/dist/tools/coordinator/__tests__/task-list.test.js +13 -7
- package/dist/tools/coordinator/__tests__/task-list.test.js.map +1 -1
- package/dist/tools/coordinator/index.d.ts +44 -0
- package/dist/tools/coordinator/index.d.ts.map +1 -1
- package/dist/tools/coordinator/index.js +239 -16
- package/dist/tools/coordinator/index.js.map +1 -1
- package/dist/types/agent/supervisor.d.ts +27 -0
- package/dist/types/agent/supervisor.d.ts.map +1 -1
- package/dist/types/tool/index.d.ts +13 -0
- package/dist/types/tool/index.d.ts.map +1 -1
- package/dist/types/tool/index.js.map +1 -1
- package/package.json +3 -2
- package/src/agents/SupervisorAgent.ts +19 -0
- package/src/agents/__tests__/supervisor-coordinator-registration.test.ts +37 -0
- package/src/gateway/__tests__/completion-inbox.test.ts +343 -0
- package/src/gateway/completion-inbox.ts +241 -0
- package/src/public-runtime.ts +4 -0
- package/src/registry/tool/__tests__/dialect.test.ts +8 -5
- package/src/registry/tool/dialect.ts +11 -4
- package/src/registry/tool/execute.ts +35 -0
- package/src/runtime/query/__tests__/completion-notification.test.ts +196 -0
- package/src/runtime/query/__tests__/run-survives-its-own-park.proc-test.ts +127 -0
- package/src/runtime/query/__tests__/step-allow-list.test.ts +205 -0
- package/src/runtime/query/executor.ts +21 -1
- package/src/runtime/query/index.ts +11 -0
- package/src/runtime/query/iteration/index.ts +77 -1
- package/src/runtime/query/iteration/phases/context.ts +51 -15
- package/src/tools/builtins/__tests__/bash.proc-test.ts +142 -0
- package/src/tools/builtins/__tests__/bash.test.ts +79 -0
- package/src/tools/builtins/bash.ts +100 -18
- package/src/tools/coordinator/__tests__/allow-delegation.test.ts +120 -0
- package/src/tools/coordinator/__tests__/completion-delivery.test.ts +347 -0
- package/src/tools/coordinator/__tests__/task-list.test.ts +13 -7
- package/src/tools/coordinator/index.ts +278 -21
- package/src/types/agent/supervisor.ts +28 -0
- 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
|
|
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
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
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
|