@namzu/sdk 6.2.0 → 8.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 (251) hide show
  1. package/CHANGELOG.md +677 -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 +172 -158
  7. package/dist/agents/SupervisorAgent.js.map +1 -1
  8. package/dist/agents/__tests__/supervisor-inbox-scope.test.d.ts +2 -0
  9. package/dist/agents/__tests__/supervisor-inbox-scope.test.d.ts.map +1 -0
  10. package/dist/agents/__tests__/supervisor-inbox-scope.test.js +125 -0
  11. package/dist/agents/__tests__/supervisor-inbox-scope.test.js.map +1 -0
  12. package/dist/agents/runAgent.d.ts +19 -1
  13. package/dist/agents/runAgent.d.ts.map +1 -1
  14. package/dist/agents/runAgent.js +2 -0
  15. package/dist/agents/runAgent.js.map +1 -1
  16. package/dist/bridge/a2a/mapper.d.ts.map +1 -1
  17. package/dist/bridge/a2a/mapper.js +4 -0
  18. package/dist/bridge/a2a/mapper.js.map +1 -1
  19. package/dist/bridge/sse/mapper.d.ts.map +1 -1
  20. package/dist/bridge/sse/mapper.js +24 -0
  21. package/dist/bridge/sse/mapper.js.map +1 -1
  22. package/dist/connector/mcp/__tests__/positional-arrays.test.d.ts +2 -0
  23. package/dist/connector/mcp/__tests__/positional-arrays.test.d.ts.map +1 -0
  24. package/dist/connector/mcp/__tests__/positional-arrays.test.js +142 -0
  25. package/dist/connector/mcp/__tests__/positional-arrays.test.js.map +1 -0
  26. package/dist/connector/mcp/adapter.d.ts.map +1 -1
  27. package/dist/connector/mcp/adapter.js +123 -8
  28. package/dist/connector/mcp/adapter.js.map +1 -1
  29. package/dist/constants/agent/index.d.ts +5 -0
  30. package/dist/constants/agent/index.d.ts.map +1 -1
  31. package/dist/constants/agent/index.js +5 -0
  32. package/dist/constants/agent/index.js.map +1 -1
  33. package/dist/constants/plugin/index.d.ts +15 -0
  34. package/dist/constants/plugin/index.d.ts.map +1 -1
  35. package/dist/constants/plugin/index.js +15 -0
  36. package/dist/constants/plugin/index.js.map +1 -1
  37. package/dist/contracts/api.d.ts +1 -1
  38. package/dist/contracts/api.d.ts.map +1 -1
  39. package/dist/gateway/__tests__/completion-inbox.test.js +292 -2
  40. package/dist/gateway/__tests__/completion-inbox.test.js.map +1 -1
  41. package/dist/gateway/completion-inbox.d.ts +94 -6
  42. package/dist/gateway/completion-inbox.d.ts.map +1 -1
  43. package/dist/gateway/completion-inbox.js +235 -15
  44. package/dist/gateway/completion-inbox.js.map +1 -1
  45. package/dist/gateway/local.d.ts +11 -0
  46. package/dist/gateway/local.d.ts.map +1 -1
  47. package/dist/gateway/local.js +27 -1
  48. package/dist/gateway/local.js.map +1 -1
  49. package/dist/manager/agent/lifecycle.d.ts.map +1 -1
  50. package/dist/manager/agent/lifecycle.js +6 -0
  51. package/dist/manager/agent/lifecycle.js.map +1 -1
  52. package/dist/manager/run/persistence.d.ts +8 -0
  53. package/dist/manager/run/persistence.d.ts.map +1 -1
  54. package/dist/manager/run/persistence.js +12 -0
  55. package/dist/manager/run/persistence.js.map +1 -1
  56. package/dist/provider/thinking-support.d.ts +2 -1
  57. package/dist/provider/thinking-support.d.ts.map +1 -1
  58. package/dist/provider/thinking-support.js +14 -0
  59. package/dist/provider/thinking-support.js.map +1 -1
  60. package/dist/public-runtime.d.ts +1 -1
  61. package/dist/public-runtime.d.ts.map +1 -1
  62. package/dist/public-runtime.js +9 -1
  63. package/dist/public-runtime.js.map +1 -1
  64. package/dist/run/reporter.d.ts.map +1 -1
  65. package/dist/run/reporter.js +11 -0
  66. package/dist/run/reporter.js.map +1 -1
  67. package/dist/runtime/query/__tests__/completion-does-not-erase-the-answer.test.d.ts +2 -0
  68. package/dist/runtime/query/__tests__/completion-does-not-erase-the-answer.test.d.ts.map +1 -0
  69. package/dist/runtime/query/__tests__/completion-does-not-erase-the-answer.test.js +142 -0
  70. package/dist/runtime/query/__tests__/completion-does-not-erase-the-answer.test.js.map +1 -0
  71. package/dist/runtime/query/__tests__/completion-notification.test.js +414 -32
  72. package/dist/runtime/query/__tests__/completion-notification.test.js.map +1 -1
  73. package/dist/runtime/query/__tests__/context-size-on-the-wire.test.d.ts +2 -0
  74. package/dist/runtime/query/__tests__/context-size-on-the-wire.test.d.ts.map +1 -0
  75. package/dist/runtime/query/__tests__/context-size-on-the-wire.test.js +100 -0
  76. package/dist/runtime/query/__tests__/context-size-on-the-wire.test.js.map +1 -0
  77. package/dist/runtime/query/__tests__/context.test.js +18 -0
  78. package/dist/runtime/query/__tests__/context.test.js.map +1 -1
  79. package/dist/runtime/query/__tests__/effort-reaches-the-wire.test.d.ts +2 -0
  80. package/dist/runtime/query/__tests__/effort-reaches-the-wire.test.d.ts.map +1 -0
  81. package/dist/runtime/query/__tests__/effort-reaches-the-wire.test.js +118 -0
  82. package/dist/runtime/query/__tests__/effort-reaches-the-wire.test.js.map +1 -0
  83. package/dist/runtime/query/__tests__/tool-timeout.test.js +34 -0
  84. package/dist/runtime/query/__tests__/tool-timeout.test.js.map +1 -1
  85. package/dist/runtime/query/context.d.ts.map +1 -1
  86. package/dist/runtime/query/context.js +16 -1
  87. package/dist/runtime/query/context.js.map +1 -1
  88. package/dist/runtime/query/executor.d.ts.map +1 -1
  89. package/dist/runtime/query/executor.js +11 -1
  90. package/dist/runtime/query/executor.js.map +1 -1
  91. package/dist/runtime/query/guard.d.ts +28 -0
  92. package/dist/runtime/query/guard.d.ts.map +1 -1
  93. package/dist/runtime/query/guard.js +31 -0
  94. package/dist/runtime/query/guard.js.map +1 -1
  95. package/dist/runtime/query/iteration/__tests__/settle-grace.test.d.ts +2 -0
  96. package/dist/runtime/query/iteration/__tests__/settle-grace.test.d.ts.map +1 -0
  97. package/dist/runtime/query/iteration/__tests__/settle-grace.test.js +226 -0
  98. package/dist/runtime/query/iteration/__tests__/settle-grace.test.js.map +1 -0
  99. package/dist/runtime/query/iteration/index.d.ts +92 -0
  100. package/dist/runtime/query/iteration/index.d.ts.map +1 -1
  101. package/dist/runtime/query/iteration/index.js +818 -565
  102. package/dist/runtime/query/iteration/index.js.map +1 -1
  103. package/dist/runtime/query/iteration/phases/__tests__/compaction-declined.test.d.ts +2 -0
  104. package/dist/runtime/query/iteration/phases/__tests__/compaction-declined.test.d.ts.map +1 -0
  105. package/dist/runtime/query/iteration/phases/__tests__/compaction-declined.test.js +95 -0
  106. package/dist/runtime/query/iteration/phases/__tests__/compaction-declined.test.js.map +1 -0
  107. package/dist/runtime/query/iteration/phases/compaction.d.ts +34 -0
  108. package/dist/runtime/query/iteration/phases/compaction.d.ts.map +1 -1
  109. package/dist/runtime/query/iteration/phases/compaction.js +61 -4
  110. package/dist/runtime/query/iteration/phases/compaction.js.map +1 -1
  111. package/dist/telemetry/__tests__/model-call-span.test.js +22 -4
  112. package/dist/telemetry/__tests__/model-call-span.test.js.map +1 -1
  113. package/dist/telemetry/__tests__/span-closure.test.js +12 -5
  114. package/dist/telemetry/__tests__/span-closure.test.js.map +1 -1
  115. package/dist/tools/__tests__/untrusted-envelope.test.js +16 -0
  116. package/dist/tools/__tests__/untrusted-envelope.test.js.map +1 -1
  117. package/dist/tools/coordinator/__tests__/completion-delivery.test.js +117 -0
  118. package/dist/tools/coordinator/__tests__/completion-delivery.test.js.map +1 -1
  119. package/dist/tools/coordinator/__tests__/task-list.test.js +57 -0
  120. package/dist/tools/coordinator/__tests__/task-list.test.js.map +1 -1
  121. package/dist/tools/coordinator/__tests__/wait-with-idle-bound.test.d.ts +2 -0
  122. package/dist/tools/coordinator/__tests__/wait-with-idle-bound.test.d.ts.map +1 -0
  123. package/dist/tools/coordinator/__tests__/wait-with-idle-bound.test.js +193 -0
  124. package/dist/tools/coordinator/__tests__/wait-with-idle-bound.test.js.map +1 -0
  125. package/dist/tools/coordinator/index.d.ts +19 -0
  126. package/dist/tools/coordinator/index.d.ts.map +1 -1
  127. package/dist/tools/coordinator/index.js +191 -71
  128. package/dist/tools/coordinator/index.js.map +1 -1
  129. package/dist/tools/coordinator/wait-with-idle-bound.d.ts +66 -0
  130. package/dist/tools/coordinator/wait-with-idle-bound.d.ts.map +1 -0
  131. package/dist/tools/coordinator/wait-with-idle-bound.js +78 -0
  132. package/dist/tools/coordinator/wait-with-idle-bound.js.map +1 -0
  133. package/dist/tools/untrusted-envelope.d.ts.map +1 -1
  134. package/dist/tools/untrusted-envelope.js +9 -1
  135. package/dist/tools/untrusted-envelope.js.map +1 -1
  136. package/dist/types/agent/base.d.ts +16 -0
  137. package/dist/types/agent/base.d.ts.map +1 -1
  138. package/dist/types/agent/gateway.d.ts +41 -0
  139. package/dist/types/agent/gateway.d.ts.map +1 -1
  140. package/dist/types/agent/lifecycle-event.d.ts +9 -1
  141. package/dist/types/agent/lifecycle-event.d.ts.map +1 -1
  142. package/dist/types/agent/task.d.ts +5 -0
  143. package/dist/types/agent/task.d.ts.map +1 -1
  144. package/dist/types/hitl/index.d.ts +10 -0
  145. package/dist/types/hitl/index.d.ts.map +1 -1
  146. package/dist/types/hitl/index.js.map +1 -1
  147. package/dist/types/probe/registry.d.ts +6 -0
  148. package/dist/types/probe/registry.d.ts.map +1 -1
  149. package/dist/types/provider/interface.d.ts +35 -0
  150. package/dist/types/provider/interface.d.ts.map +1 -1
  151. package/dist/types/run/config.d.ts +25 -0
  152. package/dist/types/run/config.d.ts.map +1 -1
  153. package/dist/types/run/entity.d.ts +16 -0
  154. package/dist/types/run/entity.d.ts.map +1 -1
  155. package/dist/types/run/events.d.ts +75 -0
  156. package/dist/types/run/events.d.ts.map +1 -1
  157. package/dist/types/run/events.js.map +1 -1
  158. package/dist/types/run/prepare-step.d.ts +17 -2
  159. package/dist/types/run/prepare-step.d.ts.map +1 -1
  160. package/dist/types/verification/index.d.ts +98 -0
  161. package/dist/types/verification/index.d.ts.map +1 -1
  162. package/dist/types/verification/index.js +10 -0
  163. package/dist/types/verification/index.js.map +1 -1
  164. package/dist/utils/__tests__/abort-reason.test.d.ts +2 -0
  165. package/dist/utils/__tests__/abort-reason.test.d.ts.map +1 -0
  166. package/dist/utils/__tests__/abort-reason.test.js +48 -0
  167. package/dist/utils/__tests__/abort-reason.test.js.map +1 -0
  168. package/dist/utils/abort.d.ts +26 -0
  169. package/dist/utils/abort.d.ts.map +1 -1
  170. package/dist/utils/abort.js +34 -0
  171. package/dist/utils/abort.js.map +1 -1
  172. package/dist/verification/__tests__/argument-pattern.test.d.ts +2 -0
  173. package/dist/verification/__tests__/argument-pattern.test.d.ts.map +1 -0
  174. package/dist/verification/__tests__/argument-pattern.test.js +122 -0
  175. package/dist/verification/__tests__/argument-pattern.test.js.map +1 -0
  176. package/dist/verification/__tests__/rule-order-and-reason.test.d.ts +2 -0
  177. package/dist/verification/__tests__/rule-order-and-reason.test.d.ts.map +1 -0
  178. package/dist/verification/__tests__/rule-order-and-reason.test.js +126 -0
  179. package/dist/verification/__tests__/rule-order-and-reason.test.js.map +1 -0
  180. package/dist/verification/gate.d.ts +17 -1
  181. package/dist/verification/gate.d.ts.map +1 -1
  182. package/dist/verification/gate.js +102 -2
  183. package/dist/verification/gate.js.map +1 -1
  184. package/dist/verification/index.d.ts +1 -1
  185. package/dist/verification/index.d.ts.map +1 -1
  186. package/dist/verification/index.js +1 -1
  187. package/dist/verification/index.js.map +1 -1
  188. package/dist/verification/rules.d.ts.map +1 -1
  189. package/dist/verification/rules.js +27 -0
  190. package/dist/verification/rules.js.map +1 -1
  191. package/package.json +1 -1
  192. package/src/agents/ReactiveAgent.ts +5 -0
  193. package/src/agents/SupervisorAgent.ts +175 -162
  194. package/src/agents/__tests__/supervisor-inbox-scope.test.ts +149 -0
  195. package/src/agents/runAgent.ts +22 -1
  196. package/src/bridge/a2a/mapper.ts +4 -0
  197. package/src/bridge/sse/mapper.ts +25 -0
  198. package/src/connector/mcp/__tests__/positional-arrays.test.ts +183 -0
  199. package/src/connector/mcp/adapter.ts +131 -7
  200. package/src/constants/agent/index.ts +5 -0
  201. package/src/constants/plugin/index.ts +15 -0
  202. package/src/contracts/api.ts +1 -0
  203. package/src/gateway/__tests__/completion-inbox.test.ts +348 -2
  204. package/src/gateway/completion-inbox.ts +248 -16
  205. package/src/gateway/local.ts +26 -1
  206. package/src/manager/agent/lifecycle.ts +6 -0
  207. package/src/manager/run/persistence.ts +12 -0
  208. package/src/provider/thinking-support.ts +19 -2
  209. package/src/public-runtime.ts +9 -0
  210. package/src/run/reporter.ts +12 -0
  211. package/src/runtime/query/__tests__/completion-does-not-erase-the-answer.test.ts +163 -0
  212. package/src/runtime/query/__tests__/completion-notification.test.ts +486 -34
  213. package/src/runtime/query/__tests__/context-size-on-the-wire.test.ts +122 -0
  214. package/src/runtime/query/__tests__/context.test.ts +24 -0
  215. package/src/runtime/query/__tests__/effort-reaches-the-wire.test.ts +135 -0
  216. package/src/runtime/query/__tests__/tool-timeout.test.ts +38 -0
  217. package/src/runtime/query/context.ts +16 -1
  218. package/src/runtime/query/executor.ts +11 -1
  219. package/src/runtime/query/guard.ts +32 -0
  220. package/src/runtime/query/iteration/__tests__/settle-grace.test.ts +265 -0
  221. package/src/runtime/query/iteration/index.ts +906 -635
  222. package/src/runtime/query/iteration/phases/__tests__/compaction-declined.test.ts +124 -0
  223. package/src/runtime/query/iteration/phases/compaction.ts +83 -10
  224. package/src/telemetry/__tests__/model-call-span.test.ts +22 -5
  225. package/src/telemetry/__tests__/span-closure.test.ts +12 -5
  226. package/src/tools/__tests__/untrusted-envelope.test.ts +23 -0
  227. package/src/tools/coordinator/__tests__/completion-delivery.test.ts +147 -0
  228. package/src/tools/coordinator/__tests__/task-list.test.ts +72 -0
  229. package/src/tools/coordinator/__tests__/wait-with-idle-bound.test.ts +247 -0
  230. package/src/tools/coordinator/index.ts +205 -78
  231. package/src/tools/coordinator/wait-with-idle-bound.ts +142 -0
  232. package/src/tools/untrusted-envelope.ts +9 -1
  233. package/src/types/agent/base.ts +17 -0
  234. package/src/types/agent/gateway.ts +42 -0
  235. package/src/types/agent/lifecycle-event.ts +7 -0
  236. package/src/types/agent/task.ts +5 -0
  237. package/src/types/hitl/index.ts +10 -0
  238. package/src/types/probe/registry.ts +6 -0
  239. package/src/types/provider/interface.ts +39 -0
  240. package/src/types/run/config.ts +26 -0
  241. package/src/types/run/entity.ts +17 -0
  242. package/src/types/run/events.ts +75 -0
  243. package/src/types/run/prepare-step.ts +17 -2
  244. package/src/types/verification/index.ts +61 -0
  245. package/src/utils/__tests__/abort-reason.test.ts +56 -0
  246. package/src/utils/abort.ts +34 -0
  247. package/src/verification/__tests__/argument-pattern.test.ts +158 -0
  248. package/src/verification/__tests__/rule-order-and-reason.test.ts +149 -0
  249. package/src/verification/gate.ts +106 -3
  250. package/src/verification/index.ts +1 -1
  251. package/src/verification/rules.ts +28 -0
@@ -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,
@@ -283,9 +311,66 @@ export function buildCoordinatorTools(opts: CoordinatorToolsOptions): ToolDefini
283
311
 
284
312
  const agentIdEnum = delegateSchema(agentIds)
285
313
 
314
+ /**
315
+ * Whether a launch can be made with nothing waiting on it.
316
+ *
317
+ * A background launch returns a task id and promises the result "later, as
318
+ * a task notification". The only thing that keeps that promise is the
319
+ * inbox: it is what holds the run open for an outstanding worker and what
320
+ * puts the completion into the transcript. With no inbox the tool told the
321
+ * model to expect a message on a channel that does not exist — measured,
322
+ * and the launch itself succeeded, so nothing failed loudly either.
323
+ *
324
+ * Withheld rather than refused per call, and rather than thrown at
325
+ * construction. Least functionality (NIST SP 800-53 Rev. 5 CM-7: provide
326
+ * only mission-essential capabilities): a parameter the model is never
327
+ * shown costs it nothing, where a parameter it is shown and then denied
328
+ * costs prompt-prefix tokens plus an iteration per attempt. And a throw
329
+ * would break a legitimate caller — an inbox-less coordinator surface is a
330
+ * supported configuration whose blocking path is unaffected, pinned by a
331
+ * test ("runs unchanged with no inbox at all"). That is the same reasoning
332
+ * that made an empty roster WITHHOLD `create_task` rather than refuse to
333
+ * build, and it is one parameter wide here for the same reason it was one
334
+ * tool wide there.
335
+ */
336
+ const canLaunchInBackground = completionInbox !== undefined
337
+
338
+ const backgroundClause = canLaunchInBackground
339
+ ? " By default this BLOCKS and returns the agent's final output as this call's tool_result; pass background: true to get a task_id back immediately and receive the result later as a task notification."
340
+ : " This BLOCKS and returns the agent's final output as this call's tool_result."
341
+
342
+ /**
343
+ * What to tell the model when a wait was cut short.
344
+ *
345
+ * The worker keeps going either way — giving up on a wait is a statement
346
+ * about the waiter, not about the work. Where the result then turns up is
347
+ * NOT the same either way, and the tool said it was: it promised a task
348
+ * notification unconditionally, which without an inbox is a message on a
349
+ * channel that does not exist. A model told to expect one waits for it,
350
+ * and the one tool that could still reach the output is the one it was
351
+ * told not to use for this.
352
+ */
353
+ const whereTheResultWillTurnUp = (taskId: TaskId): string =>
354
+ completionInbox
355
+ ? `its result will arrive separately as a task notification (task ${taskId}).`
356
+ : `it is still running as task ${taskId} — call wait_for_task with that id, or find it in agent_task_list once it finishes. Nothing will announce it on its own.`
357
+
358
+ /**
359
+ * The listing's standing advice, which depends on there being an inbox.
360
+ *
361
+ * "Do not call this to find out whether work finished" is right when a
362
+ * notification is coming. With no inbox an abandoned blocking launch has
363
+ * no announcer at all, and this listing is the only way left to reach the
364
+ * output — so the same sentence would send the model away from the one
365
+ * tool that could help it.
366
+ */
367
+ const listingAdvice = completionInbox
368
+ ? "Do NOT call this to find out whether work finished: a blocking create_task has already returned each worker's output, and a backgrounded one arrives as a task notification. Use it when you need to see what is still running, or to re-read the output of a task whose launch you stopped waiting for."
369
+ : "A blocking create_task already returns each worker's output, so do not call this in a loop to find out whether work finished. Nothing announces a completion on this configuration, so this listing and wait_for_task are how you reach the output of a task whose launch you stopped waiting for."
370
+
286
371
  const createTask = defineTool({
287
372
  name: 'create_task',
288
- description: `Launch a task on a specialized agent. By default this BLOCKS and returns the agent's final output as this call's tool_result; pass background: true to get a task_id back immediately and receive the result later as a task notification. Available agents: ${agentIds.join(', ')}. Prefer compact assignments; for large context, write/read shared workspace files and pass filenames or references. To launch multiple tasks in parallel, call this tool multiple times in a single assistant turn — the runtime executes every tool_use block from one response concurrently and delivers all tool_results together, so 'fan out 8 specialists' is one assistant message with 8 create_task blocks. Do not race: until a worker's result reaches you, you know nothing about it — never fabricate, summarise or predict what it will say, in any form.`,
373
+ description: `Launch a task on a specialized agent.${backgroundClause} Available agents: ${agentIds.join(', ')}. Prefer compact assignments; for large context, write/read shared workspace files and pass filenames or references. To launch multiple tasks in parallel, call this tool multiple times in a single assistant turn — the runtime executes every tool_use block from one response concurrently and delivers all tool_results together, so 'fan out 8 specialists' is one assistant message with 8 create_task blocks. Do not race: until a worker's result reaches you, you know nothing about it — never fabricate, summarise or predict what it will say, in any form.`,
289
374
  inputSchema: z.object({
290
375
  agent_id: agentIdEnum.describe('Which agent to run'),
291
376
  prompt: z
@@ -300,12 +385,16 @@ export function buildCoordinatorTools(opts: CoordinatorToolsOptions): ToolDefini
300
385
  .describe(
301
386
  'Existing planning task ID to link. If omitted, a planning task is auto-created.',
302
387
  ),
303
- background: z
304
- .boolean()
305
- .optional()
306
- .describe(
307
- 'Return immediately with a task_id instead of waiting. The result arrives later as a task notification. Use this when you have other work to do meanwhile; leave it off when the next thing you do depends on this answer.',
308
- ),
388
+ ...(canLaunchInBackground
389
+ ? {
390
+ background: z
391
+ .boolean()
392
+ .optional()
393
+ .describe(
394
+ 'Return immediately with a task_id instead of waiting. The result arrives later as a task notification. Use this when you have other work to do meanwhile; leave it off when the next thing you do depends on this answer.',
395
+ ),
396
+ }
397
+ : {}),
309
398
  }),
310
399
  category: 'custom',
311
400
  permissions: [],
@@ -348,6 +437,34 @@ export function buildCoordinatorTools(opts: CoordinatorToolsOptions): ToolDefini
348
437
  ...(_context.parentSpan ? { parentSpan: _context.parentSpan } : {}),
349
438
  })
350
439
 
440
+ // Whose task this is. The inbox ignores completions for anything it
441
+ // was not told about, because `onTaskCompleted` is a broadcast and a
442
+ // gateway shared between two supervisors would otherwise hand each
443
+ // of them the other's worker output. Said on BOTH paths: the
444
+ // blocking one needs it too, because the case the inbox exists for
445
+ // is exactly the blocking launch whose wait was abandoned.
446
+ completionInbox?.launched(handle.taskId)
447
+
448
+ // A background launch asked for with nowhere to deliver it is
449
+ // REFUSED, not quietly turned into a blocking one.
450
+ //
451
+ // The schema withholds the parameter and Zod strips what it does
452
+ // not declare, so this is unreachable through the model; it exists
453
+ // for a directly-constructed definition. Falling back to blocking
454
+ // would have been the tempting answer — the caller does get the
455
+ // output — but it is accepting work whose stated terms cannot be
456
+ // met, and the caller asked for a call that returns immediately.
457
+ // Naming the missing piece is the only response that tells them
458
+ // what to change.
459
+ if (background && !canLaunchInBackground) {
460
+ return {
461
+ success: false,
462
+ output: '',
463
+ error:
464
+ 'background: true needs a CompletionInbox — without one there is no channel for the notification this launch promises. Pass `completionInbox` to buildCoordinatorTools and the same instance to drainQuery, or omit `background` to wait for the result inline.',
465
+ }
466
+ }
467
+
351
468
  if (background) {
352
469
  // Tell the inbox to hold the run open for this. Without it the
353
470
  // supervisor could launch a worker, answer, and settle the run
@@ -379,7 +496,23 @@ export function buildCoordinatorTools(opts: CoordinatorToolsOptions): ToolDefini
379
496
  // assistant turn, the runtime runs them together and delivers all
380
497
  // N `tool_result`s at once. No second `tool_result` for the
381
498
  // same `tool_use_id` — providers reject a duplicated id outright.
382
- const completed = await gateway.waitForTask(handle.taskId)
499
+ // Bounded by two clocks rather than one. The wait gives up on a
500
+ // worker that has gone quiet long before the hour is out, and says
501
+ // which of the two ran out — the caller acts on that difference.
502
+ // Giving up does NOT cancel the child: it keeps going, and its
503
+ // result still reaches the supervisor as a notification.
504
+ const outcome = await waitForTaskWithBounds(gateway, handle.taskId, {
505
+ runMs: DELEGATION_TIMEOUT_MS,
506
+ idleMs: DELEGATION_IDLE_MS,
507
+ })
508
+ if (outcome.kind === 'timeout') {
509
+ return {
510
+ success: false,
511
+ output: describeWaitTimeout(outcome),
512
+ data: { task_id: handle.taskId, agent_id, timed_out: outcome.cause },
513
+ }
514
+ }
515
+ const completed = outcome.handle
383
516
 
384
517
  // Whether this call is still the live path decides who delivers the
385
518
  // result. If the executor already gave up on us — its deadline
@@ -395,7 +528,7 @@ export function buildCoordinatorTools(opts: CoordinatorToolsOptions): ToolDefini
395
528
  if (_context.abortSignal?.aborted) {
396
529
  return {
397
530
  success: false,
398
- output: `This wait was abandoned before ${agent_id} finished; its result will arrive separately as a task notification (task ${handle.taskId}).`,
531
+ output: `This wait was abandoned before ${agent_id} finished; ${whereTheResultWillTurnUp(handle.taskId)}`,
399
532
  data: { task_id: handle.taskId, agent_id, abandoned: true },
400
533
  }
401
534
  }
@@ -450,55 +583,6 @@ export function buildCoordinatorTools(opts: CoordinatorToolsOptions): ToolDefini
450
583
  },
451
584
  })
452
585
 
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
586
  /**
503
587
  * Join a task already running, without sending it anything.
504
588
  *
@@ -533,11 +617,22 @@ export function buildCoordinatorTools(opts: CoordinatorToolsOptions): ToolDefini
533
617
  }
534
618
  }
535
619
 
536
- const completed = await gateway.waitForTask(task_id as TaskId)
620
+ const outcome = await waitForTaskWithBounds(gateway, task_id as TaskId, {
621
+ runMs: DELEGATION_TIMEOUT_MS,
622
+ idleMs: DELEGATION_IDLE_MS,
623
+ })
624
+ if (outcome.kind === 'timeout') {
625
+ return {
626
+ success: false,
627
+ output: describeWaitTimeout(outcome),
628
+ data: { task_id, timed_out: outcome.cause },
629
+ }
630
+ }
631
+ const completed = outcome.handle
537
632
  if (_context.abortSignal?.aborted) {
538
633
  return {
539
634
  success: false,
540
- output: `This wait was abandoned before task ${task_id} finished; its result will arrive separately as a task notification.`,
635
+ output: `This wait was abandoned before task ${task_id} finished; ${whereTheResultWillTurnUp(task_id as TaskId)}`,
541
636
  data: { task_id, abandoned: true },
542
637
  }
543
638
  }
@@ -598,8 +693,7 @@ export function buildCoordinatorTools(opts: CoordinatorToolsOptions): ToolDefini
598
693
 
599
694
  const agentTaskList = defineTool({
600
695
  name: 'agent_task_list',
601
- description:
602
- "Inspect the live state of every agent task launched on this gateway via create_task: returns each task's id, agent, state (pending/running/completed/failed/canceled), and timing. Distinct from the plan-task store's `task_list` (which lists planning tasks): this tool lists running/completed worker invocations. Do NOT call this to find out whether work finished: a blocking create_task has already returned each worker's output, and a backgrounded one arrives as a task notification. Use it when you need to see what is still running, or to re-read the output of a task whose launch you stopped waiting for.",
696
+ description: `Inspect the live state of every agent task launched on this gateway via create_task: returns each task's id, agent, state (pending/running/completed/failed/canceled), and timing. Distinct from the plan-task store's \`task_list\` (which lists planning tasks): this tool lists running/completed worker invocations. ${listingAdvice}`,
603
697
  inputSchema: z.object({
604
698
  state: z
605
699
  .enum(['pending', 'running', 'completed', 'failed', 'canceled'])
@@ -656,10 +750,29 @@ export function buildCoordinatorTools(opts: CoordinatorToolsOptions): ToolDefini
656
750
  // model cannot see, which is how this listing came to prove a
657
751
  // task had finished while withholding what it said.
658
752
  if (i.result === undefined) return head
659
- const body =
660
- i.result.length > LISTED_RESULT_LIMIT
661
- ? `${i.result.slice(0, LISTED_RESULT_LIMIT)}\n … truncated; call wait_for_task with "${i.task_id}" for the whole thing.`
662
- : i.result
753
+ const overLimit = i.result.length > LISTED_RESULT_LIMIT
754
+ // Framed exactly as the blocking `create_task` and
755
+ // `wait_for_task` frame the same bytes. This listing was the
756
+ // third way to read a delegate's output and the only one that
757
+ // pasted it bare — so a worker's text was material on two
758
+ // paths and read as the parent's own reasoning on the third,
759
+ // and which one a run got depended on how the model chose to
760
+ // fetch it.
761
+ const framed = wrapUntrusted(
762
+ {
763
+ kind: 'agent-result',
764
+ attributes: { agent: i.agent_id, task: i.task_id },
765
+ provenance: `This is the output of the delegated agent "${i.agent_id}", not this agent's own work.`,
766
+ },
767
+ overLimit ? i.result.slice(0, LISTED_RESULT_LIMIT) : i.result,
768
+ )
769
+ // After the closing tag, not inside it: this sentence is the
770
+ // kernel telling the model how to get the rest, and inside
771
+ // the envelope it has just been told the contents are not
772
+ // instructions addressed to it.
773
+ const body = overLimit
774
+ ? `${framed}\n… truncated; call wait_for_task with "${i.task_id}" for the whole thing.`
775
+ : framed
663
776
  return `${head}\n${body
664
777
  .split('\n')
665
778
  .map((line) => ` ${line}`)
@@ -675,18 +788,32 @@ export function buildCoordinatorTools(opts: CoordinatorToolsOptions): ToolDefini
675
788
  },
676
789
  })
677
790
 
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
791
+ // `continue_task` is gone, and the reasoning is worth keeping because
792
+ // the obvious argument for bringing it back does not survive contact.
793
+ //
794
+ // It was dropped on the grounds that a blocking `create_task` leaves every
795
+ // worker terminal before a later turn learns its id, and the manager
796
+ // refuses `continue` on a terminal task. `background: true` reinstated that
797
+ // precondition a live id is reachable now — so the question was reopened.
798
+ //
799
+ // Measured rather than assumed, and it fails on the other side. On a LIVE
800
+ // task the manager accepts the call and pushes onto `pendingMessages`,
801
+ // and NOTHING drains that queue during a run. This codebase already
802
+ // knows: `runtime/query/steering.ts` says in as many words that
803
+ // `queueMessage`/`drainMessages` were never read by the iteration loop,
804
+ // and `SteeringChannel` exists BECAUSE of that — it delivers guidance on a
805
+ // tool result instead, since a `tool_use` must be answered by a
806
+ // `tool_result` with the same id and there is no legal slot for a user
807
+ // message mid-batch.
808
+ //
809
+ // So the tool had no state it worked in: terminal tasks refuse it, live
810
+ // tasks accept it into a queue nobody reads. Registering it would have
811
+ // handed the model a call that silently does nothing — worse than the
812
+ // unregistered definition it replaced, because a defined-but-unreachable
813
+ // tool at least cannot be called.
814
+ //
815
+ // If follow-ups on a live worker are wanted, the work is a consumer for
816
+ // the queue or a steering channel that reaches a child, not this tool.
690
817
  // `cancel_task` is registered again, and the reasoning that dropped it is
691
818
  // worth keeping because it was sound at the time and is not any more.
692
819
  //