@namzu/sdk 6.1.0 → 7.0.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 (185) hide show
  1. package/CHANGELOG.md +361 -0
  2. package/dist/agents/ReactiveAgent.d.ts.map +1 -1
  3. package/dist/agents/ReactiveAgent.js +5 -0
  4. package/dist/agents/ReactiveAgent.js.map +1 -1
  5. package/dist/agents/SupervisorAgent.d.ts.map +1 -1
  6. package/dist/agents/SupervisorAgent.js +9 -0
  7. package/dist/agents/SupervisorAgent.js.map +1 -1
  8. package/dist/agents/__tests__/supervisor-coordinator-registration.test.js +31 -0
  9. package/dist/agents/__tests__/supervisor-coordinator-registration.test.js.map +1 -1
  10. package/dist/agents/runAgent.d.ts +19 -1
  11. package/dist/agents/runAgent.d.ts.map +1 -1
  12. package/dist/agents/runAgent.js +2 -0
  13. package/dist/agents/runAgent.js.map +1 -1
  14. package/dist/connector/mcp/__tests__/positional-arrays.test.d.ts +2 -0
  15. package/dist/connector/mcp/__tests__/positional-arrays.test.d.ts.map +1 -0
  16. package/dist/connector/mcp/__tests__/positional-arrays.test.js +142 -0
  17. package/dist/connector/mcp/__tests__/positional-arrays.test.js.map +1 -0
  18. package/dist/connector/mcp/adapter.d.ts.map +1 -1
  19. package/dist/connector/mcp/adapter.js +123 -8
  20. package/dist/connector/mcp/adapter.js.map +1 -1
  21. package/dist/constants/agent/index.d.ts +5 -0
  22. package/dist/constants/agent/index.d.ts.map +1 -1
  23. package/dist/constants/agent/index.js +5 -0
  24. package/dist/constants/agent/index.js.map +1 -1
  25. package/dist/constants/plugin/index.d.ts +15 -0
  26. package/dist/constants/plugin/index.d.ts.map +1 -1
  27. package/dist/constants/plugin/index.js +15 -0
  28. package/dist/constants/plugin/index.js.map +1 -1
  29. package/dist/gateway/__tests__/completion-inbox.test.js +17 -0
  30. package/dist/gateway/__tests__/completion-inbox.test.js.map +1 -1
  31. package/dist/gateway/completion-inbox.d.ts.map +1 -1
  32. package/dist/gateway/completion-inbox.js +17 -3
  33. package/dist/gateway/completion-inbox.js.map +1 -1
  34. package/dist/gateway/local.d.ts +11 -0
  35. package/dist/gateway/local.d.ts.map +1 -1
  36. package/dist/gateway/local.js +27 -1
  37. package/dist/gateway/local.js.map +1 -1
  38. package/dist/manager/agent/lifecycle.d.ts.map +1 -1
  39. package/dist/manager/agent/lifecycle.js +6 -0
  40. package/dist/manager/agent/lifecycle.js.map +1 -1
  41. package/dist/provider/thinking-support.d.ts +2 -1
  42. package/dist/provider/thinking-support.d.ts.map +1 -1
  43. package/dist/provider/thinking-support.js +14 -0
  44. package/dist/provider/thinking-support.js.map +1 -1
  45. package/dist/public-runtime.d.ts +1 -1
  46. package/dist/public-runtime.d.ts.map +1 -1
  47. package/dist/public-runtime.js +9 -1
  48. package/dist/public-runtime.js.map +1 -1
  49. package/dist/runtime/query/__tests__/context.test.js +18 -0
  50. package/dist/runtime/query/__tests__/context.test.js.map +1 -1
  51. package/dist/runtime/query/__tests__/effort-reaches-the-wire.test.d.ts +2 -0
  52. package/dist/runtime/query/__tests__/effort-reaches-the-wire.test.d.ts.map +1 -0
  53. package/dist/runtime/query/__tests__/effort-reaches-the-wire.test.js +118 -0
  54. package/dist/runtime/query/__tests__/effort-reaches-the-wire.test.js.map +1 -0
  55. package/dist/runtime/query/__tests__/run-survives-its-own-park.proc-test.d.ts +2 -0
  56. package/dist/runtime/query/__tests__/run-survives-its-own-park.proc-test.d.ts.map +1 -0
  57. package/dist/runtime/query/__tests__/run-survives-its-own-park.proc-test.js +122 -0
  58. package/dist/runtime/query/__tests__/run-survives-its-own-park.proc-test.js.map +1 -0
  59. package/dist/runtime/query/__tests__/tool-timeout.test.js +34 -0
  60. package/dist/runtime/query/__tests__/tool-timeout.test.js.map +1 -1
  61. package/dist/runtime/query/context.d.ts.map +1 -1
  62. package/dist/runtime/query/context.js +16 -1
  63. package/dist/runtime/query/context.js.map +1 -1
  64. package/dist/runtime/query/executor.d.ts.map +1 -1
  65. package/dist/runtime/query/executor.js +11 -1
  66. package/dist/runtime/query/executor.js.map +1 -1
  67. package/dist/runtime/query/iteration/index.d.ts.map +1 -1
  68. package/dist/runtime/query/iteration/index.js +5 -0
  69. package/dist/runtime/query/iteration/index.js.map +1 -1
  70. package/dist/runtime/query/iteration/phases/context.d.ts.map +1 -1
  71. package/dist/runtime/query/iteration/phases/context.js +43 -13
  72. package/dist/runtime/query/iteration/phases/context.js.map +1 -1
  73. package/dist/telemetry/__tests__/model-call-span.test.js +22 -4
  74. package/dist/telemetry/__tests__/model-call-span.test.js.map +1 -1
  75. package/dist/telemetry/__tests__/span-closure.test.js +12 -5
  76. package/dist/telemetry/__tests__/span-closure.test.js.map +1 -1
  77. package/dist/tools/builtins/__tests__/bash.proc-test.d.ts +2 -0
  78. package/dist/tools/builtins/__tests__/bash.proc-test.d.ts.map +1 -0
  79. package/dist/tools/builtins/__tests__/bash.proc-test.js +115 -0
  80. package/dist/tools/builtins/__tests__/bash.proc-test.js.map +1 -0
  81. package/dist/tools/builtins/__tests__/bash.test.d.ts +2 -0
  82. package/dist/tools/builtins/__tests__/bash.test.d.ts.map +1 -0
  83. package/dist/tools/builtins/__tests__/bash.test.js +65 -0
  84. package/dist/tools/builtins/__tests__/bash.test.js.map +1 -0
  85. package/dist/tools/builtins/bash.d.ts.map +1 -1
  86. package/dist/tools/builtins/bash.js +89 -17
  87. package/dist/tools/builtins/bash.js.map +1 -1
  88. package/dist/tools/coordinator/__tests__/allow-delegation.test.d.ts +2 -0
  89. package/dist/tools/coordinator/__tests__/allow-delegation.test.d.ts.map +1 -0
  90. package/dist/tools/coordinator/__tests__/allow-delegation.test.js +94 -0
  91. package/dist/tools/coordinator/__tests__/allow-delegation.test.js.map +1 -0
  92. package/dist/tools/coordinator/__tests__/wait-with-idle-bound.test.d.ts +2 -0
  93. package/dist/tools/coordinator/__tests__/wait-with-idle-bound.test.d.ts.map +1 -0
  94. package/dist/tools/coordinator/__tests__/wait-with-idle-bound.test.js +193 -0
  95. package/dist/tools/coordinator/__tests__/wait-with-idle-bound.test.js.map +1 -0
  96. package/dist/tools/coordinator/index.d.ts +28 -0
  97. package/dist/tools/coordinator/index.d.ts.map +1 -1
  98. package/dist/tools/coordinator/index.js +101 -62
  99. package/dist/tools/coordinator/index.js.map +1 -1
  100. package/dist/tools/coordinator/wait-with-idle-bound.d.ts +66 -0
  101. package/dist/tools/coordinator/wait-with-idle-bound.d.ts.map +1 -0
  102. package/dist/tools/coordinator/wait-with-idle-bound.js +78 -0
  103. package/dist/tools/coordinator/wait-with-idle-bound.js.map +1 -0
  104. package/dist/types/agent/base.d.ts +16 -0
  105. package/dist/types/agent/base.d.ts.map +1 -1
  106. package/dist/types/agent/gateway.d.ts +23 -0
  107. package/dist/types/agent/gateway.d.ts.map +1 -1
  108. package/dist/types/agent/lifecycle-event.d.ts +9 -1
  109. package/dist/types/agent/lifecycle-event.d.ts.map +1 -1
  110. package/dist/types/agent/supervisor.d.ts +27 -0
  111. package/dist/types/agent/supervisor.d.ts.map +1 -1
  112. package/dist/types/agent/task.d.ts +5 -0
  113. package/dist/types/agent/task.d.ts.map +1 -1
  114. package/dist/types/hitl/index.d.ts +10 -0
  115. package/dist/types/hitl/index.d.ts.map +1 -1
  116. package/dist/types/hitl/index.js.map +1 -1
  117. package/dist/types/probe/registry.d.ts +6 -0
  118. package/dist/types/probe/registry.d.ts.map +1 -1
  119. package/dist/types/run/config.d.ts +25 -0
  120. package/dist/types/run/config.d.ts.map +1 -1
  121. package/dist/utils/__tests__/abort-reason.test.d.ts +2 -0
  122. package/dist/utils/__tests__/abort-reason.test.d.ts.map +1 -0
  123. package/dist/utils/__tests__/abort-reason.test.js +48 -0
  124. package/dist/utils/__tests__/abort-reason.test.js.map +1 -0
  125. package/dist/utils/abort.d.ts +26 -0
  126. package/dist/utils/abort.d.ts.map +1 -1
  127. package/dist/utils/abort.js +34 -0
  128. package/dist/utils/abort.js.map +1 -1
  129. package/dist/verification/__tests__/rule-order-and-reason.test.d.ts +2 -0
  130. package/dist/verification/__tests__/rule-order-and-reason.test.d.ts.map +1 -0
  131. package/dist/verification/__tests__/rule-order-and-reason.test.js +126 -0
  132. package/dist/verification/__tests__/rule-order-and-reason.test.js.map +1 -0
  133. package/dist/verification/gate.d.ts +17 -1
  134. package/dist/verification/gate.d.ts.map +1 -1
  135. package/dist/verification/gate.js +58 -2
  136. package/dist/verification/gate.js.map +1 -1
  137. package/dist/verification/index.d.ts +1 -1
  138. package/dist/verification/index.d.ts.map +1 -1
  139. package/dist/verification/index.js +1 -1
  140. package/dist/verification/index.js.map +1 -1
  141. package/package.json +3 -2
  142. package/src/agents/ReactiveAgent.ts +5 -0
  143. package/src/agents/SupervisorAgent.ts +9 -0
  144. package/src/agents/__tests__/supervisor-coordinator-registration.test.ts +37 -0
  145. package/src/agents/runAgent.ts +22 -1
  146. package/src/connector/mcp/__tests__/positional-arrays.test.ts +183 -0
  147. package/src/connector/mcp/adapter.ts +131 -7
  148. package/src/constants/agent/index.ts +5 -0
  149. package/src/constants/plugin/index.ts +15 -0
  150. package/src/gateway/__tests__/completion-inbox.test.ts +23 -0
  151. package/src/gateway/completion-inbox.ts +17 -3
  152. package/src/gateway/local.ts +26 -1
  153. package/src/manager/agent/lifecycle.ts +6 -0
  154. package/src/provider/thinking-support.ts +19 -2
  155. package/src/public-runtime.ts +9 -0
  156. package/src/runtime/query/__tests__/context.test.ts +24 -0
  157. package/src/runtime/query/__tests__/effort-reaches-the-wire.test.ts +135 -0
  158. package/src/runtime/query/__tests__/run-survives-its-own-park.proc-test.ts +127 -0
  159. package/src/runtime/query/__tests__/tool-timeout.test.ts +38 -0
  160. package/src/runtime/query/context.ts +16 -1
  161. package/src/runtime/query/executor.ts +11 -1
  162. package/src/runtime/query/iteration/index.ts +5 -0
  163. package/src/runtime/query/iteration/phases/context.ts +41 -15
  164. package/src/telemetry/__tests__/model-call-span.test.ts +22 -5
  165. package/src/telemetry/__tests__/span-closure.test.ts +12 -5
  166. package/src/tools/builtins/__tests__/bash.proc-test.ts +142 -0
  167. package/src/tools/builtins/__tests__/bash.test.ts +79 -0
  168. package/src/tools/builtins/bash.ts +100 -18
  169. package/src/tools/coordinator/__tests__/allow-delegation.test.ts +120 -0
  170. package/src/tools/coordinator/__tests__/wait-with-idle-bound.test.ts +247 -0
  171. package/src/tools/coordinator/index.ts +112 -65
  172. package/src/tools/coordinator/wait-with-idle-bound.ts +142 -0
  173. package/src/types/agent/base.ts +17 -0
  174. package/src/types/agent/gateway.ts +24 -0
  175. package/src/types/agent/lifecycle-event.ts +7 -0
  176. package/src/types/agent/supervisor.ts +28 -0
  177. package/src/types/agent/task.ts +5 -0
  178. package/src/types/hitl/index.ts +10 -0
  179. package/src/types/probe/registry.ts +6 -0
  180. package/src/types/run/config.ts +26 -0
  181. package/src/utils/__tests__/abort-reason.test.ts +56 -0
  182. package/src/utils/abort.ts +34 -0
  183. package/src/verification/__tests__/rule-order-and-reason.test.ts +149 -0
  184. package/src/verification/gate.ts +61 -3
  185. package/src/verification/index.ts +1 -1
@@ -369,9 +369,18 @@ export {
369
369
  FileLockManager,
370
370
  } from './bus/index.js'
371
371
 
372
+ // `describeRule` travels with `evaluateRule` deliberately. `evaluateRule`
373
+ // answers WHETHER a rule matched; on its own it leaves a caller holding a
374
+ // decision with no words for it, and the only way to say anything about the
375
+ // refusal is to switch on the rule's TYPE — which names the kind of rule and
376
+ // nothing about what it said. That is precisely the defect the gate itself
377
+ // carried until its reason stopped being `Matched rule: <type>`, and shipping
378
+ // the verdict without the sentence would have left the same hole one layer up
379
+ // for anyone driving the rules directly instead of through the gate.
372
380
  export {
373
381
  defaultSandboxedGateConfig,
374
382
  defaultSandboxedShellGateConfig,
383
+ describeRule,
375
384
  evaluateRule,
376
385
  VerificationGate,
377
386
  } from './verification/index.js'
@@ -100,4 +100,28 @@ describe('RunContextFactory.build', () => {
100
100
  const runDir = builder.runDir('prj_x' as ProjectId, 'ses_y' as SessionId, 'run_z' as RunId)
101
101
  expect(posix(runDir)).toBe('/base/.namzu/projects/prj_x/sessions/ses_y/runs/run_z')
102
102
  })
103
+
104
+ it("carries the caller's stop reason across into the run", () => {
105
+ // This is the frame that was losing it. The host aborts with a
106
+ // sentence, the run re-aborts with nothing, and every layer below —
107
+ // the tool executor, the tool itself, the result the model reads —
108
+ // can only report that something stopped. The words do not survive
109
+ // the hop unless this site forwards them.
110
+ const host = new AbortController()
111
+ const ctx = RunContextFactory.build(buildConfig({ signal: host.signal }))
112
+
113
+ host.abort(new Error('nightly window closed'))
114
+
115
+ expect(ctx.abortController.signal.aborted).toBe(true)
116
+ expect((ctx.abortController.signal.reason as Error)?.message).toBe('nightly window closed')
117
+ })
118
+
119
+ it('still aborts when the caller gave no reason', () => {
120
+ const host = new AbortController()
121
+ const ctx = RunContextFactory.build(buildConfig({ signal: host.signal }))
122
+
123
+ host.abort()
124
+
125
+ expect(ctx.abortController.signal.aborted).toBe(true)
126
+ })
103
127
  })
@@ -0,0 +1,135 @@
1
+ import { mkdtemp, rm } from 'node:fs/promises'
2
+ import { tmpdir } from 'node:os'
3
+ import { join } from 'node:path'
4
+ import { afterEach, describe, expect, it } from 'vitest'
5
+ import { MockLLMProvider } from '../../../provider/mock.js'
6
+ import { ToolRegistry } from '../../../registry/tool/execute.js'
7
+ import { createUserMessage } from '../../../types/message/index.js'
8
+ import type { AgentRunConfig } from '../../../types/run/index.js'
9
+ import { drainQuery } from '../index.js'
10
+
11
+ /**
12
+ * `effort` was declared on the provider params, exported, and read by a driver
13
+ * that wrote it straight to the wire — and nothing in the kernel ever set it.
14
+ * No caller could reach it, and the symptom (every request going out at the
15
+ * model's default) reads as "this model ignores effort" rather than "nobody
16
+ * plumbed it through".
17
+ *
18
+ * These drive a real run and read what the provider was actually handed, which
19
+ * is the only thing that distinguishes a wired field from a declared one. A
20
+ * test asserting the field exists on the config type would have passed against
21
+ * the broken version.
22
+ */
23
+
24
+ let workdirs: string[] = []
25
+
26
+ afterEach(async () => {
27
+ await Promise.all(workdirs.map((d) => rm(d, { recursive: true, force: true })))
28
+ workdirs = []
29
+ })
30
+
31
+ async function run(overrides: Partial<AgentRunConfig>, turns: unknown[]): Promise<MockLLMProvider> {
32
+ const provider = new MockLLMProvider({ turns: turns as never })
33
+ const dir = await mkdtemp(join(tmpdir(), 'namzu-effort-'))
34
+ workdirs.push(dir)
35
+
36
+ await drainQuery({
37
+ provider,
38
+ tools: new ToolRegistry(),
39
+ runConfig: {
40
+ model: 'mock-model',
41
+ timeoutMs: 30_000,
42
+ tokenBudget: 100_000,
43
+ maxIterations: 2,
44
+ ...overrides,
45
+ },
46
+ agentId: 'agent_effort',
47
+ agentName: 'Effort Agent',
48
+ workingDirectory: dir,
49
+ sessionId: 'ses_effort',
50
+ threadId: 'thd_effort',
51
+ projectId: 'prj_effort',
52
+ tenantId: 'tnt_effort',
53
+ messages: [createUserMessage('go')],
54
+ } as never)
55
+
56
+ return provider
57
+ }
58
+
59
+ describe('an effort level set on the run reaches the provider', () => {
60
+ it('arrives on the request', async () => {
61
+ const provider = await run({ effort: 'max' }, [{ text: 'done' }])
62
+
63
+ expect(provider.requests.length).toBeGreaterThan(0)
64
+ expect(provider.requests[0]?.effort).toBe('max')
65
+ })
66
+
67
+ it('is absent when nobody asked for one', async () => {
68
+ // Not `undefined`-valued but genuinely absent: a present key carrying
69
+ // undefined is the kind of thing that survives a spread into a request
70
+ // body and reaches a wire that did not expect the field.
71
+ const provider = await run({}, [{ text: 'done' }])
72
+
73
+ expect(provider.requests[0] && 'effort' in provider.requests[0]).toBe(false)
74
+ })
75
+
76
+ it('rides every turn of the run, not only the first', async () => {
77
+ // The value is run-level because the provider documents that changing
78
+ // it between requests invalidates the cached prefix. A run that
79
+ // forwarded it once and then stopped would pay that cost silently.
80
+ const provider = await run({ effort: 'low' }, [{ text: 'one' }, { text: 'two' }])
81
+
82
+ for (const request of provider.requests) {
83
+ expect(request.effort).toBe('low')
84
+ }
85
+ })
86
+
87
+ it('travels alongside thinking rather than inside it', async () => {
88
+ const provider = await run({ effort: 'high', thinking: { type: 'adaptive' } }, [
89
+ { text: 'done' },
90
+ ])
91
+
92
+ expect(provider.requests[0]?.effort).toBe('high')
93
+ expect(provider.requests[0]?.thinking?.type).toBe('adaptive')
94
+ })
95
+ })
96
+
97
+ describe('the front door forwards it too, not only the kernel', () => {
98
+ /**
99
+ * These exist because everything above passed while a real run put NOTHING
100
+ * on the wire.
101
+ *
102
+ * `drainQuery` takes the run config a caller hands it, so testing through
103
+ * it proves the loop forwards the field and nothing about whether a caller
104
+ * can set it. Every ergonomic entry point — this one, `ReactiveAgent`,
105
+ * `SupervisorAgent`, and the manager's bare-config branch — builds its
106
+ * `AgentRunConfig` by HAND-LISTING fields, so a field nobody remembered to
107
+ * add is dropped in silence, with no cast to blame and no error to see.
108
+ * `thinking` had been in that state since it shipped.
109
+ *
110
+ * It was found by watching an actual HTTP body, which is the only place the
111
+ * gap is visible. So the regression test drives the front door.
112
+ */
113
+ it('reaches the provider through runAgent', async () => {
114
+ const { runAgent } = await import('../../../agents/runAgent.js')
115
+ const provider = new MockLLMProvider({ turns: [{ text: 'done' }] as never })
116
+ const dir = await mkdtemp(join(tmpdir(), 'namzu-effort-door-'))
117
+ workdirs.push(dir)
118
+
119
+ await runAgent({
120
+ provider,
121
+ model: 'mock-model',
122
+ prompt: 'go',
123
+ workingDirectory: dir,
124
+ effort: 'xhigh',
125
+ thinking: { type: 'adaptive' },
126
+ timeoutMs: 30_000,
127
+ tokenBudget: 100_000,
128
+ maxIterations: 2,
129
+ })
130
+
131
+ expect(provider.requests.length).toBeGreaterThan(0)
132
+ expect(provider.requests[0]?.effort, 'the front door dropped effort').toBe('xhigh')
133
+ expect(provider.requests[0]?.thinking?.type, 'the front door dropped thinking').toBe('adaptive')
134
+ })
135
+ })
@@ -0,0 +1,127 @@
1
+ import { execFileSync } from 'node:child_process'
2
+ import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'
3
+ import { tmpdir } from 'node:os'
4
+ import { join } from 'node:path'
5
+ import { fileURLToPath } from 'node:url'
6
+ import { afterEach, describe, expect, it } from 'vitest'
7
+
8
+ /**
9
+ * A run has to survive its own waiting.
10
+ *
11
+ * Every HITL park went through a timer that was deliberately `unref`'d, so a
12
+ * pending park-recorder could never hold a process open after the run
13
+ * settled. The intent was right and the scope was wrong: the run AWAITS that
14
+ * timer, mid-turn, on every park. An unref'd timer does not keep Node's loop
15
+ * alive — so once the decision resolved and the run sat out the rest of the
16
+ * delay, the loop had nothing ref'd left in it and the process exited. Mid
17
+ * turn. Exit code 0. No terminal event, no error, and nothing done.
18
+ *
19
+ * The headless surfaces could not finish a turn at all: the first tool call
20
+ * completed and the process ended.
21
+ *
22
+ * **This test spawns a child process on purpose.** In-process it is
23
+ * unwritable — a test runner holds the event loop open for the whole file,
24
+ * which is the exact prop that hid this for as long as it was hidden. Every
25
+ * existing test passed throughout. If you are tempted to rewrite this as an
26
+ * ordinary `drainQuery` call because spawning is slow, that rewrite is the
27
+ * bug coming back.
28
+ *
29
+ * It loads the BUILT entry point, because that is what a consumer loads and
30
+ * because a child process cannot resolve TypeScript. The `test:proc` script
31
+ * therefore builds first — a stale `dist` reports a failure that has nothing
32
+ * to do with the code under test, and would report a pass just as
33
+ * confidently.
34
+ */
35
+
36
+ const workdirs: string[] = []
37
+ afterEach(() => {
38
+ for (const dir of workdirs) rmSync(dir, { recursive: true, force: true })
39
+ workdirs.length = 0
40
+ })
41
+
42
+ /**
43
+ * A scripted provider and a run, in a file with no test runner under it.
44
+ *
45
+ * Two turns: one tool call, then an answer. The park happens between them,
46
+ * which is where the process used to die.
47
+ */
48
+ const SCRIPT = `
49
+ import { pathToFileURL } from 'node:url'
50
+ const sdk = await import(pathToFileURL(process.argv[2]).href)
51
+ const { ToolRegistry, drainQuery, defineTool } = sdk
52
+ const { z } = await import(pathToFileURL(process.argv[3]).href)
53
+
54
+ const ZERO = { promptTokens: 0, completionTokens: 0, totalTokens: 0, cachedTokens: 0, cacheWriteTokens: 0 }
55
+
56
+ let calls = 0
57
+ const provider = {
58
+ id: 'scripted',
59
+ name: 'Scripted',
60
+ async *chatStream() {
61
+ calls += 1
62
+ if (calls === 1) {
63
+ yield { id: 'm1', delta: { toolCalls: [{ index: 0, id: 't1', type: 'function', function: { name: 'ping', arguments: '{}' } }] } }
64
+ yield { id: 'm1', delta: {}, finishReason: 'tool_calls', usage: ZERO }
65
+ return
66
+ }
67
+ yield { id: 'm2', delta: { content: 'answered' } }
68
+ yield { id: 'm2', delta: {}, finishReason: 'stop', usage: ZERO }
69
+ },
70
+ }
71
+
72
+ const tools = new ToolRegistry()
73
+ tools.register(defineTool({
74
+ name: 'ping',
75
+ description: 'pings',
76
+ inputSchema: z.object({}),
77
+ category: 'analysis',
78
+ permissions: [],
79
+ readOnly: true,
80
+ destructive: false,
81
+ concurrencySafe: true,
82
+ async execute() { return { success: true, output: 'pong' } },
83
+ }))
84
+
85
+ let last = '(none)'
86
+ drainQuery({
87
+ provider,
88
+ tools,
89
+ agentId: 'a',
90
+ agentName: 'A',
91
+ messages: [{ role: 'user', content: 'go', timestamp: Date.now() }],
92
+ workingDirectory: process.argv[4],
93
+ runConfig: { model: 'm', timeoutMs: 20000, tokenBudget: 10000, maxIterations: 4, maxResponseTokens: 128 },
94
+ sessionId: 'ses_x', threadId: 'thd_x', projectId: 'prj_x', tenantId: 'tnt_x',
95
+ }, (e) => { last = e.type }).then(
96
+ (run) => console.log('RESULT ' + JSON.stringify({ status: run.status, stop: run.stopReason, last })),
97
+ (err) => console.log('THREW ' + (err?.message ?? err)),
98
+ )
99
+ process.on('exit', () => console.log('LAST ' + last))
100
+ `
101
+
102
+ describe('a run outlives its own HITL park', () => {
103
+ it('finishes in a process with nothing else holding the event loop open', () => {
104
+ const dir = mkdtempSync(join(tmpdir(), 'namzu-park-'))
105
+ workdirs.push(dir)
106
+ const script = join(dir, 'run.mjs')
107
+ writeFileSync(script, SCRIPT)
108
+
109
+ // `fileURLToPath`, not `pathname` with the leading slash stripped. That
110
+ // stripping is right on Windows, where `pathname` is `/C:/…`, and wrong
111
+ // everywhere else, where it turns an absolute POSIX path into a
112
+ // relative one — which is how this passed locally and failed in CI with
113
+ // the repo root pasted in front of itself.
114
+ const sdkEntry = fileURLToPath(new URL('../../../../dist/index.js', import.meta.url))
115
+ const zodEntry = require.resolve('zod')
116
+
117
+ const out = execFileSync(process.execPath, [script, sdkEntry, zodEntry, dir], {
118
+ encoding: 'utf8',
119
+ timeout: 60_000,
120
+ })
121
+
122
+ // The run reached its own end rather than the process reaching it first.
123
+ expect(out, `the run did not complete:\n${out}`).toContain('RESULT ')
124
+ expect(out).toContain('"status":"completed"')
125
+ expect(out).toContain('"last":"run_completed"')
126
+ }, 90_000)
127
+ })
@@ -177,6 +177,44 @@ describe('ToolExecutor — per-tool deadline', () => {
177
177
  expect(batch.results[0]?.output).toContain('was cancelled')
178
178
  })
179
179
 
180
+ it('says WHICH stop it was, when the caller named one', async () => {
181
+ // The reason was on this signal all along — it is forwarded into the
182
+ // per-tool controller a few lines above the message — and the message
183
+ // threw it away. So a deadline, a budget and an operator pressing stop
184
+ // all reached the model as the same four words, and they want
185
+ // different next moves: one is worth waiting out, one is worth
186
+ // narrowing the input for, and one is worth stopping over.
187
+ const controller = new AbortController()
188
+ const h = harness({
189
+ toolTimeoutMs: 60_000,
190
+ abortSignal: controller.signal,
191
+ run: never,
192
+ })
193
+ const pending = h.exec.executeBatch(response('hang'))
194
+ controller.abort(new Error('deployment window closed'))
195
+ const batch = await pending
196
+
197
+ expect(batch.results[0]?.output).toContain('deployment window closed')
198
+ })
199
+
200
+ it('does not invent a reason when the caller gave none', async () => {
201
+ // `abort()` with no argument fills `reason` with a DOMException named
202
+ // AbortError. Rendering it would turn an honest silence into something
203
+ // that reads like an explanation, which is the worse failure.
204
+ const controller = new AbortController()
205
+ const h = harness({
206
+ toolTimeoutMs: 60_000,
207
+ abortSignal: controller.signal,
208
+ run: never,
209
+ })
210
+ const pending = h.exec.executeBatch(response('hang'))
211
+ controller.abort()
212
+ const batch = await pending
213
+
214
+ expect(batch.results[0]?.output).toContain('was cancelled.')
215
+ expect(batch.results[0]?.output).not.toContain('AbortError')
216
+ })
217
+
180
218
  it('exposes a sane default deadline', () => {
181
219
  // Documented so a change is a deliberate decision, not a drift.
182
220
  expect(DEFAULT_TOOL_TIMEOUT_MS).toBe(120_000)
@@ -134,7 +134,22 @@ export class RunContextFactory {
134
134
  static build(config: RunContextConfig): RunContext {
135
135
  const abortController = new AbortController()
136
136
  if (config.signal) {
137
- config.signal.addEventListener('abort', () => abortController.abort(), { once: true })
137
+ // Forward the caller's REASON, not just the fact of the abort.
138
+ //
139
+ // This used to be a bare `abort()`. Every word a host attached to
140
+ // its stop — a deadline name, a budget, an operator's message —
141
+ // died one frame above the executor, so the most a tool result
142
+ // could say was "was cancelled". A run that ends for a nameable
143
+ // reason is a run someone can debug; this is the frame where the
144
+ // name was being thrown away.
145
+ //
146
+ // `createChildAbortController` already does exactly this, but it
147
+ // takes an AbortController and what arrives here is a bare
148
+ // AbortSignal, so the reason is forwarded by hand rather than by
149
+ // reaching for a helper that does not fit.
150
+ config.signal.addEventListener('abort', () => abortController.abort(config.signal?.reason), {
151
+ once: true,
152
+ })
138
153
  }
139
154
 
140
155
  const cwd = config.workingDirectory ?? process.cwd()
@@ -33,6 +33,7 @@ import type {
33
33
  ToolCallRepair,
34
34
  ToolCallRepairReason,
35
35
  } from '../../types/tool/repair.js'
36
+ import { abortReasonText } from '../../utils/abort.js'
36
37
  import { toErrorMessage } from '../../utils/error.js'
37
38
  import type { Logger } from '../../utils/logger.js'
38
39
  import { compressShellOutput } from '../../utils/shell-compress.js'
@@ -859,10 +860,19 @@ export class ToolExecutor {
859
860
  }
860
861
 
861
862
  if (outcome === 'aborted') {
863
+ // Say WHY, when the caller said why. The reason has been
864
+ // available on this signal all along — it is forwarded into
865
+ // `controller` a few lines above — and the message threw it
866
+ // away, so a deadline, a budget and an operator pressing stop
867
+ // were all reported to the model with the same four words.
868
+ // Those want different next moves.
869
+ const why = abortReasonText(controller.signal.reason)
862
870
  return {
863
871
  success: false,
864
872
  output: '',
865
- error: `Tool "${toolName}" was cancelled.`,
873
+ error: why
874
+ ? `Tool "${toolName}" was cancelled: ${why}`
875
+ : `Tool "${toolName}" was cancelled.`,
866
876
  }
867
877
  }
868
878
 
@@ -317,6 +317,7 @@ export class IterationOrchestrator {
317
317
  maxTokens: step.maxResponseTokens ?? runConfig.maxResponseTokens,
318
318
  cacheControl: { type: 'auto' },
319
319
  ...(runConfig.thinking ? { thinking: runConfig.thinking } : {}),
320
+ ...(runConfig.effort ? { effort: runConfig.effort } : {}),
320
321
  // Thread the run abort into the model call so a Stop tears the
321
322
  // in-flight turn down (provider passes it to fetch; the consumer
322
323
  // also races it). Inert when never aborted.
@@ -1154,6 +1155,10 @@ export class IterationOrchestrator {
1154
1155
  maxTokens: this.ctx.runConfig.maxResponseTokens,
1155
1156
  cacheControl: { type: 'auto' },
1156
1157
  ...(this.ctx.runConfig.thinking ? { thinking: this.ctx.runConfig.thinking } : {}),
1158
+ // This turn is a hand-maintained duplicate of the one above, which
1159
+ // is exactly the shape a field goes missing from — so it is tested
1160
+ // separately rather than assumed to have been kept in step.
1161
+ ...(this.ctx.runConfig.effort ? { effort: this.ctx.runConfig.effort } : {}),
1157
1162
  // Cancellable too: a Stop during the closing summary must not
1158
1163
  // stream to completion.
1159
1164
  signal: this.ctx.abortController.signal,
@@ -209,16 +209,51 @@ export async function awaitDecisionDurably(
209
209
  }
210
210
  }
211
211
 
212
- const recordIfSlow = (async (): Promise<void> => {
213
- await sleep(delay)
214
- if (settled) return
215
- await record()
216
- })()
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
+ })
217
247
 
218
248
  try {
219
249
  const decision = await decisionPromise
220
250
  settled = true
221
- 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
222
257
 
223
258
  // `pause` is not an answer — it is "I am not answering now, hold
224
259
  // this". It therefore ALWAYS gets recorded, even when it arrived too
@@ -249,15 +284,6 @@ export async function awaitDecisionDurably(
249
284
  }
250
285
  }
251
286
 
252
- function sleep(ms: number): Promise<void> {
253
- return new Promise((resolve) => {
254
- const timer = setTimeout(resolve, ms)
255
- // A pending park recorder must never be the reason a process stays
256
- // alive after the run settles.
257
- ;(timer as { unref?: () => void }).unref?.()
258
- })
259
- }
260
-
261
287
  /**
262
288
  * Await a HITL `resumeHandler` decision, but RACE it against the run's abort
263
289
  * signal. A Stop that arrives while the run is parked on a tool-review or
@@ -2,6 +2,10 @@ import { mkdtemp, rm } from 'node:fs/promises'
2
2
  import { tmpdir } from 'node:os'
3
3
  import { join } from 'node:path'
4
4
  import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
5
+ import { MockLLMProvider } from '../../provider/mock.js'
6
+ import { ToolRegistry } from '../../registry/tool/execute.js'
7
+ import { drainQuery } from '../../runtime/query/index.js'
8
+ import { createUserMessage } from '../../types/message/index.js'
5
9
 
6
10
  /**
7
11
  * There was no span around the model call at all.
@@ -72,12 +76,25 @@ afterEach(async () => {
72
76
  workdirs = []
73
77
  })
74
78
 
79
+ /**
80
+ * These four are imported at module scope on purpose, and must stay there.
81
+ *
82
+ * They used to be `await import(...)` inside this function, which meant the
83
+ * query runtime's module graph — 75 imports deep — was loaded on the clock of
84
+ * whichever test called it first. Measured: that test took 1349ms on an idle
85
+ * machine while its eight siblings took 13-16ms, and under CPU load it hit
86
+ * vitest's 5000ms default and went red. Nothing about it was racy and nothing
87
+ * about it was slow; a load cost was simply billed to the wrong clock.
88
+ *
89
+ * At module scope the same work happens during collection, which carries no
90
+ * per-test deadline, so the wall time is unchanged and the deadline is not.
91
+ *
92
+ * Safe because `vi.mock` is hoisted above every import in this file, so a
93
+ * static import still receives the mocked `runtime-accessors`. That is a
94
+ * property of the transform rather than of import order — verified by running,
95
+ * not assumed, since no other file in this package had done it this way.
96
+ */
75
97
  async function runOnce(turns: { text?: string }[]): Promise<void> {
76
- const { MockLLMProvider } = await import('../../provider/mock.js')
77
- const { ToolRegistry } = await import('../../registry/tool/execute.js')
78
- const { drainQuery } = await import('../../runtime/query/index.js')
79
- const { createUserMessage } = await import('../../types/message/index.js')
80
-
81
98
  const dir = await mkdtemp(join(tmpdir(), 'namzu-chatspan-'))
82
99
  workdirs.push(dir)
83
100
 
@@ -1,5 +1,17 @@
1
1
  import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
2
2
  import { z } from 'zod'
3
+ // Imported at module scope on purpose, and must stay there.
4
+ //
5
+ // This was `await import(...)` repeated inside all five test bodies. That
6
+ // billed the tool-registry module graph to whichever test happened to run
7
+ // first, out of that test's own 5000ms deadline — and because every body
8
+ // re-entered the same pending import, a stall did not fail one test, it took
9
+ // the whole file down. At module scope the load happens during collection,
10
+ // which has no per-test deadline.
11
+ //
12
+ // `vi.mock` is hoisted above every import here, so the static form still
13
+ // receives the mocked `runtime-accessors`.
14
+ import { ToolRegistry } from '../../registry/tool/execute.js'
3
15
 
4
16
  /**
5
17
  * A span that never ends is a trace that never closes, and the export is
@@ -73,7 +85,6 @@ afterEach(() => {
73
85
 
74
86
  describe('a tool span closes however the call leaves', () => {
75
87
  it('closes on the ordinary path', async () => {
76
- const { ToolRegistry } = await import('../../registry/tool/execute.js')
77
88
  const tools = new ToolRegistry()
78
89
  tools.register({
79
90
  name: 'echo',
@@ -89,7 +100,6 @@ describe('a tool span closes however the call leaves', () => {
89
100
  })
90
101
 
91
102
  it('closes when the tool throws', async () => {
92
- const { ToolRegistry } = await import('../../registry/tool/execute.js')
93
103
  const tools = new ToolRegistry()
94
104
  tools.register({
95
105
  name: 'boom',
@@ -106,7 +116,6 @@ describe('a tool span closes however the call leaves', () => {
106
116
  })
107
117
 
108
118
  it('closes when input validation refuses the call', async () => {
109
- const { ToolRegistry } = await import('../../registry/tool/execute.js')
110
119
  const tools = new ToolRegistry()
111
120
  tools.register({
112
121
  name: 'strict',
@@ -121,7 +130,6 @@ describe('a tool span closes however the call leaves', () => {
121
130
  })
122
131
 
123
132
  it('closes when the tool is not active', async () => {
124
- const { ToolRegistry } = await import('../../registry/tool/execute.js')
125
133
  const tools = new ToolRegistry()
126
134
  tools.register(
127
135
  {
@@ -139,7 +147,6 @@ describe('a tool span closes however the call leaves', () => {
139
147
  })
140
148
 
141
149
  it('closes when the registry does not hold the name at all', async () => {
142
- const { ToolRegistry } = await import('../../registry/tool/execute.js')
143
150
  const tools = new ToolRegistry()
144
151
 
145
152
  // `getOrThrow` sat OUTSIDE the try that owned the finally, so this