@namzu/sdk 6.2.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 (144) hide show
  1. package/CHANGELOG.md +240 -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 +4 -0
  7. package/dist/agents/SupervisorAgent.js.map +1 -1
  8. package/dist/agents/runAgent.d.ts +19 -1
  9. package/dist/agents/runAgent.d.ts.map +1 -1
  10. package/dist/agents/runAgent.js +2 -0
  11. package/dist/agents/runAgent.js.map +1 -1
  12. package/dist/connector/mcp/__tests__/positional-arrays.test.d.ts +2 -0
  13. package/dist/connector/mcp/__tests__/positional-arrays.test.d.ts.map +1 -0
  14. package/dist/connector/mcp/__tests__/positional-arrays.test.js +142 -0
  15. package/dist/connector/mcp/__tests__/positional-arrays.test.js.map +1 -0
  16. package/dist/connector/mcp/adapter.d.ts.map +1 -1
  17. package/dist/connector/mcp/adapter.js +123 -8
  18. package/dist/connector/mcp/adapter.js.map +1 -1
  19. package/dist/constants/agent/index.d.ts +5 -0
  20. package/dist/constants/agent/index.d.ts.map +1 -1
  21. package/dist/constants/agent/index.js +5 -0
  22. package/dist/constants/agent/index.js.map +1 -1
  23. package/dist/constants/plugin/index.d.ts +15 -0
  24. package/dist/constants/plugin/index.d.ts.map +1 -1
  25. package/dist/constants/plugin/index.js +15 -0
  26. package/dist/constants/plugin/index.js.map +1 -1
  27. package/dist/gateway/local.d.ts +11 -0
  28. package/dist/gateway/local.d.ts.map +1 -1
  29. package/dist/gateway/local.js +27 -1
  30. package/dist/gateway/local.js.map +1 -1
  31. package/dist/manager/agent/lifecycle.d.ts.map +1 -1
  32. package/dist/manager/agent/lifecycle.js +6 -0
  33. package/dist/manager/agent/lifecycle.js.map +1 -1
  34. package/dist/provider/thinking-support.d.ts +2 -1
  35. package/dist/provider/thinking-support.d.ts.map +1 -1
  36. package/dist/provider/thinking-support.js +14 -0
  37. package/dist/provider/thinking-support.js.map +1 -1
  38. package/dist/public-runtime.d.ts +1 -1
  39. package/dist/public-runtime.d.ts.map +1 -1
  40. package/dist/public-runtime.js +9 -1
  41. package/dist/public-runtime.js.map +1 -1
  42. package/dist/runtime/query/__tests__/context.test.js +18 -0
  43. package/dist/runtime/query/__tests__/context.test.js.map +1 -1
  44. package/dist/runtime/query/__tests__/effort-reaches-the-wire.test.d.ts +2 -0
  45. package/dist/runtime/query/__tests__/effort-reaches-the-wire.test.d.ts.map +1 -0
  46. package/dist/runtime/query/__tests__/effort-reaches-the-wire.test.js +118 -0
  47. package/dist/runtime/query/__tests__/effort-reaches-the-wire.test.js.map +1 -0
  48. package/dist/runtime/query/__tests__/tool-timeout.test.js +34 -0
  49. package/dist/runtime/query/__tests__/tool-timeout.test.js.map +1 -1
  50. package/dist/runtime/query/context.d.ts.map +1 -1
  51. package/dist/runtime/query/context.js +16 -1
  52. package/dist/runtime/query/context.js.map +1 -1
  53. package/dist/runtime/query/executor.d.ts.map +1 -1
  54. package/dist/runtime/query/executor.js +11 -1
  55. package/dist/runtime/query/executor.js.map +1 -1
  56. package/dist/runtime/query/iteration/index.d.ts.map +1 -1
  57. package/dist/runtime/query/iteration/index.js +5 -0
  58. package/dist/runtime/query/iteration/index.js.map +1 -1
  59. package/dist/telemetry/__tests__/model-call-span.test.js +22 -4
  60. package/dist/telemetry/__tests__/model-call-span.test.js.map +1 -1
  61. package/dist/telemetry/__tests__/span-closure.test.js +12 -5
  62. package/dist/telemetry/__tests__/span-closure.test.js.map +1 -1
  63. package/dist/tools/coordinator/__tests__/wait-with-idle-bound.test.d.ts +2 -0
  64. package/dist/tools/coordinator/__tests__/wait-with-idle-bound.test.d.ts.map +1 -0
  65. package/dist/tools/coordinator/__tests__/wait-with-idle-bound.test.js +193 -0
  66. package/dist/tools/coordinator/__tests__/wait-with-idle-bound.test.js.map +1 -0
  67. package/dist/tools/coordinator/index.d.ts +19 -0
  68. package/dist/tools/coordinator/index.d.ts.map +1 -1
  69. package/dist/tools/coordinator/index.js +82 -60
  70. package/dist/tools/coordinator/index.js.map +1 -1
  71. package/dist/tools/coordinator/wait-with-idle-bound.d.ts +66 -0
  72. package/dist/tools/coordinator/wait-with-idle-bound.d.ts.map +1 -0
  73. package/dist/tools/coordinator/wait-with-idle-bound.js +78 -0
  74. package/dist/tools/coordinator/wait-with-idle-bound.js.map +1 -0
  75. package/dist/types/agent/base.d.ts +16 -0
  76. package/dist/types/agent/base.d.ts.map +1 -1
  77. package/dist/types/agent/gateway.d.ts +23 -0
  78. package/dist/types/agent/gateway.d.ts.map +1 -1
  79. package/dist/types/agent/lifecycle-event.d.ts +9 -1
  80. package/dist/types/agent/lifecycle-event.d.ts.map +1 -1
  81. package/dist/types/agent/task.d.ts +5 -0
  82. package/dist/types/agent/task.d.ts.map +1 -1
  83. package/dist/types/hitl/index.d.ts +10 -0
  84. package/dist/types/hitl/index.d.ts.map +1 -1
  85. package/dist/types/hitl/index.js.map +1 -1
  86. package/dist/types/probe/registry.d.ts +6 -0
  87. package/dist/types/probe/registry.d.ts.map +1 -1
  88. package/dist/types/run/config.d.ts +25 -0
  89. package/dist/types/run/config.d.ts.map +1 -1
  90. package/dist/utils/__tests__/abort-reason.test.d.ts +2 -0
  91. package/dist/utils/__tests__/abort-reason.test.d.ts.map +1 -0
  92. package/dist/utils/__tests__/abort-reason.test.js +48 -0
  93. package/dist/utils/__tests__/abort-reason.test.js.map +1 -0
  94. package/dist/utils/abort.d.ts +26 -0
  95. package/dist/utils/abort.d.ts.map +1 -1
  96. package/dist/utils/abort.js +34 -0
  97. package/dist/utils/abort.js.map +1 -1
  98. package/dist/verification/__tests__/rule-order-and-reason.test.d.ts +2 -0
  99. package/dist/verification/__tests__/rule-order-and-reason.test.d.ts.map +1 -0
  100. package/dist/verification/__tests__/rule-order-and-reason.test.js +126 -0
  101. package/dist/verification/__tests__/rule-order-and-reason.test.js.map +1 -0
  102. package/dist/verification/gate.d.ts +17 -1
  103. package/dist/verification/gate.d.ts.map +1 -1
  104. package/dist/verification/gate.js +58 -2
  105. package/dist/verification/gate.js.map +1 -1
  106. package/dist/verification/index.d.ts +1 -1
  107. package/dist/verification/index.d.ts.map +1 -1
  108. package/dist/verification/index.js +1 -1
  109. package/dist/verification/index.js.map +1 -1
  110. package/package.json +1 -1
  111. package/src/agents/ReactiveAgent.ts +5 -0
  112. package/src/agents/SupervisorAgent.ts +4 -0
  113. package/src/agents/runAgent.ts +22 -1
  114. package/src/connector/mcp/__tests__/positional-arrays.test.ts +183 -0
  115. package/src/connector/mcp/adapter.ts +131 -7
  116. package/src/constants/agent/index.ts +5 -0
  117. package/src/constants/plugin/index.ts +15 -0
  118. package/src/gateway/local.ts +26 -1
  119. package/src/manager/agent/lifecycle.ts +6 -0
  120. package/src/provider/thinking-support.ts +19 -2
  121. package/src/public-runtime.ts +9 -0
  122. package/src/runtime/query/__tests__/context.test.ts +24 -0
  123. package/src/runtime/query/__tests__/effort-reaches-the-wire.test.ts +135 -0
  124. package/src/runtime/query/__tests__/tool-timeout.test.ts +38 -0
  125. package/src/runtime/query/context.ts +16 -1
  126. package/src/runtime/query/executor.ts +11 -1
  127. package/src/runtime/query/iteration/index.ts +5 -0
  128. package/src/telemetry/__tests__/model-call-span.test.ts +22 -5
  129. package/src/telemetry/__tests__/span-closure.test.ts +12 -5
  130. package/src/tools/coordinator/__tests__/wait-with-idle-bound.test.ts +247 -0
  131. package/src/tools/coordinator/index.ts +83 -63
  132. package/src/tools/coordinator/wait-with-idle-bound.ts +142 -0
  133. package/src/types/agent/base.ts +17 -0
  134. package/src/types/agent/gateway.ts +24 -0
  135. package/src/types/agent/lifecycle-event.ts +7 -0
  136. package/src/types/agent/task.ts +5 -0
  137. package/src/types/hitl/index.ts +10 -0
  138. package/src/types/probe/registry.ts +6 -0
  139. package/src/types/run/config.ts +26 -0
  140. package/src/utils/__tests__/abort-reason.test.ts +56 -0
  141. package/src/utils/abort.ts +34 -0
  142. package/src/verification/__tests__/rule-order-and-reason.test.ts +149 -0
  143. package/src/verification/gate.ts +61 -3
  144. package/src/verification/index.ts +1 -1
@@ -39,6 +39,8 @@ export class LocalTaskGateway implements TaskGateway {
39
39
  private settledHandles: Map<TaskId, TaskHandle> = new Map()
40
40
 
41
41
  private siblingFailurePolicy: SiblingFailurePolicy = 'continue'
42
+ /** See {@link onTaskProgress}. */
43
+ private readonly progressListeners = new Set<(taskId: TaskId) => void>()
42
44
 
43
45
  constructor(
44
46
  agentManager: AgentManagerContract,
@@ -86,7 +88,15 @@ export class LocalTaskGateway implements TaskGateway {
86
88
  // allocated `maxBudgetFraction` of the SAME number — N x 50% of a
87
89
  // budget that only had 100% in it.
88
90
  this.taskContext,
89
- this.listener,
91
+ // The host's listener still sees everything it always did; this
92
+ // only tees off the fact that SOMETHING happened, which is what an
93
+ // idle bound measures. The event itself is not forwarded — a
94
+ // progress signal that carried the child's output would be a
95
+ // second, undocumented way to read a worker's work.
96
+ (event) => {
97
+ this.listener?.(event)
98
+ for (const notify of this.progressListeners) notify(task.taskId)
99
+ },
90
100
  )
91
101
 
92
102
  this.trackedTaskIds.add(task.taskId)
@@ -208,6 +218,21 @@ export class LocalTaskGateway implements TaskGateway {
208
218
  return handles
209
219
  }
210
220
 
221
+ /**
222
+ * Every event a child emits, reduced to "this one is still alive".
223
+ *
224
+ * Deliberately just the id. A caller that wanted the event itself has
225
+ * the run listener; what an idle clock needs is the fact, and passing
226
+ * the payload here would make this a second way to read a worker's
227
+ * output — one nobody documented and nothing frames as untrusted.
228
+ */
229
+ onTaskProgress(callback: (taskId: TaskId) => void): () => void {
230
+ this.progressListeners.add(callback)
231
+ return () => {
232
+ this.progressListeners.delete(callback)
233
+ }
234
+ }
235
+
211
236
  onTaskCompleted(callback: (handle: TaskHandle) => void): () => void {
212
237
  this.completionListeners.add(callback)
213
238
  return () => {
@@ -296,6 +296,12 @@ export class AgentManager {
296
296
  parentSpan: options.configOverrides?.parentSpan,
297
297
  maxIterations: options.configOverrides?.maxIterations,
298
298
  maxResponseTokens: options.configOverrides?.maxResponseTokens,
299
+ // A delegate spawned without a configBuilder lands here, and this
300
+ // list is the only thing it inherits. Omitting these meant a child
301
+ // silently ran at the default depth and effort its parent had
302
+ // deliberately moved off.
303
+ thinking: options.configOverrides?.thinking,
304
+ effort: options.configOverrides?.effort,
299
305
  env: options.configOverrides?.env,
300
306
  sessionId: spawnRecord.childSessionId,
301
307
  threadId: context.threadId,
@@ -1,4 +1,4 @@
1
- import type { ThinkingConfig } from '../types/provider/index.js'
1
+ import type { ReasoningEffort, ThinkingConfig } from '../types/provider/index.js'
2
2
 
3
3
  /**
4
4
  * Refuse a thinking request a driver does not implement.
@@ -27,8 +27,25 @@ import type { ThinkingConfig } from '../types/provider/index.js'
27
27
  */
28
28
  export function assertThinkingUnsupported(
29
29
  driverName: string,
30
- params: { thinking?: ThinkingConfig },
30
+ params: { thinking?: ThinkingConfig; effort?: ReasoningEffort },
31
31
  ): void {
32
+ // `effort` is refused on exactly the same reasoning, and it is the worse
33
+ // silence of the two. A dropped `thinking` at least leaves an empty
34
+ // reasoning list a caller could notice; a dropped `effort` leaves a
35
+ // perfectly ordinary answer, so a run someone believes they paid for at
36
+ // `max` is indistinguishable from one at the model's default — including
37
+ // on the bill.
38
+ //
39
+ // Checked before thinking because it is the cheaper mistake to make: a
40
+ // caller reaching for effort on a driver without it has usually pointed a
41
+ // working config at a new provider, and naming the field they set beats
42
+ // naming the neighbouring one.
43
+ if (params.effort !== undefined) {
44
+ throw new Error(
45
+ `${driverName} does not implement effort. Silently ignoring it would return an ordinary completion, so a run requested at "${params.effort}" would be indistinguishable from one at the model's default — including in what it cost. Drop \`effort\`, or use a driver that implements it.`,
46
+ )
47
+ }
48
+
32
49
  const type = params.thinking?.type
33
50
  if (type !== 'enabled' && type !== 'adaptive') return
34
51
  throw new Error(
@@ -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
+ })
@@ -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,
@@ -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