@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
@@ -0,0 +1,247 @@
1
+ import { describe, expect, it, vi } from 'vitest'
2
+
3
+ import type { TaskGateway, TaskHandle } from '../../../types/agent/gateway.js'
4
+ import type { TaskId } from '../../../types/ids/index.js'
5
+ import { describeWaitTimeout, waitForTaskWithBounds } from '../wait-with-idle-bound.js'
6
+
7
+ /**
8
+ * A wall clock cannot tell a slow worker from a stuck one.
9
+ *
10
+ * The bound before this was an hour of elapsed time, and an hour has to do
11
+ * two incompatible jobs: be long enough for a child doing real work, and
12
+ * short enough to notice one that wedged. It cannot be both, so a worker
13
+ * stuck in minute two held the supervisor for another fifty-eight, and one
14
+ * making steady progress at minute fifty-nine was killed for being slow.
15
+ *
16
+ * These tests drive both clocks with a fake one, because the real bounds are
17
+ * measured in minutes and a test that actually waits them out is a test
18
+ * nobody runs.
19
+ */
20
+
21
+ function gatewayFor(opts: {
22
+ /** Resolve the wait when this is called. */
23
+ settle?: (resolve: (h: TaskHandle) => void) => void
24
+ withProgress?: boolean
25
+ }): { gateway: TaskGateway; progress: () => void; finish: () => void } {
26
+ let resolveWait: ((h: TaskHandle) => void) | undefined
27
+ const listeners = new Set<(id: TaskId) => void>()
28
+
29
+ const handle: TaskHandle = {
30
+ taskId: 'tsk_1' as TaskId,
31
+ agentId: 'worker',
32
+ state: 'completed',
33
+ createdAt: 0,
34
+ completedAt: 1,
35
+ }
36
+
37
+ const gateway = {
38
+ waitForTask: () =>
39
+ new Promise<TaskHandle>((resolve) => {
40
+ resolveWait = resolve
41
+ opts.settle?.(resolve)
42
+ }),
43
+ ...(opts.withProgress === false
44
+ ? {}
45
+ : {
46
+ onTaskProgress: (cb: (id: TaskId) => void) => {
47
+ listeners.add(cb)
48
+ return () => listeners.delete(cb)
49
+ },
50
+ }),
51
+ } as unknown as TaskGateway
52
+
53
+ return {
54
+ gateway,
55
+ progress: () => {
56
+ for (const cb of listeners) cb('tsk_1' as TaskId)
57
+ },
58
+ finish: () => resolveWait?.(handle),
59
+ }
60
+ }
61
+
62
+ /** A clock the test moves by hand. */
63
+ function fakeClock(): { now: () => number; advance: (ms: number) => void } {
64
+ let t = 1_000_000
65
+ return {
66
+ now: () => t,
67
+ advance: (ms) => {
68
+ t += ms
69
+ },
70
+ }
71
+ }
72
+
73
+ describe('a worker that is still working is not killed for being slow', () => {
74
+ it('keeps waiting past the idle bound while progress keeps arriving', async () => {
75
+ vi.useFakeTimers()
76
+ try {
77
+ const clock = fakeClock()
78
+ const { gateway, progress, finish } = gatewayFor({})
79
+
80
+ const waiting = waitForTaskWithBounds(
81
+ gateway,
82
+ 'tsk_1' as TaskId,
83
+ { runMs: 60_000, idleMs: 5_000 },
84
+ clock.now,
85
+ )
86
+
87
+ // Four times the idle bound in elapsed time, but never quiet for
88
+ // more than half of it.
89
+ for (let i = 0; i < 8; i += 1) {
90
+ clock.advance(2_500)
91
+ progress()
92
+ await vi.advanceTimersByTimeAsync(1_000)
93
+ }
94
+
95
+ finish()
96
+ await vi.advanceTimersByTimeAsync(1_000)
97
+
98
+ expect((await waiting).kind, 'a working worker was cut off').toBe('completed')
99
+ } finally {
100
+ vi.useRealTimers()
101
+ }
102
+ })
103
+ })
104
+
105
+ describe('a worker that has gone quiet is reported as quiet', () => {
106
+ it('fires the idle bound and says which clock it was', async () => {
107
+ vi.useFakeTimers()
108
+ try {
109
+ const clock = fakeClock()
110
+ const { gateway } = gatewayFor({})
111
+
112
+ const waiting = waitForTaskWithBounds(
113
+ gateway,
114
+ 'tsk_1' as TaskId,
115
+ { runMs: 600_000, idleMs: 5_000 },
116
+ clock.now,
117
+ )
118
+
119
+ clock.advance(6_000)
120
+ await vi.advanceTimersByTimeAsync(1_100)
121
+
122
+ const outcome = await waiting
123
+ expect(outcome.kind).toBe('timeout')
124
+ if (outcome.kind !== 'timeout') return
125
+ // The distinction is the point: "went quiet" and "ran too long"
126
+ // are different diagnoses and the caller acts on the message.
127
+ expect(outcome.cause).toBe('idle')
128
+ expect(outcome.idleBoundArmed).toBe(true)
129
+ } finally {
130
+ vi.useRealTimers()
131
+ }
132
+ })
133
+
134
+ it('says it went quiet, and that the worker was not cancelled', () => {
135
+ const text = describeWaitTimeout({
136
+ kind: 'timeout',
137
+ cause: 'idle',
138
+ elapsedMs: 30_000,
139
+ idleBoundArmed: true,
140
+ })
141
+
142
+ expect(text).toContain('went quiet')
143
+ // A wait that ran out is a statement about the WAITER. Losing an
144
+ // eight-minute worker's output because a short clock expired is the
145
+ // bug this whole area has been unpicking.
146
+ expect(text).toContain('not been cancelled')
147
+ })
148
+ })
149
+
150
+ describe('the run bound still catches a worker that never stops', () => {
151
+ it('fires on elapsed time even while progress keeps arriving', async () => {
152
+ vi.useFakeTimers()
153
+ try {
154
+ const clock = fakeClock()
155
+ const { gateway, progress } = gatewayFor({})
156
+
157
+ const waiting = waitForTaskWithBounds(
158
+ gateway,
159
+ 'tsk_1' as TaskId,
160
+ { runMs: 10_000, idleMs: 5_000 },
161
+ clock.now,
162
+ )
163
+
164
+ for (let i = 0; i < 6; i += 1) {
165
+ clock.advance(2_000)
166
+ progress()
167
+ await vi.advanceTimersByTimeAsync(1_000)
168
+ }
169
+
170
+ const outcome = await waiting
171
+ expect(outcome.kind).toBe('timeout')
172
+ if (outcome.kind !== 'timeout') return
173
+ expect(outcome.cause).toBe('run')
174
+ } finally {
175
+ vi.useRealTimers()
176
+ }
177
+ })
178
+ })
179
+
180
+ describe('a gateway that cannot see its children', () => {
181
+ it('is bounded by the wall clock alone, as it was before', async () => {
182
+ vi.useFakeTimers()
183
+ try {
184
+ const clock = fakeClock()
185
+ const { gateway } = gatewayFor({ withProgress: false })
186
+
187
+ const waiting = waitForTaskWithBounds(
188
+ gateway,
189
+ 'tsk_1' as TaskId,
190
+ { runMs: 10_000, idleMs: 1_000 },
191
+ clock.now,
192
+ )
193
+
194
+ // Far past the idle bound, and it must NOT fire — there is no
195
+ // signal, so silence carries no information.
196
+ clock.advance(5_000)
197
+ await vi.advanceTimersByTimeAsync(1_100)
198
+ clock.advance(6_000)
199
+ await vi.advanceTimersByTimeAsync(1_100)
200
+
201
+ const outcome = await waiting
202
+ expect(outcome.kind).toBe('timeout')
203
+ if (outcome.kind !== 'timeout') return
204
+ expect(outcome.cause).toBe('run')
205
+ // And the degradation is visible rather than silent.
206
+ expect(outcome.idleBoundArmed).toBe(false)
207
+ } finally {
208
+ vi.useRealTimers()
209
+ }
210
+ })
211
+
212
+ it('says so, instead of implying the worker was stuck', () => {
213
+ const text = describeWaitTimeout({
214
+ kind: 'timeout',
215
+ cause: 'run',
216
+ elapsedMs: 3_600_000,
217
+ idleBoundArmed: false,
218
+ })
219
+
220
+ expect(text).toContain('cannot report progress')
221
+ })
222
+ })
223
+
224
+ describe('a completion always wins', () => {
225
+ it('returns the handle rather than a timeout when the task finishes first', async () => {
226
+ vi.useFakeTimers()
227
+ try {
228
+ const clock = fakeClock()
229
+ const { gateway, finish } = gatewayFor({})
230
+
231
+ const waiting = waitForTaskWithBounds(
232
+ gateway,
233
+ 'tsk_1' as TaskId,
234
+ { runMs: 10_000, idleMs: 5_000 },
235
+ clock.now,
236
+ )
237
+
238
+ finish()
239
+ await vi.advanceTimersByTimeAsync(10)
240
+
241
+ const outcome = await waiting
242
+ expect(outcome.kind).toBe('completed')
243
+ } finally {
244
+ vi.useRealTimers()
245
+ }
246
+ })
247
+ })
@@ -11,6 +11,7 @@ import type { ToolDefinition } from '../../types/tool/index.js'
11
11
  import { defineTool } from '../defineTool.js'
12
12
  import { wrapUntrusted } from '../untrusted-envelope.js'
13
13
  import { resolvePlanDependencies } from './plan-dependencies.js'
14
+ import { describeWaitTimeout, waitForTaskWithBounds } from './wait-with-idle-bound.js'
14
15
 
15
16
  export type TaskLaunchedCallback = (
16
17
  agentTaskId: TaskId,
@@ -259,6 +260,33 @@ const LISTED_RESULT_LIMIT = 2_000
259
260
  */
260
261
  export const DELEGATION_TIMEOUT_MS = 60 * 60 * 1000
261
262
 
263
+ /**
264
+ * How long a delegated worker may say nothing before the wait gives up.
265
+ *
266
+ * The hour above answers "how long is too long". It cannot also answer
267
+ * "how quiet is too quiet", because it has to be generous enough for a
268
+ * child doing real work — which makes it useless as a stall detector. A
269
+ * worker wedged in its second minute held the supervisor for another
270
+ * fifty-eight under that number alone.
271
+ *
272
+ * Five minutes of silence, because a worker between tool calls can be
273
+ * quiet for a while legitimately — a long model turn emits nothing until
274
+ * it starts streaming — and the cost of guessing low is killing a wait on
275
+ * a worker that was fine. Guessing high only delays a diagnosis. Set
276
+ * `NAMZU_DELEGATION_IDLE_MS` to change it.
277
+ *
278
+ * Only armed when the gateway can report progress at all; see
279
+ * `TaskGateway.onTaskProgress`.
280
+ */
281
+ export const DELEGATION_IDLE_MS = readPositiveIntEnv('NAMZU_DELEGATION_IDLE_MS', 5 * 60 * 1000)
282
+
283
+ function readPositiveIntEnv(key: string, fallback: number): number {
284
+ const value = process.env[key]?.trim()
285
+ if (!value) return fallback
286
+ const parsed = Number(value)
287
+ return Number.isSafeInteger(parsed) && parsed > 0 ? parsed : fallback
288
+ }
289
+
262
290
  export function buildCoordinatorTools(opts: CoordinatorToolsOptions): ToolDefinition[] {
263
291
  const {
264
292
  gateway,
@@ -379,7 +407,23 @@ export function buildCoordinatorTools(opts: CoordinatorToolsOptions): ToolDefini
379
407
  // assistant turn, the runtime runs them together and delivers all
380
408
  // N `tool_result`s at once. No second `tool_result` for the
381
409
  // same `tool_use_id` — providers reject a duplicated id outright.
382
- const completed = await gateway.waitForTask(handle.taskId)
410
+ // Bounded by two clocks rather than one. The wait gives up on a
411
+ // worker that has gone quiet long before the hour is out, and says
412
+ // which of the two ran out — the caller acts on that difference.
413
+ // Giving up does NOT cancel the child: it keeps going, and its
414
+ // result still reaches the supervisor as a notification.
415
+ const outcome = await waitForTaskWithBounds(gateway, handle.taskId, {
416
+ runMs: DELEGATION_TIMEOUT_MS,
417
+ idleMs: DELEGATION_IDLE_MS,
418
+ })
419
+ if (outcome.kind === 'timeout') {
420
+ return {
421
+ success: false,
422
+ output: describeWaitTimeout(outcome),
423
+ data: { task_id: handle.taskId, agent_id, timed_out: outcome.cause },
424
+ }
425
+ }
426
+ const completed = outcome.handle
383
427
 
384
428
  // Whether this call is still the live path decides who delivers the
385
429
  // result. If the executor already gave up on us — its deadline
@@ -450,55 +494,6 @@ export function buildCoordinatorTools(opts: CoordinatorToolsOptions): ToolDefini
450
494
  },
451
495
  })
452
496
 
453
- const continueTask = defineTool({
454
- name: 'continue_task',
455
- description:
456
- "Send a follow-up message to a previously completed task and await the agent's next reply. BLOCKING: returns the agent's new output as this call's tool_result, the same shape as create_task. Only use this with a task_id from a previous create_task. To run multiple follow-ups in parallel, call this tool multiple times in a single assistant turn.",
457
- inputSchema: z.object({
458
- task_id: z.string().describe('Agent task ID from a previous create_task'),
459
- message: z.string().describe('Follow-up instruction for the agent'),
460
- }),
461
- category: 'custom',
462
- permissions: [],
463
- readOnly: false,
464
- destructive: false,
465
- concurrencySafe: true,
466
- // It waits on a child exactly as create_task does, so it inherits
467
- // the same bound rather than the file-read default.
468
- timeoutMs: DELEGATION_TIMEOUT_MS,
469
- async execute({ task_id, message }, _context) {
470
- await gateway.continueTask(task_id as TaskId, message)
471
- // Mirror create_task's blocking pattern: await the new
472
- // completion and return the agent's output inline. The
473
- // previous non-blocking shape ('You will receive a
474
- // task-notification…') relied on a global
475
- // onTaskCompleted listener that the iteration loop
476
- // no longer registers (envelope path is dead).
477
- const completed = await gateway.waitForTask(task_id as TaskId)
478
- // Same reasoning as create_task: the model already has a timeout
479
- // for this call, so leaving the completion unclaimed is what sends
480
- // it to the transcript as a notification.
481
- if (_context.abortSignal?.aborted) {
482
- return {
483
- success: false,
484
- output: `This wait was abandoned before task ${task_id} finished; its result will arrive separately as a task notification.`,
485
- data: { task_id, abandoned: true },
486
- }
487
- }
488
- completionInbox?.claim(task_id as TaskId)
489
- const success = completed.state === 'completed'
490
- const resultText =
491
- completed.result?.result ??
492
- completed.result?.lastError ??
493
- `Task finished with state: ${completed.state}`
494
- return {
495
- success,
496
- output: resultText,
497
- data: { task_id, state: completed.state },
498
- }
499
- },
500
- })
501
-
502
497
  /**
503
498
  * Join a task already running, without sending it anything.
504
499
  *
@@ -533,7 +528,18 @@ export function buildCoordinatorTools(opts: CoordinatorToolsOptions): ToolDefini
533
528
  }
534
529
  }
535
530
 
536
- const completed = await gateway.waitForTask(task_id as TaskId)
531
+ const outcome = await waitForTaskWithBounds(gateway, task_id as TaskId, {
532
+ runMs: DELEGATION_TIMEOUT_MS,
533
+ idleMs: DELEGATION_IDLE_MS,
534
+ })
535
+ if (outcome.kind === 'timeout') {
536
+ return {
537
+ success: false,
538
+ output: describeWaitTimeout(outcome),
539
+ data: { task_id, timed_out: outcome.cause },
540
+ }
541
+ }
542
+ const completed = outcome.handle
537
543
  if (_context.abortSignal?.aborted) {
538
544
  return {
539
545
  success: false,
@@ -675,18 +681,32 @@ export function buildCoordinatorTools(opts: CoordinatorToolsOptions): ToolDefini
675
681
  },
676
682
  })
677
683
 
678
- // `continue_task` was a follow-up channel for a still-alive worker
679
- // task. With `create_task` now blocking + tool_result returning
680
- // the worker's final output, every worker reaches a terminal
681
- // state by the time the supervisor wants to follow up and the
682
- // agent manager rejects `continue` on terminal tasks. The
683
- // industrial pattern is to issue a fresh `create_task` that
684
- // references the prior worker's output path, so we drop
685
- // `continue_task` from the registered surface entirely. The
686
- // definition stays in this file for now in case a future
687
- // non-default gateway (one that keeps the worker process alive
688
- // for follow-ups) wants to re-register it.
689
- void continueTask
684
+ // `continue_task` is gone, and the reasoning is worth keeping because
685
+ // the obvious argument for bringing it back does not survive contact.
686
+ //
687
+ // It was dropped on the grounds that a blocking `create_task` leaves every
688
+ // worker terminal before a later turn learns its id, and the manager
689
+ // refuses `continue` on a terminal task. `background: true` reinstated that
690
+ // precondition a live id is reachable now — so the question was reopened.
691
+ //
692
+ // Measured rather than assumed, and it fails on the other side. On a LIVE
693
+ // task the manager accepts the call and pushes onto `pendingMessages`,
694
+ // and NOTHING drains that queue during a run. This codebase already
695
+ // knows: `runtime/query/steering.ts` says in as many words that
696
+ // `queueMessage`/`drainMessages` were never read by the iteration loop,
697
+ // and `SteeringChannel` exists BECAUSE of that — it delivers guidance on a
698
+ // tool result instead, since a `tool_use` must be answered by a
699
+ // `tool_result` with the same id and there is no legal slot for a user
700
+ // message mid-batch.
701
+ //
702
+ // So the tool had no state it worked in: terminal tasks refuse it, live
703
+ // tasks accept it into a queue nobody reads. Registering it would have
704
+ // handed the model a call that silently does nothing — worse than the
705
+ // unregistered definition it replaced, because a defined-but-unreachable
706
+ // tool at least cannot be called.
707
+ //
708
+ // If follow-ups on a live worker are wanted, the work is a consumer for
709
+ // the queue or a steering channel that reaches a child, not this tool.
690
710
  // `cancel_task` is registered again, and the reasoning that dropped it is
691
711
  // worth keeping because it was sound at the time and is not any more.
692
712
  //
@@ -0,0 +1,142 @@
1
+ import type { TaskGateway, TaskHandle } from '../../types/agent/gateway.js'
2
+ import type { TaskId } from '../../types/ids/index.js'
3
+
4
+ /**
5
+ * Waiting on a delegated worker, bounded by two different questions.
6
+ *
7
+ * A wall clock alone is the wrong instrument. It has to be long enough to
8
+ * serve as the outer bound for a child doing real work — an hour, here — and
9
+ * that is far too long to notice a child that wedged in its second minute.
10
+ * The same number cannot be both "how long is too long" and "how quiet is too
11
+ * quiet", so this keeps them apart:
12
+ *
13
+ * - the **run bound** counts elapsed time and is never refreshed. It exists
14
+ * for a worker that stays busy forever.
15
+ * - the **idle bound** counts time since the worker last did anything, and
16
+ * resets whenever it does. It exists for a worker that stopped.
17
+ *
18
+ * Whichever fires first ends the wait, and the result says WHICH — because
19
+ * "it went quiet" and "it ran too long" are different diagnoses and lead to
20
+ * different next moves. Telling a caller its worker timed out when the worker
21
+ * was making steady progress is the failure this replaces.
22
+ *
23
+ * The idle bound is only armed when the gateway can report progress.
24
+ * `onTaskProgress` is optional on the contract, because hosts implement
25
+ * `TaskGateway` and not all of them can observe their children — so a gateway
26
+ * without it is bounded by the wall clock alone, exactly as before. That is a
27
+ * real degradation and it is deliberately visible in the result rather than
28
+ * silent: `idleBoundArmed` says whether the quieter half was ever watching.
29
+ */
30
+ export type WaitOutcome =
31
+ | { readonly kind: 'completed'; readonly handle: TaskHandle }
32
+ | {
33
+ readonly kind: 'timeout'
34
+ /** Which clock ran out. */
35
+ readonly cause: 'idle' | 'run'
36
+ readonly elapsedMs: number
37
+ /** False when the gateway cannot report progress, so only the wall clock applied. */
38
+ readonly idleBoundArmed: boolean
39
+ }
40
+
41
+ export interface WaitBounds {
42
+ /** Elapsed-time ceiling, never refreshed. */
43
+ readonly runMs: number
44
+ /**
45
+ * Time-without-progress ceiling, refreshed on every progress signal.
46
+ *
47
+ * Omit to bound by the run clock alone.
48
+ */
49
+ readonly idleMs?: number
50
+ }
51
+
52
+ /**
53
+ * Await a task under both bounds.
54
+ *
55
+ * Note what this does NOT do: it does not cancel the worker. A wait that ran
56
+ * out is a statement about the waiter, not about the work — the child keeps
57
+ * going, its completion still reaches the inbox, and the supervisor is still
58
+ * told what it produced. Killing a child because a parent stopped waiting was
59
+ * never asked for, and losing an eight-minute worker's output because a
60
+ * two-minute clock expired is the exact shape of the bug this whole area has
61
+ * been unpicking.
62
+ */
63
+ export async function waitForTaskWithBounds(
64
+ gateway: TaskGateway,
65
+ taskId: TaskId,
66
+ bounds: WaitBounds,
67
+ now: () => number = Date.now,
68
+ ): Promise<WaitOutcome> {
69
+ const startedAt = now()
70
+ let lastProgressAt = startedAt
71
+ let settled = false
72
+
73
+ const detach = gateway.onTaskProgress?.((id) => {
74
+ if (id === taskId) lastProgressAt = now()
75
+ })
76
+ const idleBoundArmed = detach !== undefined && bounds.idleMs !== undefined
77
+
78
+ try {
79
+ const completion = gateway.waitForTask(taskId).then(
80
+ (handle): WaitOutcome => ({ kind: 'completed', handle }),
81
+ // A gateway that rejects has answered the question; let it through
82
+ // rather than reporting a timeout that did not happen.
83
+ (err) => {
84
+ throw err
85
+ },
86
+ )
87
+
88
+ const expiry = new Promise<WaitOutcome>((resolve) => {
89
+ // Polled rather than scheduled, because the idle deadline MOVES: a
90
+ // timer armed for it would have to be cleared and rearmed on every
91
+ // tick of progress, and the one that slipped through would be the
92
+ // one that mattered. A coarse tick is enough — these bounds are
93
+ // minutes, and being a second late to notice silence costs nothing.
94
+ const tick = setInterval(() => {
95
+ if (settled) return
96
+ const elapsed = now() - startedAt
97
+ if (elapsed >= bounds.runMs) {
98
+ clearInterval(tick)
99
+ resolve({ kind: 'timeout', cause: 'run', elapsedMs: elapsed, idleBoundArmed })
100
+ return
101
+ }
102
+ if (idleBoundArmed && bounds.idleMs !== undefined) {
103
+ const quietFor = now() - lastProgressAt
104
+ if (quietFor >= bounds.idleMs) {
105
+ clearInterval(tick)
106
+ resolve({ kind: 'timeout', cause: 'idle', elapsedMs: elapsed, idleBoundArmed })
107
+ }
108
+ }
109
+ }, POLL_INTERVAL_MS)
110
+ // Never the reason a process stays alive. This one is safe to unref
111
+ // where the park recorder was not, because nothing AWAITS it alone:
112
+ // it races a real completion promise, so the wait is held open by
113
+ // work that is genuinely outstanding rather than by this timer.
114
+ ;(tick as { unref?: () => void }).unref?.()
115
+ })
116
+
117
+ return await Promise.race([completion, expiry])
118
+ } finally {
119
+ settled = true
120
+ detach?.()
121
+ }
122
+ }
123
+
124
+ /**
125
+ * How often the bounds are checked.
126
+ *
127
+ * Coarse on purpose: both bounds are measured in minutes, so a second of
128
+ * latency in noticing is irrelevant, and a tight interval would spend a timer
129
+ * wakeup per second per in-flight worker for nothing.
130
+ */
131
+ const POLL_INTERVAL_MS = 1_000
132
+
133
+ /** What to tell the model, in the words that fit what actually happened. */
134
+ export function describeWaitTimeout(outcome: Extract<WaitOutcome, { kind: 'timeout' }>): string {
135
+ const seconds = Math.round(outcome.elapsedMs / 1000)
136
+ if (outcome.cause === 'idle') {
137
+ return `This worker went quiet: nothing has come from it for a while, after ${seconds}s. It has not been cancelled and may still finish — its result will arrive as a task notification if it does. Check agent_task_list, or start a different approach.`
138
+ }
139
+ return outcome.idleBoundArmed
140
+ ? `This worker has been running for ${seconds}s without finishing, though it was still doing something. It has not been cancelled — its result will arrive as a task notification if it finishes.`
141
+ : `This worker has been running for ${seconds}s without finishing. This gateway cannot report progress, so there is no way to tell a busy worker from a stuck one here. It has not been cancelled — its result will arrive as a task notification if it finishes.`
142
+ }
@@ -24,6 +24,23 @@ export interface BaseAgentConfig {
24
24
  permissionMode?: PermissionMode
25
25
  env?: Record<string, string>
26
26
 
27
+ /**
28
+ * Thinking mode and response-effort level for every model call this agent
29
+ * makes. See {@link import('../run/config.js').AgentRunConfig} for what
30
+ * each one controls and why they are siblings.
31
+ *
32
+ * They are declared HERE, on the shared base, rather than on each agent
33
+ * config that happens to want them. Every agent builds its `AgentRunConfig`
34
+ * by hand-listing fields, and a field absent from a hand-listed literal is
35
+ * dropped in silence — which is exactly how `thinking` came to be settable
36
+ * only through the raw kernel entry point while every ergonomic one quietly
37
+ * ignored it. Putting them on the base is what makes "did you forget to
38
+ * forward it" a type error in the places that matter rather than a support
39
+ * question.
40
+ */
41
+ thinking?: import('../provider/index.js').ThinkingConfig
42
+ effort?: import('../provider/index.js').ReasoningEffort
43
+
27
44
  /**
28
45
  * Deduplicate a retried invocation instead of running it twice.
29
46
  *
@@ -57,4 +57,28 @@ export interface TaskGateway {
57
57
  listTasks(): TaskHandle[]
58
58
 
59
59
  onTaskCompleted(callback: (handle: TaskHandle) => void): () => void
60
+
61
+ /**
62
+ * Tell me when a task does something, not just when it finishes.
63
+ *
64
+ * This is what an idle bound is measured against. A wall clock says
65
+ * nothing about whether a worker is working: an hour is long enough to
66
+ * be useless as a stall detector, and short enough to kill a child that
67
+ * is making steady progress at minute fifty-nine. Time-without-progress
68
+ * is the quantity that separates "stuck" from "slow", and only the
69
+ * gateway can see it.
70
+ *
71
+ * OPTIONAL, and the absence is meaningful rather than an oversight: a
72
+ * gateway that cannot observe its children still works, and its waits
73
+ * are bounded by the wall clock alone — which is exactly the behaviour
74
+ * before this existed. It is optional because `TaskGateway` is
75
+ * implemented by hosts, and a required method would break every one of
76
+ * them for a capability not all of them can provide.
77
+ *
78
+ * Anything the worker did counts: a tool call, an emitted token, a state
79
+ * change. What must NOT count is the supervisor's own activity — the
80
+ * point is to notice a child that has gone quiet, and a parent polling
81
+ * about it is not the child speaking.
82
+ */
83
+ onTaskProgress?(callback: (taskId: TaskId) => void): () => void
60
84
  }
@@ -11,6 +11,13 @@ export type AgentLifecycleEvent =
11
11
  depth: number
12
12
  }
13
13
  | { type: 'running'; taskId: TaskId }
14
+ /**
15
+ * **Never emitted.** Nothing constructs this variant, so a host that
16
+ * switches on it has written a branch that cannot run — and a host that
17
+ * relies on progress arriving will wait for an event that never comes.
18
+ *
19
+ * @deprecated No producer. Removed in the next major.
20
+ */
14
21
  | { type: 'progress_updated'; taskId: TaskId; progress: AgentTaskProgress }
15
22
  | { type: 'completed'; taskId: TaskId; result: BaseAgentResult }
16
23
  | { type: 'failed'; taskId: TaskId; error: string }
@@ -106,6 +106,11 @@ export interface AgentTask {
106
106
  context: AgentTaskContext
107
107
  state: AgentTaskState
108
108
  result?: BaseAgentResult
109
+ /**
110
+ * **Never populated.** Nothing in the SDK writes task progress.
111
+ *
112
+ * @deprecated No producer. Removed in the next major.
113
+ */
109
114
  progress?: AgentTaskProgress
110
115
 
111
116
  /**
@@ -180,6 +180,16 @@ export interface IterationCheckpoint {
180
180
  messages: Message[]
181
181
  tokenUsage: TokenUsage
182
182
  costInfo: CostInfo
183
+ /**
184
+ * **Never set.** No checkpoint is written with a plan status.
185
+ *
186
+ * It matters more than an unused field usually would: a host restoring a
187
+ * checkpoint and reading this to decide whether the plan was approved
188
+ * gets `undefined` for every run, approved or not, and cannot tell the
189
+ * two apart. Ask the plan manager instead.
190
+ *
191
+ * @deprecated No producer. Removed in the next major.
192
+ */
183
193
  planStatus?: PlanStatus
184
194
 
185
195
  /**