@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
@@ -1,5 +1,29 @@
1
+ import { wrapUntrusted } from '../tools/untrusted-envelope.js'
1
2
  import type { TaskGateway, TaskHandle } from '../types/agent/gateway.js'
3
+ import { isTerminalAgentTaskState } from '../types/agent/task.js'
2
4
  import type { TaskId } from '../types/ids/index.js'
5
+ import { getRootLogger } from '../utils/logger.js'
6
+
7
+ /**
8
+ * How many unclaimed announcements may wait for an owner at once.
9
+ *
10
+ * Derived from what it has to survive rather than picked. An entry lives here
11
+ * only between a gateway announcing a task and this run saying whether the
12
+ * task is its own — one microtask, for a launch made through `create_task`.
13
+ * The number that has to fit is therefore the largest batch of launches that
14
+ * can be in flight together before any of them is claimed: one assistant turn
15
+ * of `create_task` blocks, which this codebase's own tool description
16
+ * illustrates as "fan out 8 specialists" and which a provider bounds at a few
17
+ * dozen tool_use blocks per response. 32 clears that with room, and a batch
18
+ * bigger than it is announced rather than silently truncated.
19
+ *
20
+ * The ceiling is what stops this being the retention half of the leak it
21
+ * exists beside: on a gateway shared with other runs, every foreign completion
22
+ * lands here and is never claimed, and each one holds a whole worker result —
23
+ * kilobytes at least. Bounded, the cost is 32 handles; unbounded, it is every
24
+ * result every other run on that gateway ever produced.
25
+ */
26
+ const UNOWNED_BUFFER_LIMIT = 32
3
27
 
4
28
  /**
5
29
  * Completions that finished with nobody left to hear them.
@@ -37,8 +61,42 @@ export class CompletionInbox {
37
61
  private readonly claimed = new Set<TaskId>()
38
62
  /** Launched with nothing waiting on it, and not settled yet. */
39
63
  private readonly outstanding = new Set<TaskId>()
64
+ /**
65
+ * Tasks THIS run launched.
66
+ *
67
+ * `onTaskCompleted` is a broadcast and `TaskHandle` carries no run id, so
68
+ * a gateway shared between two supervisors hands every completion to both
69
+ * of their inboxes. Measured: with two inboxes on one gateway, the run
70
+ * that launched nothing drained the other run's task and would have been
71
+ * told "a task you launched has finished" — a claim that was false, over
72
+ * another run's worker output, in a transcript whose model then has to
73
+ * account for it.
74
+ *
75
+ * A shared gateway is not an abuse of the API: `SupervisorAgentConfig`
76
+ * takes one, and a host that owns a gateway naturally reuses it.
77
+ */
78
+ private readonly ours = new Set<TaskId>()
79
+ /**
80
+ * Announcements that arrived before anyone said whose task it was.
81
+ *
82
+ * `gateway.createTask` resolves one microtask before its caller can name
83
+ * the task, and a worker that finishes inside that window is announced
84
+ * first — `LocalTaskGateway` attaches its completion continuation before
85
+ * it returns the handle, so the ordering is guaranteed to be reachable
86
+ * rather than merely possible. Dropping an unowned announcement outright
87
+ * would therefore turn the leak fix into a LOST RESULT for exactly the
88
+ * fast completions the inbox exists to catch.
89
+ *
90
+ * So they wait here, and ownership may be claimed retroactively. What
91
+ * makes that safe rather than a second leak is the bound: on a gateway
92
+ * shared with other runs this fills with completions that will never be
93
+ * claimed, each holding a whole worker result.
94
+ */
95
+ private readonly unowned = new Map<TaskId, TaskHandle>()
40
96
  private readonly arrivals = new Set<() => void>()
41
97
  private detach?: () => void
98
+ /** Kept for {@link launched}: the source of truth about a task's state. */
99
+ private gateway?: TaskGateway
42
100
 
43
101
  /**
44
102
  * Start listening.
@@ -50,7 +108,16 @@ export class CompletionInbox {
50
108
  */
51
109
  attach(gateway: TaskGateway): () => void {
52
110
  if (this.detach) return this.detach
111
+ this.gateway = gateway
53
112
  this.detach = gateway.onTaskCompleted((handle) => {
113
+ // Not known to be ours — either another run's worker on a shared
114
+ // gateway, or ours announced before the launch could be recorded.
115
+ // The two are indistinguishable here, so it waits rather than
116
+ // being delivered or dropped. See {@link unowned}.
117
+ if (!this.ours.has(handle.taskId)) {
118
+ this.hold(handle)
119
+ return
120
+ }
54
121
  // A completion claimed before it was announced — a tool that
55
122
  // finished its wait faster than the listener ran — is already
56
123
  // delivered. Nothing to queue.
@@ -62,16 +129,88 @@ export class CompletionInbox {
62
129
  return this.detach
63
130
  }
64
131
 
132
+ /**
133
+ * Park an announcement nobody has claimed yet, evicting the oldest if the
134
+ * buffer is full.
135
+ *
136
+ * An eviction is logged at WARN, and that is not decoration. If the entry
137
+ * turned out to be ours, its completion has just been dropped — the
138
+ * original defect, wearing the cap as a disguise — and the only evidence
139
+ * would otherwise be an absence, which is precisely the shape of failure
140
+ * this whole session has been closing. A reader who sees this line knows
141
+ * where to look.
142
+ */
143
+ private hold(handle: TaskHandle): void {
144
+ if (this.unowned.size >= UNOWNED_BUFFER_LIMIT && !this.unowned.has(handle.taskId)) {
145
+ const oldest = this.unowned.keys().next()
146
+ if (!oldest.done) {
147
+ this.unowned.delete(oldest.value)
148
+ getRootLogger()
149
+ .child({ component: 'CompletionInbox' })
150
+ .warn(
151
+ "Unclaimed completion buffer is full — dropped the oldest. If that task was this run's, its result is now unreachable; raise UNOWNED_BUFFER_LIMIT or launch fewer tasks per turn.",
152
+ { dropped: oldest.value, limit: UNOWNED_BUFFER_LIMIT },
153
+ )
154
+ }
155
+ }
156
+ this.unowned.set(handle.taskId, handle)
157
+ }
158
+
159
+ /**
160
+ * Say that this run launched the task.
161
+ *
162
+ * Required before anything about the task can reach this inbox — see
163
+ * {@link ours}. Every launch says it, whether or not something is waiting
164
+ * on the result, because the case the inbox exists for is precisely the
165
+ * one where the waiter gave up.
166
+ *
167
+ * **The late-announcement branches are not defensive.**
168
+ * `gateway.createTask` resolves one microtask before its caller can say
169
+ * who owns the task, and a worker that finishes inside that window is
170
+ * announced first — the same ordering that used to leave a permanent
171
+ * pending flag. Without recovery the ownership check would turn that race
172
+ * from a stale flag into a lost result.
173
+ *
174
+ * There are two, and the second is the safety net for the first. The
175
+ * buffer ({@link unowned}) needs nothing from the gateway beyond the
176
+ * announcement it already made. Asking `getTask` covers the case the
177
+ * buffer cannot — an announcement evicted under load — but it rests on a
178
+ * gateway still knowing about a task it has just settled, which is a
179
+ * property of the implementations here rather than of the `TaskGateway`
180
+ * contract, and `getTask`'s own docs now say so.
181
+ */
182
+ launched(taskId: TaskId): void {
183
+ if (this.ours.has(taskId)) return
184
+ this.ours.add(taskId)
185
+
186
+ if (this.claimed.has(taskId) || this.unheard.has(taskId)) return
187
+
188
+ const parked = this.unowned.get(taskId)
189
+ if (parked) {
190
+ this.unowned.delete(taskId)
191
+ this.unheard.set(taskId, parked)
192
+ for (const wake of [...this.arrivals]) wake()
193
+ return
194
+ }
195
+
196
+ const settled = this.gateway?.getTask(taskId)
197
+ if (!settled || !isTerminalAgentTaskState(settled.state)) return
198
+ this.unheard.set(taskId, settled)
199
+ for (const wake of [...this.arrivals]) wake()
200
+ }
201
+
65
202
  /**
66
203
  * Say that a task was launched with nothing waiting on it.
67
204
  *
68
- * Without this the inbox can only see completions that have already
69
- * happened, and a run whose supervisor launched a background worker and
70
- * then answered would settle while the worker was still going — throwing
71
- * away the very result the launch existed to produce. Knowing a task is
72
- * outstanding is what lets the loop hold the run open for it.
205
+ * {@link launched} plus the statement that no call will deliver the
206
+ * result. Without the second half the inbox can only see completions that
207
+ * have already happened, and a run whose supervisor launched a background
208
+ * worker and then answered would settle while the worker was still going
209
+ * throwing away the very result the launch existed to produce. Knowing a
210
+ * task is outstanding is what lets the loop hold the run open for it.
73
211
  */
74
212
  expect(taskId: TaskId): void {
213
+ this.launched(taskId)
75
214
  if (this.claimed.has(taskId)) return
76
215
  this.outstanding.add(taskId)
77
216
  }
@@ -134,6 +273,17 @@ export class CompletionInbox {
134
273
  return this.unheard.size > 0
135
274
  }
136
275
 
276
+ /**
277
+ * Tasks this run launched that are still running.
278
+ *
279
+ * Read when a run ends, so it can say which work it walked away from.
280
+ * Nothing here is cancelled by being read — the ids are a statement, and
281
+ * what to do about them is the host's call.
282
+ */
283
+ get outstandingTaskIds(): readonly TaskId[] {
284
+ return [...this.outstanding]
285
+ }
286
+
137
287
  /**
138
288
  * Take every unheard completion, leaving the inbox empty.
139
289
  *
@@ -144,7 +294,22 @@ export class CompletionInbox {
144
294
  if (this.unheard.size === 0) return []
145
295
  const handles = [...this.unheard.values()]
146
296
  this.unheard.clear()
147
- for (const handle of handles) this.claimed.add(handle.taskId)
297
+ for (const handle of handles) {
298
+ this.claimed.add(handle.taskId)
299
+ // A delivered result is not pending WORK, and `outstanding` can
300
+ // still be holding this id — the listener clears it, but only if
301
+ // the announcement came AFTER the launching tool said `expect`.
302
+ // The other order is reachable: `expect` runs one microtask after
303
+ // `gateway.createTask` resolves, and the gateway's own completion
304
+ // callback can win that race for a task that finished fast. Then
305
+ // `expect` re-adds an id the listener had nothing to remove, and
306
+ // nothing else ever takes it off — so `hasPendingWork` stayed true
307
+ // for the rest of the run and every attempt to settle paid the
308
+ // full grace period waiting for a result already in the transcript.
309
+ //
310
+ // Symmetric with `claim`, which clears it for the same reason.
311
+ this.outstanding.delete(handle.taskId)
312
+ }
148
313
  return handles
149
314
  }
150
315
 
@@ -179,12 +344,25 @@ export class CompletionInbox {
179
344
  for (const wake of [...this.arrivals]) wake()
180
345
  }
181
346
 
182
- /** Stop listening. Safe to call more than once. */
347
+ /**
348
+ * Stop listening. Safe to call more than once.
349
+ *
350
+ * A run that ends without this leaves its listener on the gateway
351
+ * forever. On a gateway the host reuses that is measurable — three
352
+ * sequential runs left three live subscriptions, each still holding its
353
+ * run's handles — and the listener set only grows. Ownership stops a
354
+ * retained listener from DELIVERING another run's work; closing is what
355
+ * stops it existing.
356
+ */
183
357
  close(): void {
184
358
  this.detach?.()
185
359
  this.detach = undefined
360
+ this.gateway = undefined
186
361
  this.unheard.clear()
187
362
  this.outstanding.clear()
363
+ this.ours.clear()
364
+ this.claimed.clear()
365
+ this.unowned.clear()
188
366
  // Release anyone still waiting. A closed inbox would otherwise hold
189
367
  // them to their own deadline for a completion that can no longer come.
190
368
  for (const wake of [...this.arrivals]) wake()
@@ -195,6 +373,30 @@ export class CompletionInbox {
195
373
  /** How much of a worker's output rides in the notification itself. */
196
374
  const NOTIFICATION_OUTPUT_LIMIT = 4_000
197
375
 
376
+ const NOTIFICATION_DELIMITER = /task-notification/gi
377
+
378
+ /**
379
+ * Defang this file's own delimiter inside a worker's text.
380
+ *
381
+ * Without it a worker whose output contains `</task-notification>` closes the
382
+ * block early, and everything it wrote after that sits OUTSIDE the boundary —
383
+ * reading as ordinary transcript rather than as a delegate's material.
384
+ * Measured before the fix: two closing tags in one notification, with
385
+ * attacker-controlled text between them.
386
+ *
387
+ * The replacement swaps the hyphen for an underscore rather than appending a
388
+ * suffix, for the reason `neutralizeEnvelopeDelimiter` records: a replacement
389
+ * that still CONTAINS the token is found again by a second pass or by any
390
+ * looser matcher downstream. `task_notification` shares no substring with the
391
+ * real delimiter while staying legible.
392
+ *
393
+ * The nested `<namzu-untrusted>` block defangs its own delimiter; these two
394
+ * patterns are disjoint, so the order they run in does not matter.
395
+ */
396
+ function neutralizeNotificationDelimiter(content: string): string {
397
+ return content.replace(NOTIFICATION_DELIMITER, 'task_notification')
398
+ }
399
+
198
400
  /**
199
401
  * The message a supervisor reads when a worker it stopped waiting for
200
402
  * finishes.
@@ -212,14 +414,32 @@ export function formatCompletionNotification(handles: readonly TaskHandle[]): st
212
414
  const blocks = handles.map((handle) => {
213
415
  const durationMs = handle.completedAt ? handle.completedAt - handle.createdAt : undefined
214
416
  const output = handle.result?.result ?? handle.result?.lastError ?? ''
215
- const truncated =
216
- output.length > NOTIFICATION_OUTPUT_LIMIT
217
- ? // `wait_for_task`, not `agent_task_list` — the listing takes only a
218
- // state filter, so an instruction to call it "with task_id" named
219
- // a parameter that does not exist and could not be followed. On an
220
- // already-finished task the wait returns immediately.
221
- `${output.slice(0, NOTIFICATION_OUTPUT_LIMIT)}\n… truncated. Call wait_for_task with task_id "${handle.taskId}" for the full output.`
222
- : output
417
+ const overLimit = output.length > NOTIFICATION_OUTPUT_LIMIT
418
+ const shown = overLimit ? output.slice(0, NOTIFICATION_OUTPUT_LIMIT) : output
419
+
420
+ // Framed for the same reason the blocking `create_task` frames its
421
+ // return value, and this path is the one that had nothing. A delegated
422
+ // worker is the component most likely to have consumed material nobody
423
+ // in this run authored it was told to read and report, and it ran
424
+ // `read`, `grep`, `fetch` over whatever it found — and its text lands
425
+ // in a parent that typically holds the broader tool grant. The same
426
+ // bytes were being wrapped on one path and pasted bare on this one.
427
+ //
428
+ // The metadata above stays OUTSIDE the envelope: the task id, the agent
429
+ // and the state are this kernel's own statements, and framing them as
430
+ // untrusted material would tell the model to discount the only part of
431
+ // the message it can rely on.
432
+ const body =
433
+ shown.length > 0
434
+ ? wrapUntrusted(
435
+ {
436
+ kind: 'agent-result',
437
+ attributes: { agent: handle.agentId, task: handle.taskId },
438
+ provenance: `This is the output of the delegated agent "${handle.agentId}", not this agent's own work.`,
439
+ },
440
+ neutralizeNotificationDelimiter(shown),
441
+ )
442
+ : '(the task produced no output)'
223
443
 
224
444
  const lines = [
225
445
  `task_id: ${handle.taskId}`,
@@ -227,7 +447,19 @@ export function formatCompletionNotification(handles: readonly TaskHandle[]): st
227
447
  `state: ${handle.state}`,
228
448
  ...(durationMs !== undefined ? [`duration_ms: ${durationMs}`] : []),
229
449
  '',
230
- truncated.length > 0 ? truncated : '(the task produced no output)',
450
+ body,
451
+ // Outside the envelope, deliberately: this sentence is an
452
+ // instruction from the kernel about how to get the rest, and inside
453
+ // the envelope the model has just been told not to treat the
454
+ // contents as instructions.
455
+ //
456
+ // `wait_for_task`, not `agent_task_list` — the listing takes only a
457
+ // state filter, so an instruction to call it "with task_id" named a
458
+ // parameter that does not exist and could not be followed. On an
459
+ // already-finished task the wait returns immediately.
460
+ ...(overLimit
461
+ ? [`… truncated. Call wait_for_task with task_id "${handle.taskId}" for the full output.`]
462
+ : []),
231
463
  ]
232
464
  return `<task-notification>\n${lines.join('\n')}\n</task-notification>`
233
465
  })
@@ -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,
@@ -270,6 +270,18 @@ export class RunPersistence {
270
270
  this.run.structuredOutput = value
271
271
  }
272
272
 
273
+ /**
274
+ * Name the delegated work this run ended without waiting for.
275
+ *
276
+ * See {@link Run.abandonedTaskIds}. Recording rather than cancelling is
277
+ * the point: the kernel owes the caller the truth about what it walked
278
+ * away from, and nothing more.
279
+ */
280
+ setAbandonedTaskIds(taskIds: readonly string[]): void {
281
+ if (taskIds.length === 0) return
282
+ this.run.abandonedTaskIds = [...taskIds]
283
+ }
284
+
273
285
  setSteps(steps: readonly StepResult[]): void {
274
286
  this.run.steps = steps
275
287
  }
@@ -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'
@@ -270,6 +270,18 @@ export function createRunReporter(parentLogger?: Logger): RunReporter {
270
270
  })
271
271
  break
272
272
 
273
+ case 'compaction_failed':
274
+ // warn rather than info: the run is now continuing at a context
275
+ // size it had already decided was too large.
276
+ log.warn('Context compaction shed nothing', {
277
+ runId: event.runId,
278
+ iteration: event.iteration,
279
+ cause: event.cause,
280
+ messages: event.messages,
281
+ ...(event.error !== undefined ? { error: event.error } : {}),
282
+ })
283
+ break
284
+
273
285
  case 'capability_warning':
274
286
  log.warn('Provider capability mismatch', {
275
287
  runId: event.runId,
@@ -0,0 +1,163 @@
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 { z } from 'zod'
6
+
7
+ import { CompletionInbox } from '../../../gateway/completion-inbox.js'
8
+ import { ToolRegistry } from '../../../registry/tool/execute.js'
9
+ import { defineTool } from '../../../tools/defineTool.js'
10
+ import type { TaskHandle } from '../../../types/agent/gateway.js'
11
+ import type { SessionId, TaskId, TenantId } from '../../../types/ids/index.js'
12
+ import { createUserMessage } from '../../../types/message/index.js'
13
+ import type { LLMProvider, StreamChunk } from '../../../types/provider/index.js'
14
+ import type { ProjectId, ThreadId } from '../../../types/session/ids.js'
15
+ import { drainQuery } from '../index.js'
16
+
17
+ /**
18
+ * A notification appended after the answer must not become the answer's grave.
19
+ *
20
+ * `RunPersistence.resolveResult` assembles `Run.result` by walking the message
21
+ * tail BACKWARDS and stopping at the first non-assistant message, and it runs
22
+ * at `markCompleted` — after the loop has finished. So a task notification
23
+ * pushed after the final assistant turn hides that turn from the assembler
24
+ * entirely.
25
+ *
26
+ * This is not hypothetical. It was introduced by the change that made every
27
+ * exit hand over a finished worker's output, and measured here: a run whose
28
+ * model had just said "THIS IS THE RUN ANSWER." returned `run.result ===
29
+ * undefined`. Trading a lost worker result for a lost RUN result is strictly
30
+ * worse than the defect the delivery exists to fix, and every one of the
31
+ * suite's 2,600 tests passed while it was true, because none of them asserted
32
+ * `run.result` on a path where a completion could land last.
33
+ */
34
+
35
+ const ZERO_USAGE = {
36
+ promptTokens: 0,
37
+ completionTokens: 0,
38
+ totalTokens: 0,
39
+ cachedTokens: 0,
40
+ cacheWriteTokens: 0,
41
+ }
42
+
43
+ const ANSWER = 'THIS IS THE RUN ANSWER.'
44
+
45
+ function completed(): TaskHandle {
46
+ return {
47
+ taskId: 'tsk_bg' as TaskId,
48
+ agentId: 'reviewer',
49
+ state: 'completed',
50
+ createdAt: 0,
51
+ completedAt: 1,
52
+ result: { status: 'completed', result: 'THE WORKER RESULT' },
53
+ } as TaskHandle
54
+ }
55
+
56
+ const noop = defineTool({
57
+ name: 'noop',
58
+ description: 'does nothing',
59
+ inputSchema: z.object({}),
60
+ category: 'analysis',
61
+ permissions: [],
62
+ readOnly: true,
63
+ destructive: false,
64
+ concurrencySafe: true,
65
+ async execute() {
66
+ return { success: true, output: 'ok' }
67
+ },
68
+ })
69
+
70
+ const workdirs: string[] = []
71
+ afterEach(async () => {
72
+ await Promise.all(workdirs.map((dir) => rm(dir, { recursive: true, force: true })))
73
+ workdirs.length = 0
74
+ })
75
+
76
+ describe('a completion delivered on the way out leaves the answer readable', () => {
77
+ it('keeps run.result when the worker lands during the closing turn', async () => {
78
+ const workingDirectory = await mkdtemp(join(tmpdir(), 'namzu-answer-'))
79
+ workdirs.push(workingDirectory)
80
+
81
+ const inbox = new CompletionInbox()
82
+ let announce: ((h: TaskHandle) => void) | undefined
83
+ inbox.launched('tsk_bg' as TaskId)
84
+ inbox.attach({
85
+ onTaskCompleted: (cb: (h: TaskHandle) => void) => {
86
+ announce = cb
87
+ return () => {
88
+ announce = undefined
89
+ }
90
+ },
91
+ getTask: () => undefined,
92
+ } as never)
93
+
94
+ /**
95
+ * One tool call, then the iteration ceiling forces a closing turn. The
96
+ * worker settles DURING that closing turn — after the last in-loop
97
+ * drain, so the only thing left to deliver it is the exit path.
98
+ */
99
+ class ClosingTurnProvider implements LLMProvider {
100
+ readonly id = 'closing-turn'
101
+ readonly name = 'Closing Turn Provider'
102
+ calls = 0
103
+ async *chatStream(): AsyncIterable<StreamChunk> {
104
+ this.calls += 1
105
+ if (this.calls === 1) {
106
+ yield {
107
+ id: 'm1',
108
+ delta: {
109
+ toolCalls: [
110
+ {
111
+ index: 0,
112
+ id: 'toolu_1',
113
+ type: 'function',
114
+ function: { name: 'noop', arguments: '{}' },
115
+ },
116
+ ],
117
+ },
118
+ }
119
+ yield { id: 'm1', delta: {}, finishReason: 'tool_calls', usage: ZERO_USAGE }
120
+ return
121
+ }
122
+ announce?.(completed())
123
+ yield { id: 'm2', delta: { content: ANSWER } }
124
+ yield { id: 'm2', delta: {}, finishReason: 'stop', usage: ZERO_USAGE }
125
+ }
126
+ }
127
+
128
+ const tools = new ToolRegistry()
129
+ tools.register(noop)
130
+
131
+ const run = await drainQuery({
132
+ provider: new ClosingTurnProvider(),
133
+ tools,
134
+ completionInbox: inbox,
135
+ agentId: 'agent_test',
136
+ agentName: 'Test Agent',
137
+ messages: [createUserMessage('go')],
138
+ workingDirectory,
139
+ runConfig: {
140
+ model: 'mock-model',
141
+ timeoutMs: 20_000,
142
+ tokenBudget: 100_000,
143
+ maxIterations: 1,
144
+ maxResponseTokens: 256,
145
+ },
146
+ sessionId: 'ses_answer' as SessionId,
147
+ threadId: 'thd_answer' as ThreadId,
148
+ projectId: 'prj_answer' as ProjectId,
149
+ tenantId: 'tnt_answer' as TenantId,
150
+ } as never)
151
+
152
+ // Both halves, because either one alone is satisfied by a broken fix:
153
+ // dropping the delivery keeps the answer, and dropping the answer fix
154
+ // keeps the notification.
155
+ expect(run.result, 'the notification buried the run answer').toBe(ANSWER)
156
+ expect(
157
+ (run.messages as { content: unknown }[]).some(
158
+ (m) => typeof m.content === 'string' && m.content.includes('THE WORKER RESULT'),
159
+ ),
160
+ 'the worker result was not delivered',
161
+ ).toBe(true)
162
+ }, 60_000)
163
+ })