@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,4 +1,5 @@
1
1
  import { SpanStatusCode } from '@opentelemetry/api';
2
+ import { resolveContextWindow } from '../../../compaction/context-window.js';
2
3
  import { extractFromAssistantMessage } from '../../../compaction/extractor.js';
3
4
  import { AUTO_CONTINUATION_USER_MESSAGE } from '../../../constants/continuation.js';
4
5
  import { DEFAULT_STRUCTURED_OUTPUT_RETRIES, STRUCTURED_OUTPUT_REPROMPT, } from '../../../constants/tools/index.js';
@@ -8,6 +9,7 @@ import { collect } from '../../../provider/collect.js';
8
9
  import { GENAI, NAMZU, agentIterationSpanName, parentContext, } from '../../../telemetry/attributes.js';
9
10
  import { getTracer } from '../../../telemetry/runtime-accessors.js';
10
11
  import { STRUCTURED_OUTPUT_TOOL_NAME } from '../../../tools/builtins/structuredOutput.js';
12
+ import { DELEGATION_TIMEOUT_MS } from '../../../tools/coordinator/index.js';
11
13
  import { createAssistantMessage, createSystemMessage, createUserMessage, } from '../../../types/message/index.js';
12
14
  import { classifyProviderError } from '../../../types/provider/errors.js';
13
15
  import { toErrorMessage } from '../../../utils/error.js';
@@ -15,7 +17,7 @@ import { generateMessageId } from '../../../utils/id.js';
15
17
  import { applyLifecycleHookResults } from '../plugin-hooks.js';
16
18
  import { runAdvisoryPhase } from './phases/advisory.js';
17
19
  import { runIterationCheckpoint } from './phases/checkpoint.js';
18
- import { relieveOverflow, runCompactionCheck } from './phases/compaction.js';
20
+ import { measureContext, relieveOverflow, runCompactionCheck } from './phases/compaction.js';
19
21
  import { runPlanGate } from './phases/plan.js';
20
22
  import { runToolReview } from './phases/tool-review.js';
21
23
  import { refreshWorkingMemory } from './phases/working-memory.js';
@@ -29,19 +31,82 @@ import { streamProviderTurn } from './stream-turn.js';
29
31
  * on the thing that actually went wrong.
30
32
  */
31
33
  const DEFAULT_ANSWER_REVIEW_LIMIT = 3;
34
+ /**
35
+ * The share of a run's REMAINING time a settle-hold may take.
36
+ *
37
+ * The rule is borrowed from `AGENT_MANAGER_DEFAULTS.maxBudgetFraction`, which
38
+ * gives a spawned child at most half of what its parent has left: one
39
+ * sub-activity may take a share of the remainder, never the remainder. The
40
+ * value is written out here rather than imported, because that field is a
41
+ * host-tunable knob about TOKEN allocation and coupling the two would let a
42
+ * host lowering one silently change the other.
43
+ *
44
+ * Half, specifically, because the hold is not the last thing the run does.
45
+ * Its whole purpose is to put a worker's result where the model can read it,
46
+ * and reading it costs a turn. A hold that spent everything remaining would
47
+ * deliver a notification into a run with no turn left to act on it — the same
48
+ * "the result exists and the model is never told" failure this mechanism was
49
+ * built to close, wearing a different costume.
50
+ */
51
+ const SETTLE_GRACE_FRACTION = 0.5;
32
52
  /**
33
53
  * How long a finishing run waits for a background worker it launched.
34
54
  *
35
- * Long enough to be worth having a delegated worker doing real work takes
36
- * minutes and bounded because a worker that never finishes must not hold
37
- * the run open forever. `maxIterations` bounds how many times this can
38
- * happen, and the run's own timeout bounds the whole thing regardless.
55
+ * Derived from the run rather than fixed, because a constant is wrong in both
56
+ * directions at once. The 120 seconds this replaces held a run configured for
57
+ * a twenty-second timeout open for 120,267 ms six times its own budget, and
58
+ * unreachable by the guard, which only checks between iterations while on an
59
+ * hour-long run it abandoned workers measured at 4m21s, 5m58s and 8m04s, all
60
+ * of them well inside the hour the delegation tools themselves declare.
61
+ *
62
+ * **Bounded by construction, and against the right boundary.** The input is
63
+ * time-to-FINALIZE, not time-to-deadline (see
64
+ * `GuardCoordinator.remainingBeforeFinalizeMs`). Measuring to the deadline was
65
+ * the first attempt and it was wrong in a way that looked safe: a hold cannot
66
+ * outlive the deadline either way, but half of the time-to-deadline started
67
+ * just under the warning threshold ends at 95% of the budget — so the slice
68
+ * that exists for the run to produce a closing answer is half spent waiting
69
+ * for the result that answer was supposed to use. Against the finalize point
70
+ * the hold cannot reach the reserve at all, which is what makes the guard's
71
+ * inability to interrupt a hold a non-issue rather than a smaller issue.
72
+ *
73
+ * **The floor of zero is a decision, not a clamp artefact.** A run with no
74
+ * time left before it must start finishing has no turn in which to read a
75
+ * notification, so waiting could only delay a stop that is already due.
76
+ * Nothing is lost by it: `CompletionInbox.waitForArrival` returns before it
77
+ * looks at its timer when a completion is already in hand, so a zero grace
78
+ * still delivers everything that has arrived. No minimum is invented on top,
79
+ * because zero is exactly what a run past the threshold should wait — and
80
+ * reading the remainder at hold time rather than trusting `forceFinalize`,
81
+ * which is sampled at the top of the iteration, is what makes a long iteration
82
+ * that crossed the line in between compute it.
83
+ *
84
+ * **The ceiling is the longest anything in this subsystem waits for a
85
+ * delegated worker.** It binds only for a host whose run timeout exceeds
86
+ * roughly two and a quarter hours; below that the fraction is smaller.
39
87
  */
40
- const BACKGROUND_TASK_GRACE_MS = 120_000;
88
+ export function settleGraceMs(remainingBeforeFinalizeMs) {
89
+ return Math.min(Math.floor(remainingBeforeFinalizeMs * SETTLE_GRACE_FRACTION), DELEGATION_TIMEOUT_MS);
90
+ }
41
91
  export class IterationOrchestrator {
42
92
  ctx;
43
93
  /** Rejections so far. See {@link DEFAULT_ANSWER_REVIEW_LIMIT}. */
44
94
  answerReviewAttempts = 0;
95
+ /**
96
+ * The previous iteration held a `stopWhen` decision open for a worker.
97
+ *
98
+ * Set when the stop predicate fired and the run took one extra turn to
99
+ * read a delegated result, so the turn that then ends the run can report
100
+ * WHY it is over. Without it the outcome was right and the record was
101
+ * wrong: the run stopped because the host said so and reported `end_turn`,
102
+ * and this repo carries thirteen `StopReason` values precisely so that a
103
+ * run which ends for a nameable reason names it.
104
+ *
105
+ * Lives for exactly one iteration — see the read-and-clear at the top of
106
+ * the loop, which is the only site that touches it besides the one that
107
+ * sets it.
108
+ */
109
+ stopDeferredForOutstandingWork = false;
45
110
  constructor(ctx) {
46
111
  this.ctx = ctx;
47
112
  }
@@ -72,377 +137,382 @@ export class IterationOrchestrator {
72
137
  const planSignal = yield* runPlanGate(this.ctx);
73
138
  if (planSignal === 'stop')
74
139
  return;
75
- while (true) {
76
- const guardResult = this.ctx.guard.beforeIteration(runMgr, this.ctx.abortController.signal);
77
- if (guardResult.shouldStop) {
78
- if (guardResult.isCancelled) {
79
- this.ctx.log.info('Run cancelled by signal', { runId: runMgr.id });
80
- runMgr.setStopReason('cancelled');
81
- runMgr.markCancelled();
82
- break;
83
- }
84
- const stopReason = guardResult.stopReason ?? 'end_turn';
85
- this.ctx.log.info('Guard enforcing stop', {
86
- runId: runMgr.id,
87
- stopReason,
88
- iteration: runMgr.currentIteration,
89
- inputTokens: runMgr.tokenUsage.promptTokens,
90
- outputTokens: runMgr.tokenUsage.completionTokens,
91
- });
92
- await this.requestFinalResponse(model, stopReason);
93
- yield* this.ctx.drainPending();
94
- runMgr.setStopReason(stopReason);
95
- break;
96
- }
97
- const forceFinalize = guardResult.forceFinalize;
98
- const iterationNum = runMgr.incrementIteration();
99
- this.ctx.log.debug('Iteration started', {
100
- runId: runMgr.id,
101
- iteration: iterationNum,
102
- model,
103
- forceFinalize,
104
- messageCount: runMgr.messages.length,
105
- });
106
- const iterationActivity = this.ctx.activityStore.create({
107
- type: 'llm_turn',
108
- description: `LLM iteration ${iterationNum}`,
109
- });
110
- if (iterationActivity) {
111
- this.ctx.activityStore.start(iterationActivity.id);
112
- }
113
- // Parent explicitly: this body is an async generator, so the
114
- // ambient context at resume time belongs to the CONSUMER, not to
115
- // whoever created the run span. Without this every iteration
116
- // emits as its own root and a 20-turn run shows up as 21
117
- // disconnected traces.
118
- const iterSpan = tracer.startSpan(agentIterationSpanName(iterationNum), {}, parentContext(this.ctx.rootSpan));
119
- try {
120
- // Tool spans for this turn belong under this iteration. Inside
121
- // the try rather than before it: a throw from any of these left
122
- // the span open, and an iteration span that never ends is a
123
- // trace that never closes — the export is incomplete for exactly
124
- // the run that failed.
125
- this.ctx.toolExecutor.setParentSpan(iterSpan);
126
- iterSpan.setAttributes({
127
- [NAMZU.ITERATION]: iterationNum,
128
- [NAMZU.RUN_ID]: runMgr.id,
129
- [GENAI.REQUEST_MODEL]: model,
130
- });
131
- await this.ctx.emitEvent({
132
- type: 'iteration_started',
133
- runId: runMgr.id,
134
- iteration: iterationNum,
135
- });
136
- yield* this.ctx.drainPending();
137
- if (this.ctx.pluginManager) {
138
- const hookResults = await this.ctx.pluginManager.executeHooks('iteration_start', { runId: runMgr.id, iteration: iterationNum }, this.ctx.emitEvent);
139
- applyLifecycleHookResults('iteration_start', hookResults);
140
- yield* this.ctx.drainPending();
141
- }
142
- // Re-pin the working-memory block from ground truth at the primacy
143
- // edge BEFORE compaction runs (so the refreshed slot is what
144
- // compaction preserves). No-op when no provider is configured.
145
- await refreshWorkingMemory(this.ctx);
146
- await runCompactionCheck(this.ctx);
147
- // Cache discipline: keep the tools param byte-stable even on the
148
- // forced-final iteration and forbid tool use via tool_choice
149
- // 'none' instead. Dropping the tools array would invalidate the
150
- // entire prompt-cache prefix (tools render at position 0) and
151
- // risks a 400 because the history still carries
152
- // tool_use/tool_result blocks.
153
- // Snapshot the cumulative counters so the step can report ITS
154
- // own usage rather than the run total.
155
- const stepStartedAt = Date.now();
156
- const usageBefore = { ...runMgr.tokenUsage };
157
- const costBefore = { ...runMgr.costInfo };
158
- // Shape this step before calling the model. `stopWhen` decides
159
- // whether to keep going; this decides HOW. No-op when the host
160
- // supplied no hook.
161
- const step = await this.prepareStep(iterationNum);
162
- const stepAllowedTools = step.allowedTools ?? this.ctx.allowedTools;
163
- const llmTools = this.ctx.tools.toLLMTools(stepAllowedTools);
164
- // The same list the request was built from now also bounds what
165
- // may run. Narrowing only the request left the restriction
166
- // presentational — the model was shown fewer tools and could
167
- // still call any of them by name.
168
- this.ctx.toolExecutor.setStepAllowedTools(stepAllowedTools);
169
- const enforceToolInputSchema = enforcedModelInputToolNames(this.ctx.tools, llmTools);
170
- const stepModel = step.model ?? model;
171
- const baseMessages = forceFinalize
172
- ? [
173
- ...runMgr.messages,
174
- createUserMessage('[SYSTEM] You are approaching your resource limits. Provide your final, comprehensive response now based on everything you have gathered so far. Do not request any more tool calls.'),
175
- ]
176
- : runMgr.messages;
177
- // Step guidance is appended to the REQUEST, never pushed onto
178
- // the run's history: it applies to this step only, and pushing
179
- // it would accumulate one stale instruction per iteration.
180
- // Copy before it crosses the provider boundary. `runMgr.messages`
181
- // is the LIVE run array, and the loop pushes onto it after the
182
- // call returns — so a driver that retains what it was handed
183
- // (to log it, cache it, or replay it on retry) watched its own
184
- // input grow new turns underneath it. A capture provider in the
185
- // estate recorded every turn as identical to the last for
186
- // exactly this reason. Shallow is enough: the defect is array
187
- // mutation, and per-iteration this is trivial next to the model
188
- // call it precedes.
189
- // A step's skills and its guidance ride the same ephemeral
190
- // trailing system message. Appending leaves the cached prefix
191
- // intact; rewriting the run's own prompt to carry a phase's
192
- // skills would invalidate it on every iteration.
193
- // `renderSkillsSection` already answers null for an empty list, so
194
- // there is no length check here — a second guard for the same
195
- // case is one more thing to keep in agreement with the first.
196
- const stepSkills = step.skills ? renderSkillsSection([...step.skills]) : null;
197
- const stepPreamble = [step.system, stepSkills].filter(Boolean).join('\n\n');
198
- const messages = stepPreamble
199
- ? [...baseMessages, createSystemMessage(stepPreamble)]
200
- : [...baseMessages];
201
- if (this.ctx.pluginManager) {
202
- const hookResults = await this.ctx.pluginManager.executeHooks('pre_llm_call', {
203
- runId: runMgr.id,
204
- iteration: iterationNum,
205
- // Built inside the guard: a run with no plugins installed
206
- // pays nothing for a projection nobody reads.
207
- request: Object.freeze({
208
- model: stepModel,
209
- // Copied per turn, not handed over live: these are the
210
- // run's own message objects, and a hook writing into
211
- // one would edit the history the run is about to send.
212
- messages: Object.freeze(messages.map((m) => Object.freeze({ ...m }))),
213
- toolNames: Object.freeze(llmTools.map((t) => t.function.name)),
214
- temperature: step.temperature ?? runConfig.temperature,
215
- maxTokens: step.maxResponseTokens ?? runConfig.maxResponseTokens,
216
- }),
217
- }, this.ctx.emitEvent);
218
- applyLifecycleHookResults('pre_llm_call', hookResults);
219
- yield* this.ctx.drainPending();
220
- }
221
- // Phase 4 (ses_001-tool-stream-events): consume the
222
- // streaming response natively, emitting message and
223
- // tool-input lifecycle events as deltas arrive. The
224
- // helper yields RunEvents through drainPending() so SSE
225
- // consumers see live progress; its return value is the
226
- // aggregated `ChatCompletionResponse` for the legacy
227
- // downstream paths (assistantMsg construction, working
228
- // state extraction, telemetry attribute stamping).
229
- const { response, messageId } = yield* streamProviderTurn(this.ctx.provider, {
230
- model: stepModel,
231
- messages,
232
- tools: llmTools.length > 0 ? llmTools : undefined,
233
- ...(enforceToolInputSchema ? { enforceToolInputSchema } : {}),
234
- // The forced-final turn wins: a step that asked to force a
235
- // tool cannot override the loop's own decision to stop
236
- // asking for them. Otherwise the step's choice applies —
237
- // and only to this step, because the next one is prepared
238
- // from scratch.
239
- toolChoice: forceFinalize && llmTools.length > 0
240
- ? 'none'
241
- : llmTools.length > 0
242
- ? step.toolChoice
243
- : undefined,
244
- temperature: step.temperature ?? runConfig.temperature,
245
- maxTokens: step.maxResponseTokens ?? runConfig.maxResponseTokens,
246
- cacheControl: { type: 'auto' },
247
- ...(runConfig.thinking ? { thinking: runConfig.thinking } : {}),
248
- // Thread the run abort into the model call so a Stop tears the
249
- // in-flight turn down (provider passes it to fetch; the consumer
250
- // also races it). Inert when never aborted.
251
- signal: this.ctx.abortController.signal,
252
- }, this.ctx.emitEvent, this.ctx.drainPending, runMgr.id, iterationNum, forceFinalize, this.ctx.log, iterSpan);
253
- // Main-loop turn: also records the prompt size compaction reads.
254
- runMgr.recordTurnUsage(response.usage);
255
- // The turn went through, so the run is not sitting on an
256
- // irreducible prompt any more. Re-arm relief for the next one.
257
- overflowRelieved = false;
258
- if (this.ctx.pluginManager) {
259
- const hookResults = await this.ctx.pluginManager.executeHooks('post_llm_call', {
140
+ // A `finally` rather than a line at each exit, for the reason written
141
+ // beside `iterSpan.end()` below: this loop leaves by eight `break`s,
142
+ // two `return`s and a `throw`, and a rule every future edit has to
143
+ // remember is a rule that gets forgotten — measured, it had been. Only
144
+ // the ordinary final-answer exit consulted the inbox, so a run that
145
+ // ended on a terminal tool, a structured output or the host's
146
+ // `stopWhen` settled over a finished worker's output and threw it away.
147
+ // A `finally` also covers a generator abandoned by its consumer, which
148
+ // no post-loop block reaches.
149
+ try {
150
+ while (true) {
151
+ // Read AND clear, in that order, in this one place.
152
+ //
153
+ // The flag is set by the previous iteration and read by this
154
+ // one, so a clear that ran before the read would wipe it
155
+ // before anything could use it — the obvious spelling of
156
+ // "clear it at the top" is the broken one. Taking the value
157
+ // into a local first gives the flag a lifetime of exactly one
158
+ // iteration, which is the property that makes this cheap: no
159
+ // path has to remember to clear it, because the next iteration
160
+ // does so whether or not anything read it, and there is no
161
+ // path by which a stale deferral can reach a later turn.
162
+ const stopWasDeferredForOutstandingWork = this.stopDeferredForOutstandingWork;
163
+ this.stopDeferredForOutstandingWork = false;
164
+ const guardResult = this.ctx.guard.beforeIteration(runMgr, this.ctx.abortController.signal);
165
+ if (guardResult.shouldStop) {
166
+ if (guardResult.isCancelled) {
167
+ this.ctx.log.info('Run cancelled by signal', { runId: runMgr.id });
168
+ runMgr.setStopReason('cancelled');
169
+ runMgr.markCancelled();
170
+ break;
171
+ }
172
+ const stopReason = guardResult.stopReason ?? 'end_turn';
173
+ this.ctx.log.info('Guard enforcing stop', {
260
174
  runId: runMgr.id,
261
- iteration: iterationNum,
262
- response: Object.freeze({
263
- content: response.message.content,
264
- toolNames: Object.freeze((response.message.toolCalls ?? []).map((c) => c.function.name)),
265
- finishReason: response.finishReason,
266
- usage: Object.freeze({ ...response.usage }),
267
- }),
268
- }, this.ctx.emitEvent);
269
- applyLifecycleHookResults('post_llm_call', hookResults);
175
+ stopReason,
176
+ iteration: runMgr.currentIteration,
177
+ inputTokens: runMgr.tokenUsage.promptTokens,
178
+ outputTokens: runMgr.tokenUsage.completionTokens,
179
+ });
180
+ await this.requestFinalResponse(model, stopReason);
270
181
  yield* this.ctx.drainPending();
182
+ runMgr.setStopReason(stopReason);
183
+ break;
271
184
  }
272
- this.ctx.log.debug('LLM response received', {
185
+ const forceFinalize = guardResult.forceFinalize;
186
+ const iterationNum = runMgr.incrementIteration();
187
+ this.ctx.log.debug('Iteration started', {
273
188
  runId: runMgr.id,
274
189
  iteration: iterationNum,
275
- finishReason: response.finishReason,
276
- hasContent: response.message.content !== null && response.message.content.length > 0,
277
- toolCallCount: response.message.toolCalls?.length ?? 0,
278
- promptTokens: response.usage.promptTokens,
279
- completionTokens: response.usage.completionTokens,
280
- totalTokens: runMgr.tokenUsage.totalTokens,
281
- totalCost: runMgr.costInfo.totalCost,
282
- });
283
- await this.ctx.emitEvent({
284
- type: 'token_usage_updated',
285
- runId: runMgr.id,
286
- usage: runMgr.tokenUsage,
287
- cost: runMgr.costInfo,
190
+ model,
191
+ forceFinalize,
192
+ messageCount: runMgr.messages.length,
288
193
  });
289
- // Reasoning rides along with the turn it belongs to, so the
290
- // replay contract holds automatically: trimming or compacting
291
- // the assistant message takes its thinking blocks with it,
292
- // and no separate atomicity rule is needed.
293
- const assistantMsg = createAssistantMessage(response.message.content, forceFinalize ? undefined : response.message.toolCalls, response.message.reasoning,
294
- // Rides with the turn it belongs to, like reasoning does, so
295
- // trimming or compacting the turn takes its evidence with it
296
- // rather than leaving citations pointing at prose that is gone.
297
- response.message.citations);
298
- runMgr.pushMessage(assistantMsg);
299
- if (this.ctx.workingStateManager && this.ctx.compactionConfig && assistantMsg.content) {
300
- extractFromAssistantMessage(this.ctx.workingStateManager, assistantMsg.content, this.ctx.compactionConfig);
301
- }
302
- yield* this.ctx.drainPending();
303
- iterSpan.setAttributes({
304
- [GENAI.USAGE_INPUT_TOKENS]: response.usage.promptTokens,
305
- [GENAI.USAGE_OUTPUT_TOKENS]: response.usage.completionTokens,
194
+ const iterationActivity = this.ctx.activityStore.create({
195
+ type: 'llm_turn',
196
+ description: `LLM iteration ${iterationNum}`,
306
197
  });
307
- iterSpan.setStatus({ code: SpanStatusCode.OK });
308
198
  if (iterationActivity) {
309
- this.ctx.activityStore.complete(iterationActivity.id, {
310
- content: response.message.content,
311
- hasToolCalls: forceFinalize ? false : !!response.message.toolCalls?.length,
312
- });
199
+ this.ctx.activityStore.start(iterationActivity.id);
313
200
  }
314
- // Tool calls beat the finish reason. The reason is the
315
- // provider's SUMMARY of the turn and the tool calls are the
316
- // turn itself, so when they disagree the calls are the fact.
317
- // Several function-calling endpoints gateways and local servers
318
- // especially — report `stop` alongside a populated
319
- // `tool_calls`, and three of this repo's drivers pass that
320
- // value through untouched.
321
- //
322
- // Reading `stop` first meant the turn ended with every
323
- // requested call silently skipped, an assistant message
324
- // carrying tool_use blocks that were never answered, and a
325
- // run that settled `end_turn` having done nothing it was
326
- // asked to do. Checking the calls first costs nothing when
327
- // the provider is honest and is the only thing that saves the
328
- // run when it is not.
329
- const hasToolCalls = (response.message.toolCalls?.length ?? 0) > 0;
330
- if (forceFinalize || !hasToolCalls) {
331
- // Every task-dispatch tool (create_task, continue_task, Agent)
332
- // is BLOCKING: the worker's output returns as the dispatching
333
- // tool_use's canonical tool_result, so by the time the model
334
- // ends its turn nothing launched by this run should still be
335
- // in flight. A running task here is an orphan (interrupted
336
- // tool execution, cancel race) with no delivery path back to
337
- // the parent — the <task-notification> producer was removed
338
- // in dc16d58, so waiting on the queue could only ever time
339
- // out. Log the orphans honestly and end the turn normally.
340
- if (!forceFinalize && this.hasRunningAgentTasks()) {
341
- this.ctx.log.warn('LLM ended turn with agent tasks still running — ending run without waiting (orphan tasks have no delivery path)', {
342
- runId: runMgr.id,
343
- iteration: iterationNum,
344
- });
201
+ // Parent explicitly: this body is an async generator, so the
202
+ // ambient context at resume time belongs to the CONSUMER, not to
203
+ // whoever created the run span. Without this every iteration
204
+ // emits as its own root and a 20-turn run shows up as 21
205
+ // disconnected traces.
206
+ const iterSpan = tracer.startSpan(agentIterationSpanName(iterationNum), {}, parentContext(this.ctx.rootSpan));
207
+ try {
208
+ // Tool spans for this turn belong under this iteration. Inside
209
+ // the try rather than before it: a throw from any of these left
210
+ // the span open, and an iteration span that never ends is a
211
+ // trace that never closes the export is incomplete for exactly
212
+ // the run that failed.
213
+ this.ctx.toolExecutor.setParentSpan(iterSpan);
214
+ iterSpan.setAttributes({
215
+ [NAMZU.ITERATION]: iterationNum,
216
+ [NAMZU.RUN_ID]: runMgr.id,
217
+ [GENAI.REQUEST_MODEL]: model,
218
+ });
219
+ await this.ctx.emitEvent({
220
+ type: 'iteration_started',
221
+ runId: runMgr.id,
222
+ iteration: iterationNum,
223
+ });
224
+ yield* this.ctx.drainPending();
225
+ if (this.ctx.pluginManager) {
226
+ const hookResults = await this.ctx.pluginManager.executeHooks('iteration_start', { runId: runMgr.id, iteration: iterationNum }, this.ctx.emitEvent);
227
+ applyLifecycleHookResults('iteration_start', hookResults);
228
+ yield* this.ctx.drainPending();
345
229
  }
346
- const hasContent = response.message.content !== null && response.message.content.length > 0;
347
- // Auto-continuation on `stop_reason: max_tokens`. The
348
- // model hit its per-call output cap mid-text (NOT
349
- // mid-tool-use — that path is handled separately
350
- // below via `inputTruncated`). Push a synthetic
351
- // "continue" user message and let the loop fire
352
- // another turn. The provider receives the partial
353
- // assistant content + the continue prompt and
354
- // resumes from where it left off, mirroring the
355
- // Auto-continuation after an output-ceiling cutoff.
356
- //
357
- // Guards:
358
- // - `hasContent` so we don't loop forever on an
359
- // empty cutoff (a provider occasionally emits
360
- // `stop_reason: max_tokens` with no content
361
- // when an injected pre-fill blocks the model).
362
- // - `!forceFinalize` so the forced-finalize path
363
- // never auto-continues that path is invoked
364
- // specifically to extract a closing summary.
365
- // - max_iterations bounds the loop in any case.
366
- if (!forceFinalize && response.finishReason === 'length' && hasContent) {
367
- this.ctx.log.info('LLM hit max_tokens mid-text — auto-continuing', {
230
+ // Re-pin the working-memory block from ground truth at the primacy
231
+ // edge BEFORE compaction runs (so the refreshed slot is what
232
+ // compaction preserves). No-op when no provider is configured.
233
+ await refreshWorkingMemory(this.ctx);
234
+ await runCompactionCheck(this.ctx);
235
+ // Cache discipline: keep the tools param byte-stable even on the
236
+ // forced-final iteration and forbid tool use via tool_choice
237
+ // 'none' instead. Dropping the tools array would invalidate the
238
+ // entire prompt-cache prefix (tools render at position 0) and
239
+ // risks a 400 because the history still carries
240
+ // tool_use/tool_result blocks.
241
+ // Snapshot the cumulative counters so the step can report ITS
242
+ // own usage rather than the run total.
243
+ const stepStartedAt = Date.now();
244
+ const usageBefore = { ...runMgr.tokenUsage };
245
+ const costBefore = { ...runMgr.costInfo };
246
+ // Shape this step before calling the model. `stopWhen` decides
247
+ // whether to keep going; this decides HOW. No-op when the host
248
+ // supplied no hook.
249
+ const step = await this.prepareStep(iterationNum);
250
+ const stepAllowedTools = step.allowedTools ?? this.ctx.allowedTools;
251
+ const llmTools = this.ctx.tools.toLLMTools(stepAllowedTools);
252
+ // The same list the request was built from now also bounds what
253
+ // may run. Narrowing only the request left the restriction
254
+ // presentational — the model was shown fewer tools and could
255
+ // still call any of them by name.
256
+ this.ctx.toolExecutor.setStepAllowedTools(stepAllowedTools);
257
+ const enforceToolInputSchema = enforcedModelInputToolNames(this.ctx.tools, llmTools);
258
+ const stepModel = step.model ?? model;
259
+ const baseMessages = forceFinalize
260
+ ? [
261
+ ...runMgr.messages,
262
+ createUserMessage('[SYSTEM] You are approaching your resource limits. Provide your final, comprehensive response now based on everything you have gathered so far. Do not request any more tool calls.'),
263
+ ]
264
+ : runMgr.messages;
265
+ // Step guidance is appended to the REQUEST, never pushed onto
266
+ // the run's history: it applies to this step only, and pushing
267
+ // it would accumulate one stale instruction per iteration.
268
+ // Copy before it crosses the provider boundary. `runMgr.messages`
269
+ // is the LIVE run array, and the loop pushes onto it after the
270
+ // call returns — so a driver that retains what it was handed
271
+ // (to log it, cache it, or replay it on retry) watched its own
272
+ // input grow new turns underneath it. A capture provider in the
273
+ // estate recorded every turn as identical to the last for
274
+ // exactly this reason. Shallow is enough: the defect is array
275
+ // mutation, and per-iteration this is trivial next to the model
276
+ // call it precedes.
277
+ // A step's skills and its guidance ride the same ephemeral
278
+ // trailing system message. Appending leaves the cached prefix
279
+ // intact; rewriting the run's own prompt to carry a phase's
280
+ // skills would invalidate it on every iteration.
281
+ // `renderSkillsSection` already answers null for an empty list, so
282
+ // there is no length check here — a second guard for the same
283
+ // case is one more thing to keep in agreement with the first.
284
+ const stepSkills = step.skills ? renderSkillsSection([...step.skills]) : null;
285
+ const stepPreamble = [step.system, stepSkills].filter(Boolean).join('\n\n');
286
+ const messages = stepPreamble
287
+ ? [...baseMessages, createSystemMessage(stepPreamble)]
288
+ : [...baseMessages];
289
+ if (this.ctx.pluginManager) {
290
+ const hookResults = await this.ctx.pluginManager.executeHooks('pre_llm_call', {
368
291
  runId: runMgr.id,
369
292
  iteration: iterationNum,
370
- completionTokens: response.usage.completionTokens,
371
- });
372
- runMgr.pushMessage(createUserMessage(AUTO_CONTINUATION_USER_MESSAGE));
373
- await this.ctx.emitEvent({
374
- type: 'iteration_completed',
375
- runId: runMgr.id,
376
- iteration: iterationNum,
377
- hasToolCalls: false,
378
- });
293
+ // Built inside the guard: a run with no plugins installed
294
+ // pays nothing for a projection nobody reads.
295
+ request: Object.freeze({
296
+ model: stepModel,
297
+ // Copied per turn, not handed over live: these are the
298
+ // run's own message objects, and a hook writing into
299
+ // one would edit the history the run is about to send.
300
+ messages: Object.freeze(messages.map((m) => Object.freeze({ ...m }))),
301
+ toolNames: Object.freeze(llmTools.map((t) => t.function.name)),
302
+ temperature: step.temperature ?? runConfig.temperature,
303
+ maxTokens: step.maxResponseTokens ?? runConfig.maxResponseTokens,
304
+ }),
305
+ }, this.ctx.emitEvent);
306
+ applyLifecycleHookResults('pre_llm_call', hookResults);
379
307
  yield* this.ctx.drainPending();
380
- continue;
381
308
  }
382
- // The model tried to finish in prose while a structured
383
- // output was demanded. Send it back with the schema error
384
- // rather than returning an unusable result — this is the
385
- // re-prompt half, and it is bounded so a model that cannot
386
- // satisfy the schema fails loudly instead of looping.
387
- if (!forceFinalize && this.needsStructuredOutput()) {
388
- const attempt = ++this.structuredOutputAttempts;
389
- const limit = this.structuredOutputRetryLimit();
390
- if (attempt > limit) {
391
- this.ctx.log.warn('Structured output not produced within its retries', {
392
- runId: runMgr.id,
393
- attempts: attempt - 1,
394
- });
395
- runMgr.setStopReason('structured_output_failed');
396
- break;
397
- }
398
- this.ctx.log.info('Re-prompting for structured output', {
399
- runId: runMgr.id,
400
- attempt,
401
- limit,
402
- });
403
- runMgr.pushMessage(createUserMessage(STRUCTURED_OUTPUT_REPROMPT));
404
- await this.ctx.emitEvent({
405
- type: 'iteration_completed',
309
+ // Phase 4 (ses_001-tool-stream-events): consume the
310
+ // streaming response natively, emitting message and
311
+ // tool-input lifecycle events as deltas arrive. The
312
+ // helper yields RunEvents through drainPending() so SSE
313
+ // consumers see live progress; its return value is the
314
+ // aggregated `ChatCompletionResponse` for the legacy
315
+ // downstream paths (assistantMsg construction, working
316
+ // state extraction, telemetry attribute stamping).
317
+ const { response, messageId } = yield* streamProviderTurn(this.ctx.provider, {
318
+ model: stepModel,
319
+ messages,
320
+ tools: llmTools.length > 0 ? llmTools : undefined,
321
+ ...(enforceToolInputSchema ? { enforceToolInputSchema } : {}),
322
+ // The forced-final turn wins: a step that asked to force a
323
+ // tool cannot override the loop's own decision to stop
324
+ // asking for them. Otherwise the step's choice applies —
325
+ // and only to this step, because the next one is prepared
326
+ // from scratch.
327
+ toolChoice: forceFinalize && llmTools.length > 0
328
+ ? 'none'
329
+ : llmTools.length > 0
330
+ ? step.toolChoice
331
+ : undefined,
332
+ temperature: step.temperature ?? runConfig.temperature,
333
+ maxTokens: step.maxResponseTokens ?? runConfig.maxResponseTokens,
334
+ cacheControl: { type: 'auto' },
335
+ ...(runConfig.thinking ? { thinking: runConfig.thinking } : {}),
336
+ ...(runConfig.effort ? { effort: runConfig.effort } : {}),
337
+ // Thread the run abort into the model call so a Stop tears the
338
+ // in-flight turn down (provider passes it to fetch; the consumer
339
+ // also races it). Inert when never aborted.
340
+ signal: this.ctx.abortController.signal,
341
+ }, this.ctx.emitEvent, this.ctx.drainPending, runMgr.id, iterationNum, forceFinalize, this.ctx.log, iterSpan);
342
+ // Main-loop turn: also records the prompt size compaction reads.
343
+ runMgr.recordTurnUsage(response.usage);
344
+ // The turn went through, so the run is not sitting on an
345
+ // irreducible prompt any more. Re-arm relief for the next one.
346
+ overflowRelieved = false;
347
+ if (this.ctx.pluginManager) {
348
+ const hookResults = await this.ctx.pluginManager.executeHooks('post_llm_call', {
406
349
  runId: runMgr.id,
407
350
  iteration: iterationNum,
408
- hasToolCalls: false,
409
- });
351
+ response: Object.freeze({
352
+ content: response.message.content,
353
+ toolNames: Object.freeze((response.message.toolCalls ?? []).map((c) => c.function.name)),
354
+ finishReason: response.finishReason,
355
+ usage: Object.freeze({ ...response.usage }),
356
+ }),
357
+ }, this.ctx.emitEvent);
358
+ applyLifecycleHookResults('post_llm_call', hookResults);
410
359
  yield* this.ctx.drainPending();
411
- continue;
412
360
  }
413
- // Let the host judge the ANSWER and hand back work.
361
+ this.ctx.log.debug('LLM response received', {
362
+ runId: runMgr.id,
363
+ iteration: iterationNum,
364
+ finishReason: response.finishReason,
365
+ hasContent: response.message.content !== null && response.message.content.length > 0,
366
+ toolCallCount: response.message.toolCalls?.length ?? 0,
367
+ promptTokens: response.usage.promptTokens,
368
+ completionTokens: response.usage.completionTokens,
369
+ totalTokens: runMgr.tokenUsage.totalTokens,
370
+ totalCost: runMgr.costInfo.totalCost,
371
+ });
372
+ // The context figures ride with the spend figures because a
373
+ // surface showing one almost always wants the other — and
374
+ // because the two were confusable enough that a host divided
375
+ // cumulative spend by a context window and shipped it. They
376
+ // are measured here rather than left to be derived, since the
377
+ // only correct derivation needs internals a host cannot see.
414
378
  //
415
- // The stop predicate is only consulted after tools ran, so
416
- // there was no seam here at all: the moment the model
417
- // stopped calling tools the run finalized, whatever it had
418
- // produced. Verify-then-fix — run the build, feed the
419
- // failure back, let it try again — meant starting a whole
420
- // new run and re-supplying the context the first one had.
379
+ // Absent when the run has no compaction config: nothing then
380
+ // resolves a window, and inventing one would be the guess this
381
+ // replaces.
382
+ const contextFigures = this.ctx.compactionConfig
383
+ ? (() => {
384
+ const measured = measureContext(this.ctx);
385
+ const window = resolveContextWindow(this.ctx.compactionConfig?.contextWindowTokens, runConfig.model);
386
+ return {
387
+ contextTokens: measured.tokens,
388
+ contextMeasuredBy: measured.source,
389
+ contextWindowTokens: window.tokens,
390
+ windowSource: window.source,
391
+ };
392
+ })()
393
+ : {};
394
+ await this.ctx.emitEvent({
395
+ type: 'token_usage_updated',
396
+ runId: runMgr.id,
397
+ usage: runMgr.tokenUsage,
398
+ cost: runMgr.costInfo,
399
+ ...contextFigures,
400
+ });
401
+ // Reasoning rides along with the turn it belongs to, so the
402
+ // replay contract holds automatically: trimming or compacting
403
+ // the assistant message takes its thinking blocks with it,
404
+ // and no separate atomicity rule is needed.
405
+ const assistantMsg = createAssistantMessage(response.message.content, forceFinalize ? undefined : response.message.toolCalls, response.message.reasoning,
406
+ // Rides with the turn it belongs to, like reasoning does, so
407
+ // trimming or compacting the turn takes its evidence with it
408
+ // rather than leaving citations pointing at prose that is gone.
409
+ response.message.citations);
410
+ runMgr.pushMessage(assistantMsg);
411
+ if (this.ctx.workingStateManager && this.ctx.compactionConfig && assistantMsg.content) {
412
+ extractFromAssistantMessage(this.ctx.workingStateManager, assistantMsg.content, this.ctx.compactionConfig);
413
+ }
414
+ yield* this.ctx.drainPending();
415
+ iterSpan.setAttributes({
416
+ [GENAI.USAGE_INPUT_TOKENS]: response.usage.promptTokens,
417
+ [GENAI.USAGE_OUTPUT_TOKENS]: response.usage.completionTokens,
418
+ });
419
+ iterSpan.setStatus({ code: SpanStatusCode.OK });
420
+ if (iterationActivity) {
421
+ this.ctx.activityStore.complete(iterationActivity.id, {
422
+ content: response.message.content,
423
+ hasToolCalls: forceFinalize ? false : !!response.message.toolCalls?.length,
424
+ });
425
+ }
426
+ // Tool calls beat the finish reason. The reason is the
427
+ // provider's SUMMARY of the turn and the tool calls are the
428
+ // turn itself, so when they disagree the calls are the fact.
429
+ // Several function-calling endpoints — gateways and local servers
430
+ // especially — report `stop` alongside a populated
431
+ // `tool_calls`, and three of this repo's drivers pass that
432
+ // value through untouched.
421
433
  //
422
- // Shaped after the structured-output re-prompt directly
423
- // above, which solves the same problem for one specific
424
- // judge: bounded attempts, feedback as a user message, and
425
- // a loud stop rather than a loop.
426
- if (!forceFinalize && this.ctx.reviewAnswer) {
427
- const review = await this.reviewAnswer(response.message.content ?? '');
428
- if (review && !review.accept) {
429
- const attempt = ++this.answerReviewAttempts;
430
- const limit = this.ctx.maxAnswerReviews ?? DEFAULT_ANSWER_REVIEW_LIMIT;
434
+ // Reading `stop` first meant the turn ended with every
435
+ // requested call silently skipped, an assistant message
436
+ // carrying tool_use blocks that were never answered, and a
437
+ // run that settled `end_turn` having done nothing it was
438
+ // asked to do. Checking the calls first costs nothing when
439
+ // the provider is honest and is the only thing that saves the
440
+ // run when it is not.
441
+ const hasToolCalls = (response.message.toolCalls?.length ?? 0) > 0;
442
+ if (forceFinalize || !hasToolCalls) {
443
+ // Every task-dispatch tool (create_task, continue_task, Agent)
444
+ // is BLOCKING: the worker's output returns as the dispatching
445
+ // tool_use's canonical tool_result, so by the time the model
446
+ // ends its turn nothing launched by this run should still be
447
+ // in flight. A running task here is an orphan (interrupted
448
+ // tool execution, cancel race) with no delivery path back to
449
+ // the parent — the <task-notification> producer was removed
450
+ // in dc16d58, so waiting on the queue could only ever time
451
+ // out. Log the orphans honestly and end the turn normally.
452
+ if (!forceFinalize && this.hasRunningAgentTasks()) {
453
+ this.ctx.log.warn('LLM ended turn with agent tasks still running — ending run without waiting (orphan tasks have no delivery path)', {
454
+ runId: runMgr.id,
455
+ iteration: iterationNum,
456
+ });
457
+ }
458
+ const hasContent = response.message.content !== null && response.message.content.length > 0;
459
+ // Auto-continuation on `stop_reason: max_tokens`. The
460
+ // model hit its per-call output cap mid-text (NOT
461
+ // mid-tool-use — that path is handled separately
462
+ // below via `inputTruncated`). Push a synthetic
463
+ // "continue" user message and let the loop fire
464
+ // another turn. The provider receives the partial
465
+ // assistant content + the continue prompt and
466
+ // resumes from where it left off, mirroring the
467
+ // Auto-continuation after an output-ceiling cutoff.
468
+ //
469
+ // Guards:
470
+ // - `hasContent` so we don't loop forever on an
471
+ // empty cutoff (a provider occasionally emits
472
+ // `stop_reason: max_tokens` with no content
473
+ // when an injected pre-fill blocks the model).
474
+ // - `!forceFinalize` so the forced-finalize path
475
+ // never auto-continues — that path is invoked
476
+ // specifically to extract a closing summary.
477
+ // - max_iterations bounds the loop in any case.
478
+ if (!forceFinalize && response.finishReason === 'length' && hasContent) {
479
+ this.ctx.log.info('LLM hit max_tokens mid-text — auto-continuing', {
480
+ runId: runMgr.id,
481
+ iteration: iterationNum,
482
+ completionTokens: response.usage.completionTokens,
483
+ });
484
+ runMgr.pushMessage(createUserMessage(AUTO_CONTINUATION_USER_MESSAGE));
485
+ await this.ctx.emitEvent({
486
+ type: 'iteration_completed',
487
+ runId: runMgr.id,
488
+ iteration: iterationNum,
489
+ hasToolCalls: false,
490
+ });
491
+ yield* this.ctx.drainPending();
492
+ continue;
493
+ }
494
+ // The model tried to finish in prose while a structured
495
+ // output was demanded. Send it back with the schema error
496
+ // rather than returning an unusable result — this is the
497
+ // re-prompt half, and it is bounded so a model that cannot
498
+ // satisfy the schema fails loudly instead of looping.
499
+ if (!forceFinalize && this.needsStructuredOutput()) {
500
+ const attempt = ++this.structuredOutputAttempts;
501
+ const limit = this.structuredOutputRetryLimit();
431
502
  if (attempt > limit) {
432
- this.ctx.log.warn('Answer rejected more times than the run allows', {
503
+ this.ctx.log.warn('Structured output not produced within its retries', {
433
504
  runId: runMgr.id,
434
505
  attempts: attempt - 1,
435
- limit,
436
506
  });
437
- runMgr.setStopReason('answer_rejected');
507
+ runMgr.setStopReason('structured_output_failed');
438
508
  break;
439
509
  }
440
- this.ctx.log.info('Answer rejected returning it to the model', {
510
+ this.ctx.log.info('Re-prompting for structured output', {
441
511
  runId: runMgr.id,
442
512
  attempt,
443
513
  limit,
444
514
  });
445
- runMgr.pushMessage(createUserMessage(review.feedback));
515
+ runMgr.pushMessage(createUserMessage(STRUCTURED_OUTPUT_REPROMPT));
446
516
  await this.ctx.emitEvent({
447
517
  type: 'iteration_completed',
448
518
  runId: runMgr.id,
@@ -452,249 +522,415 @@ export class IterationOrchestrator {
452
522
  yield* this.ctx.drainPending();
453
523
  continue;
454
524
  }
525
+ // Let the host judge the ANSWER and hand back work.
526
+ //
527
+ // The stop predicate is only consulted after tools ran, so
528
+ // there was no seam here at all: the moment the model
529
+ // stopped calling tools the run finalized, whatever it had
530
+ // produced. Verify-then-fix — run the build, feed the
531
+ // failure back, let it try again — meant starting a whole
532
+ // new run and re-supplying the context the first one had.
533
+ //
534
+ // Shaped after the structured-output re-prompt directly
535
+ // above, which solves the same problem for one specific
536
+ // judge: bounded attempts, feedback as a user message, and
537
+ // a loud stop rather than a loop.
538
+ if (!forceFinalize && this.ctx.reviewAnswer) {
539
+ const review = await this.reviewAnswer(response.message.content ?? '');
540
+ if (review && !review.accept) {
541
+ const attempt = ++this.answerReviewAttempts;
542
+ const limit = this.ctx.maxAnswerReviews ?? DEFAULT_ANSWER_REVIEW_LIMIT;
543
+ if (attempt > limit) {
544
+ this.ctx.log.warn('Answer rejected more times than the run allows', {
545
+ runId: runMgr.id,
546
+ attempts: attempt - 1,
547
+ limit,
548
+ });
549
+ runMgr.setStopReason('answer_rejected');
550
+ break;
551
+ }
552
+ this.ctx.log.info('Answer rejected — returning it to the model', {
553
+ runId: runMgr.id,
554
+ attempt,
555
+ limit,
556
+ });
557
+ runMgr.pushMessage(createUserMessage(review.feedback));
558
+ await this.ctx.emitEvent({
559
+ type: 'iteration_completed',
560
+ runId: runMgr.id,
561
+ iteration: iterationNum,
562
+ hasToolCalls: false,
563
+ });
564
+ yield* this.ctx.drainPending();
565
+ continue;
566
+ }
567
+ }
568
+ // A background worker is still out there, and this turn was
569
+ // about to end the run.
570
+ //
571
+ // Settling here would throw away the very thing the launch
572
+ // existed to produce: the supervisor said "launched", the
573
+ // worker had not finished, and the run closed over it.
574
+ if (!forceFinalize && (yield* this.holdForOutstandingWork(iterationNum, false))) {
575
+ continue;
576
+ }
577
+ if (!hasContent && !forceFinalize) {
578
+ this.ctx.log.warn('Empty completion detected — requesting final summary', {
579
+ iteration: iterationNum,
580
+ finishReason: response.finishReason,
581
+ });
582
+ await this.requestFinalResponse(model, 'end_turn');
583
+ yield* this.ctx.drainPending();
584
+ }
585
+ await this.ctx.emitEvent({
586
+ type: 'iteration_completed',
587
+ runId: runMgr.id,
588
+ iteration: iterationNum,
589
+ hasToolCalls: false,
590
+ });
591
+ yield* this.ctx.drainPending();
592
+ // A Stop that lands AFTER the final turn streamed but before
593
+ // this break must settle the run as cancelled, not end_turn —
594
+ // otherwise the just-produced answer is recorded as a clean
595
+ // completion. Mirrors the between-iteration cancel at :511.
596
+ if (this.ctx.abortController.signal.aborted) {
597
+ runMgr.setStopReason('cancelled');
598
+ runMgr.markCancelled();
599
+ break;
600
+ }
601
+ // The host's stop predicate, if the previous turn deferred it
602
+ // to let the model read a delegated result. That extra turn
603
+ // is prose, and `stopWhen` is consulted only after a tool
604
+ // batch, so the predicate is never asked again — reporting
605
+ // `end_turn` would name the shape of the last message rather
606
+ // than the reason the run is over.
607
+ //
608
+ // Only here. A terminal tool and a captured structured output
609
+ // also settle as `end_turn`, and there the deferred predicate
610
+ // is not why the run ended: those decided the answer
611
+ // themselves.
612
+ runMgr.setStopReason(stopWasDeferredForOutstandingWork ? 'stop_condition' : 'end_turn');
613
+ break;
455
614
  }
456
- // A background worker is still out there, and this turn was
457
- // about to end the run.
458
- //
459
- // Settling here would throw away the very thing the launch
460
- // existed to produce: the supervisor said "launched", the
461
- // worker had not finished, and the run closed over it. So
462
- // the run is held open — bounded by the deadline below and
463
- // by `maxIterations` above, so a worker that never finishes
464
- // cannot keep it open forever — and the completion arrives
465
- // as a notification the next turn reads.
466
- if (!forceFinalize && this.ctx.completionInbox?.hasPendingWork) {
467
- this.ctx.log.info('Holding the run open for a background task', {
615
+ const reviewOutcome = yield* runToolReview(this.ctx, response, iterationNum);
616
+ // The step record is built even for a rejected batch: a run that
617
+ // spent a turn getting its tools refused still spent the tokens,
618
+ // and a caller reconstructing cost per step must see it.
619
+ this.recordStep({
620
+ stepNumber: iterationNum,
621
+ model,
622
+ messageId,
623
+ response,
624
+ toolResults: reviewOutcome.results,
625
+ toolExecutionMs: reviewOutcome.durationMs,
626
+ startedAt: stepStartedAt,
627
+ usageBefore,
628
+ costBefore,
629
+ });
630
+ if (reviewOutcome.decision === 'stop') {
631
+ return;
632
+ }
633
+ if (reviewOutcome.decision === 'rejected') {
634
+ continue;
635
+ }
636
+ // A successful `structured_output` call IS the answer, so the
637
+ // run ends here rather than paying for another turn whose only
638
+ // job would be to restate it.
639
+ if (this.captureStructuredOutput(reviewOutcome.results)) {
640
+ this.ctx.log.info('Structured output produced — ending run', {
468
641
  runId: runMgr.id,
469
642
  iteration: iterationNum,
470
643
  });
471
- await this.ctx.completionInbox.waitForArrival(BACKGROUND_TASK_GRACE_MS);
472
- const arrived = this.ctx.completionInbox.drain();
473
- if (arrived.length > 0) {
474
- runMgr.pushMessage(createUserMessage(formatCompletionNotification(arrived)));
475
- await this.ctx.emitEvent({
476
- type: 'iteration_completed',
477
- runId: runMgr.id,
478
- iteration: iterationNum,
479
- hasToolCalls: false,
480
- });
481
- yield* this.ctx.drainPending();
644
+ runMgr.setStopReason('end_turn');
645
+ await this.ctx.emitEvent({
646
+ type: 'iteration_completed',
647
+ runId: runMgr.id,
648
+ iteration: iterationNum,
649
+ hasToolCalls: true,
650
+ });
651
+ yield* this.ctx.drainPending();
652
+ break;
653
+ }
654
+ // A tool the author declared terminal settles the run with its
655
+ // own output, the same rule `structured_output` has always
656
+ // had. Without it a delegation cost the parent one more model
657
+ // call at full context whose only job was to restate what the
658
+ // worker already said — and to restate it through the parent's
659
+ // compacted view, so the caller did not even receive the
660
+ // worker's words.
661
+ const settled = this.terminalToolOutput(reviewOutcome.results, response);
662
+ if (settled !== undefined) {
663
+ this.ctx.log.info('Terminal tool produced the answer — ending run', {
664
+ runId: runMgr.id,
665
+ iteration: iterationNum,
666
+ tool: settled.toolName,
667
+ });
668
+ runMgr.setResult(settled.output);
669
+ runMgr.setStopReason('end_turn');
670
+ await this.ctx.emitEvent({
671
+ type: 'iteration_completed',
672
+ runId: runMgr.id,
673
+ iteration: iterationNum,
674
+ hasToolCalls: true,
675
+ });
676
+ yield* this.ctx.drainPending();
677
+ break;
678
+ }
679
+ // Evaluated AFTER the tools ran, so a predicate can see what they
680
+ // returned — which is what makes a terminal submit_answer tool
681
+ // usable without discarding its output.
682
+ if (await this.shouldStop()) {
683
+ // Outstanding delegated work outranks the host's stop
684
+ // predicate, exactly once.
685
+ //
686
+ // This is a precedence rule chosen here, not something
687
+ // `stopWhen` implies — a stop predicate is a programmable
688
+ // halt and says nothing about whether the answer is
689
+ // complete, which is what separates it from a terminal
690
+ // tool or a captured structured output. Those decide the
691
+ // result, so no turn follows and a hold would buy nothing.
692
+ // This one only says "stop", and stopping one turn later
693
+ // with the worker's result in hand is a better reading of
694
+ // the host's intent than stopping now and discarding it.
695
+ //
696
+ // Bounded: after the notification is delivered the inbox
697
+ // is drained, so the predicate fires again next turn with
698
+ // nothing pending and the run stops. Exactly one extra
699
+ // turn, and `maxIterations` bounds it regardless.
700
+ if (yield* this.holdForOutstandingWork(iterationNum, true)) {
701
+ // Remember WHY the next turn exists, so the turn that
702
+ // ends the run can name the host's decision instead of
703
+ // reporting the shape of the last message.
704
+ this.stopDeferredForOutstandingWork = true;
482
705
  continue;
483
706
  }
707
+ this.ctx.log.info('Stop condition met', {
708
+ runId: runMgr.id,
709
+ iteration: iterationNum,
710
+ });
711
+ runMgr.setStopReason('stop_condition');
712
+ await this.ctx.emitEvent({
713
+ type: 'iteration_completed',
714
+ runId: runMgr.id,
715
+ iteration: iterationNum,
716
+ hasToolCalls: true,
717
+ });
718
+ yield* this.ctx.drainPending();
719
+ break;
484
720
  }
485
- if (!hasContent && !forceFinalize) {
486
- this.ctx.log.warn('Empty completion detected — requesting final summary', {
721
+ const checkpointSignal = yield* runIterationCheckpoint(this.ctx, iterationNum);
722
+ if (checkpointSignal === 'stop') {
723
+ return;
724
+ }
725
+ // Workers that finished with nobody listening.
726
+ //
727
+ // A completion normally reaches the supervisor as the
728
+ // `tool_result` of the `create_task` that launched it. Two
729
+ // cases have no such call: a launch made in the background on
730
+ // purpose, and a blocking launch whose deadline passed — the
731
+ // model was told "timed out, it may still be running" and the
732
+ // worker then finished, holding a result nothing would read.
733
+ //
734
+ // This is the channel that was removed in `dc16d58` because it
735
+ // double-delivered: it fired for completions the blocking tool
736
+ // had already handed over, so the supervisor saw each result
737
+ // twice. The inbox restores it with the distinction that was
738
+ // missing — a tool that delivers a completion claims it, and
739
+ // only unclaimed ones arrive here.
740
+ //
741
+ // Placed beside the advisory phase deliberately: that is the
742
+ // established seam for putting a user message in after tool
743
+ // results and before the next turn.
744
+ const unheard = this.ctx.completionInbox?.drain() ?? [];
745
+ if (unheard.length > 0) {
746
+ this.ctx.log.info('Delivering unawaited task completions', {
747
+ runId: runMgr.id,
487
748
  iteration: iterationNum,
488
- finishReason: response.finishReason,
749
+ tasks: unheard.map((h) => h.taskId),
489
750
  });
490
- await this.requestFinalResponse(model, 'end_turn');
751
+ runMgr.pushMessage(createUserMessage(formatCompletionNotification(unheard)));
752
+ }
753
+ await runAdvisoryPhase(this.ctx, iterationNum, response);
754
+ if (this.ctx.pluginManager) {
755
+ const hookResults = await this.ctx.pluginManager.executeHooks('iteration_end', { runId: runMgr.id, iteration: iterationNum }, this.ctx.emitEvent);
756
+ applyLifecycleHookResults('iteration_end', hookResults);
491
757
  yield* this.ctx.drainPending();
492
758
  }
493
759
  await this.ctx.emitEvent({
494
760
  type: 'iteration_completed',
495
761
  runId: runMgr.id,
496
762
  iteration: iterationNum,
497
- hasToolCalls: false,
763
+ hasToolCalls: true,
498
764
  });
499
765
  yield* this.ctx.drainPending();
500
- // A Stop that lands AFTER the final turn streamed but before
501
- // this break must settle the run as cancelled, not end_turn —
502
- // otherwise the just-produced answer is recorded as a clean
503
- // completion. Mirrors the between-iteration cancel at :511.
766
+ }
767
+ catch (err) {
768
+ // A Stop that aborted the in-flight turn surfaces here as a
769
+ // thrown abort (the provider stream was raced against the run
770
+ // signal). Settle it as a CANCELLATION — mirroring the
771
+ // between-iteration cancel at the top of the loop — rather than
772
+ // recording it as an SDK failure (error span + failed activity)
773
+ // and re-throwing. The run then returns cleanly with a
774
+ // 'cancelled' stop reason instead of propagating an error.
504
775
  if (this.ctx.abortController.signal.aborted) {
505
776
  runMgr.setStopReason('cancelled');
506
777
  runMgr.markCancelled();
507
778
  break;
508
779
  }
509
- runMgr.setStopReason('end_turn');
510
- break;
511
- }
512
- const reviewOutcome = yield* runToolReview(this.ctx, response, iterationNum);
513
- // The step record is built even for a rejected batch: a run that
514
- // spent a turn getting its tools refused still spent the tokens,
515
- // and a caller reconstructing cost per step must see it.
516
- this.recordStep({
517
- stepNumber: iterationNum,
518
- model,
519
- messageId,
520
- response,
521
- toolResults: reviewOutcome.results,
522
- toolExecutionMs: reviewOutcome.durationMs,
523
- startedAt: stepStartedAt,
524
- usageBefore,
525
- costBefore,
526
- });
527
- if (reviewOutcome.decision === 'stop') {
528
- return;
529
- }
530
- if (reviewOutcome.decision === 'rejected') {
531
- continue;
532
- }
533
- // A successful `structured_output` call IS the answer, so the
534
- // run ends here rather than paying for another turn whose only
535
- // job would be to restate it.
536
- if (this.captureStructuredOutput(reviewOutcome.results)) {
537
- this.ctx.log.info('Structured output produced — ending run', {
538
- runId: runMgr.id,
539
- iteration: iterationNum,
540
- });
541
- runMgr.setStopReason('end_turn');
542
- await this.ctx.emitEvent({
543
- type: 'iteration_completed',
544
- runId: runMgr.id,
545
- iteration: iterationNum,
546
- hasToolCalls: true,
547
- });
548
- yield* this.ctx.drainPending();
549
- break;
550
- }
551
- // A tool the author declared terminal settles the run with its
552
- // own output, the same rule `structured_output` has always
553
- // had. Without it a delegation cost the parent one more model
554
- // call at full context whose only job was to restate what the
555
- // worker already said — and to restate it through the parent's
556
- // compacted view, so the caller did not even receive the
557
- // worker's words.
558
- const settled = this.terminalToolOutput(reviewOutcome.results, response);
559
- if (settled !== undefined) {
560
- this.ctx.log.info('Terminal tool produced the answer — ending run', {
561
- runId: runMgr.id,
562
- iteration: iterationNum,
563
- tool: settled.toolName,
564
- });
565
- runMgr.setResult(settled.output);
566
- runMgr.setStopReason('end_turn');
567
- await this.ctx.emitEvent({
568
- type: 'iteration_completed',
569
- runId: runMgr.id,
570
- iteration: iterationNum,
571
- hasToolCalls: true,
572
- });
573
- yield* this.ctx.drainPending();
574
- break;
575
- }
576
- // Evaluated AFTER the tools ran, so a predicate can see what they
577
- // returned — which is what makes a terminal submit_answer tool
578
- // usable without discarding its output.
579
- if (await this.shouldStop()) {
580
- this.ctx.log.info('Stop condition met', {
581
- runId: runMgr.id,
582
- iteration: iterationNum,
583
- });
584
- runMgr.setStopReason('stop_condition');
585
- await this.ctx.emitEvent({
586
- type: 'iteration_completed',
587
- runId: runMgr.id,
588
- iteration: iterationNum,
589
- hasToolCalls: true,
590
- });
591
- yield* this.ctx.drainPending();
592
- break;
593
- }
594
- const checkpointSignal = yield* runIterationCheckpoint(this.ctx, iterationNum);
595
- if (checkpointSignal === 'stop') {
596
- return;
597
- }
598
- // Workers that finished with nobody listening.
599
- //
600
- // A completion normally reaches the supervisor as the
601
- // `tool_result` of the `create_task` that launched it. Two
602
- // cases have no such call: a launch made in the background on
603
- // purpose, and a blocking launch whose deadline passed — the
604
- // model was told "timed out, it may still be running" and the
605
- // worker then finished, holding a result nothing would read.
606
- //
607
- // This is the channel that was removed in `dc16d58` because it
608
- // double-delivered: it fired for completions the blocking tool
609
- // had already handed over, so the supervisor saw each result
610
- // twice. The inbox restores it with the distinction that was
611
- // missing — a tool that delivers a completion claims it, and
612
- // only unclaimed ones arrive here.
613
- //
614
- // Placed beside the advisory phase deliberately: that is the
615
- // established seam for putting a user message in after tool
616
- // results and before the next turn.
617
- const unheard = this.ctx.completionInbox?.drain() ?? [];
618
- if (unheard.length > 0) {
619
- this.ctx.log.info('Delivering unawaited task completions', {
620
- runId: runMgr.id,
621
- iteration: iterationNum,
622
- tasks: unheard.map((h) => h.taskId),
623
- });
624
- runMgr.pushMessage(createUserMessage(formatCompletionNotification(unheard)));
625
- }
626
- await runAdvisoryPhase(this.ctx, iterationNum, response);
627
- if (this.ctx.pluginManager) {
628
- const hookResults = await this.ctx.pluginManager.executeHooks('iteration_end', { runId: runMgr.id, iteration: iterationNum }, this.ctx.emitEvent);
629
- applyLifecycleHookResults('iteration_end', hookResults);
630
- yield* this.ctx.drainPending();
631
- }
632
- await this.ctx.emitEvent({
633
- type: 'iteration_completed',
634
- runId: runMgr.id,
635
- iteration: iterationNum,
636
- hasToolCalls: true,
637
- });
638
- yield* this.ctx.drainPending();
639
- }
640
- catch (err) {
641
- // A Stop that aborted the in-flight turn surfaces here as a
642
- // thrown abort (the provider stream was raced against the run
643
- // signal). Settle it as a CANCELLATION — mirroring the
644
- // between-iteration cancel at the top of the loop — rather than
645
- // recording it as an SDK failure (error span + failed activity)
646
- // and re-throwing. The run then returns cleanly with a
647
- // 'cancelled' stop reason instead of propagating an error.
648
- if (this.ctx.abortController.signal.aborted) {
649
- runMgr.setStopReason('cancelled');
650
- runMgr.markCancelled();
651
- break;
652
- }
653
- // The one provider failure the kernel can actually do something
654
- // about. `context_length_exceeded` is correctly non-retryable —
655
- // resending the identical prompt cannot help — but the kernel
656
- // owns a compaction subsystem that can make the prompt smaller.
657
- // Without this the run died holding the remedy: the threshold
658
- // path had simply guessed low, which a run carrying images or a
659
- // language the chars-per-token ratio does not fit will do.
660
- //
661
- // Relief is attempted ONCE per iteration and only when it
662
- // actually shed something. A second overflow after a successful
663
- // compaction means the prompt is irreducible, and looping on it
664
- // would burn the budget to arrive at the same error.
665
- if (!overflowRelieved &&
666
- classifyProviderError(err, this.ctx.provider.id).code === 'context_length_exceeded') {
667
- overflowRelieved = true;
668
- const shed = await relieveOverflow(this.ctx);
669
- if (shed) {
670
- this.ctx.log.info('Retrying the turn after relieving a context overflow', {
671
- runId: runMgr.id,
672
- iteration: iterationNum,
673
- });
674
- if (iterationActivity) {
675
- this.ctx.activityStore.complete(iterationActivity.id);
780
+ // The one provider failure the kernel can actually do something
781
+ // about. `context_length_exceeded` is correctly non-retryable —
782
+ // resending the identical prompt cannot help — but the kernel
783
+ // owns a compaction subsystem that can make the prompt smaller.
784
+ // Without this the run died holding the remedy: the threshold
785
+ // path had simply guessed low, which a run carrying images or a
786
+ // language the chars-per-token ratio does not fit will do.
787
+ //
788
+ // Relief is attempted ONCE per iteration and only when it
789
+ // actually shed something. A second overflow after a successful
790
+ // compaction means the prompt is irreducible, and looping on it
791
+ // would burn the budget to arrive at the same error.
792
+ if (!overflowRelieved &&
793
+ classifyProviderError(err, this.ctx.provider.id).code === 'context_length_exceeded') {
794
+ overflowRelieved = true;
795
+ const shed = await relieveOverflow(this.ctx);
796
+ if (shed) {
797
+ this.ctx.log.info('Retrying the turn after relieving a context overflow', {
798
+ runId: runMgr.id,
799
+ iteration: iterationNum,
800
+ });
801
+ if (iterationActivity) {
802
+ this.ctx.activityStore.complete(iterationActivity.id);
803
+ }
804
+ continue;
676
805
  }
677
- continue;
678
806
  }
807
+ if (iterationActivity) {
808
+ this.ctx.activityStore.fail(iterationActivity.id, toErrorMessage(err));
809
+ }
810
+ iterSpan.setStatus({
811
+ code: SpanStatusCode.ERROR,
812
+ message: toErrorMessage(err),
813
+ });
814
+ iterSpan.recordException(err instanceof Error ? err : new Error(String(err)));
815
+ throw err;
679
816
  }
680
- if (iterationActivity) {
681
- this.ctx.activityStore.fail(iterationActivity.id, toErrorMessage(err));
817
+ finally {
818
+ // The only place the iteration span ends. It used to be ended at each of
819
+ // seventeen exits, which is a rule every future edit has to
820
+ // remember; a generator abandoned by its consumer never reached
821
+ // any of them.
822
+ iterSpan.end();
682
823
  }
683
- iterSpan.setStatus({
684
- code: SpanStatusCode.ERROR,
685
- message: toErrorMessage(err),
686
- });
687
- iterSpan.recordException(err instanceof Error ? err : new Error(String(err)));
688
- throw err;
689
- }
690
- finally {
691
- // The only place the iteration span ends. It used to be ended at each of
692
- // seventeen exits, which is a rule every future edit has to
693
- // remember; a generator abandoned by its consumer never reached
694
- // any of them.
695
- iterSpan.end();
696
824
  }
697
825
  }
826
+ finally {
827
+ this.settleOutstandingWork();
828
+ }
829
+ }
830
+ /**
831
+ * Hold the run open for a worker that has not finished, and deliver it.
832
+ *
833
+ * Returns whether a completion arrived and was put in the transcript — the
834
+ * caller continues the loop on `true`, so the model gets a turn in which to
835
+ * USE the result. That turn is the entire justification for waiting, which
836
+ * is why only the exits that can still take one call this.
837
+ *
838
+ * Bounded by `settleGraceMs` and by `maxIterations`, so a worker that never
839
+ * finishes cannot keep the run open.
840
+ */
841
+ async *holdForOutstandingWork(iterationNum, hasToolCalls) {
842
+ if (!this.ctx.completionInbox?.hasPendingWork)
843
+ return false;
844
+ // Read HERE rather than from `forceFinalize`, which was sampled at the
845
+ // top of the iteration: one that has since crossed the finalize point
846
+ // must not open a wait against a reserve it has already entered.
847
+ const graceMs = settleGraceMs(this.ctx.guard.remainingBeforeFinalizeMs());
848
+ this.ctx.log.info('Holding the run open for a background task', {
849
+ runId: this.ctx.runMgr.id,
850
+ iteration: iterationNum,
851
+ graceMs,
852
+ });
853
+ await this.ctx.completionInbox.waitForArrival(graceMs);
854
+ const arrived = this.ctx.completionInbox.drain();
855
+ if (arrived.length === 0)
856
+ return false;
857
+ this.ctx.runMgr.pushMessage(createUserMessage(formatCompletionNotification(arrived)));
858
+ await this.ctx.emitEvent({
859
+ type: 'iteration_completed',
860
+ runId: this.ctx.runMgr.id,
861
+ iteration: iterationNum,
862
+ hasToolCalls,
863
+ });
864
+ yield* this.ctx.drainPending();
865
+ return true;
866
+ }
867
+ /**
868
+ * Account for delegated work on the way out: deliver what arrived, and say
869
+ * what did not.
870
+ *
871
+ * A run that ends with a worker outstanding must not leave the impression
872
+ * that the worker's result was delivered. There are exactly two honest
873
+ * outcomes and this does both:
874
+ *
875
+ * - **What has already arrived is delivered.** It makes no false claim,
876
+ * and dropping it is pure loss — the message rides out on
877
+ * `Run.messages`, so a host reads it and the next turn of a continued
878
+ * thread starts with it. This does NOT wait: a hold buys the model a
879
+ * turn in which to USE a result, and on an exit whose answer is already
880
+ * decided there is no such turn, so waiting would delay a settled answer
881
+ * to append text this run will not read. The bounded hold stays where it
882
+ * was, on the exits that do have a turn left.
883
+ * - **What is still running is NAMED, not cancelled.** Giving up on a wait
884
+ * is a statement about the waiter, not about the work — the rule
885
+ * `wait-with-idle-bound.ts` already states for the same subsystem — and
886
+ * "the parent answered early" is a weaker warrant for killing a child
887
+ * than "the clock ran out", not a stronger one. Killing a worker that
888
+ * may be mid-write is a policy only the host can judge, and it has
889
+ * `cancel_task` and the run controller to judge it with.
890
+ */
891
+ settleOutstandingWork() {
892
+ this.deliverArrivedCompletions();
893
+ this.recordAbandonedWork();
894
+ }
895
+ /** Delegated work this run walked away from. See {@link settleOutstandingWork}. */
896
+ recordAbandonedWork() {
897
+ const abandoned = this.ctx.completionInbox?.outstandingTaskIds ?? [];
898
+ if (abandoned.length === 0)
899
+ return;
900
+ this.ctx.log.warn('Run ended with delegated work still running', {
901
+ runId: this.ctx.runMgr.id,
902
+ tasks: abandoned,
903
+ });
904
+ this.ctx.runMgr.setAbandonedTaskIds(abandoned);
905
+ }
906
+ deliverArrivedCompletions() {
907
+ const unheard = this.ctx.completionInbox?.drain() ?? [];
908
+ if (unheard.length === 0)
909
+ return;
910
+ // Fix the run's answer BEFORE appending anything after it.
911
+ //
912
+ // `RunPersistence.resolveResult` walks the message tail backwards and
913
+ // stops at the first non-assistant message, and it runs at
914
+ // `markCompleted` — which is AFTER this. So a notification appended
915
+ // after the final assistant turn makes the run's own answer
916
+ // unreachable. Measured, on a run whose model had just said "THIS IS
917
+ // THE RUN ANSWER.": `run.result` came back `undefined`. That trades a
918
+ // lost worker result for a lost RUN result, which is strictly worse
919
+ // than the defect this delivery exists to fix.
920
+ //
921
+ // Materialising resolves it while the tail is still the assistant's;
922
+ // pinning it means the later re-resolution cannot undo the fix. Only
923
+ // when there is something to pin: on the cancelled and thrown paths
924
+ // there may be no answer, and pinning an empty string there would
925
+ // suppress whatever the error path assembles.
926
+ const answer = this.ctx.runMgr.materializeResult();
927
+ if (answer.length > 0)
928
+ this.ctx.runMgr.setResult(answer);
929
+ this.ctx.log.info('Delivering task completions the run would have settled over', {
930
+ runId: this.ctx.runMgr.id,
931
+ tasks: unheard.map((h) => h.taskId),
932
+ });
933
+ this.ctx.runMgr.pushMessage(createUserMessage(formatCompletionNotification(unheard)));
698
934
  }
699
935
  /**
700
936
  * Ask the host how to shape this step.
@@ -738,15 +974,28 @@ export class IterationOrchestrator {
738
974
  }
739
975
  const prepared = {};
740
976
  if (result.activeTools) {
741
- // A phase list that outlives a tool rename should narrow the
742
- // surface, not kill the agent mid-run.
743
977
  const known = result.activeTools.filter((name) => this.ctx.tools.has(name));
744
978
  const unknown = result.activeTools.filter((name) => !this.ctx.tools.has(name));
745
979
  if (unknown.length > 0) {
746
- this.ctx.log.warn('prepareStep named tools that are not registered ignoring them', {
980
+ // The all-unknown case gets its own sentence because it has its
981
+ // own consequence. Some names dropped narrows the step; ALL of
982
+ // them dropped leaves it able to call nothing — which is the
983
+ // honest reading of "only these tools" when none of them exist,
984
+ // and is not what a reader of "ignoring them" would expect.
985
+ //
986
+ // Widening back to the run's list would be worse: it grants
987
+ // exactly the tools the caller asked to exclude, on the grounds
988
+ // that their own list failed. A step that can call nothing is
989
+ // constrained; a step that can call everything is a control
990
+ // that stopped applying.
991
+ const message = known.length === 0
992
+ ? 'prepareStep named only tools that are not registered — this step can call nothing'
993
+ : 'prepareStep named tools that are not registered — ignoring them';
994
+ this.ctx.log.warn(message, {
747
995
  runId: this.ctx.runMgr.id,
748
996
  stepNumber,
749
997
  unknown,
998
+ remaining: known.length,
750
999
  });
751
1000
  }
752
1001
  prepared.allowedTools = known;
@@ -977,6 +1226,10 @@ export class IterationOrchestrator {
977
1226
  maxTokens: this.ctx.runConfig.maxResponseTokens,
978
1227
  cacheControl: { type: 'auto' },
979
1228
  ...(this.ctx.runConfig.thinking ? { thinking: this.ctx.runConfig.thinking } : {}),
1229
+ // This turn is a hand-maintained duplicate of the one above, which
1230
+ // is exactly the shape a field goes missing from — so it is tested
1231
+ // separately rather than assumed to have been kept in step.
1232
+ ...(this.ctx.runConfig.effort ? { effort: this.ctx.runConfig.effort } : {}),
980
1233
  // Cancellable too: a Stop during the closing summary must not
981
1234
  // stream to completion.
982
1235
  signal: this.ctx.abortController.signal,