@namzu/sdk 1.1.0 → 1.3.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 (153) hide show
  1. package/CHANGELOG.md +98 -0
  2. package/dist/bridge/a2a/mapper.d.ts.map +1 -1
  3. package/dist/bridge/a2a/mapper.js +2 -0
  4. package/dist/bridge/a2a/mapper.js.map +1 -1
  5. package/dist/bridge/sse/mapper.d.ts.map +1 -1
  6. package/dist/bridge/sse/mapper.js +4 -0
  7. package/dist/bridge/sse/mapper.js.map +1 -1
  8. package/dist/{runtime/query → constants}/continuation.d.ts +5 -0
  9. package/dist/constants/continuation.d.ts.map +1 -0
  10. package/dist/{runtime/query → constants}/continuation.js +5 -0
  11. package/dist/constants/continuation.js.map +1 -0
  12. package/dist/constants/index.d.ts +1 -0
  13. package/dist/constants/index.d.ts.map +1 -1
  14. package/dist/constants/index.js +1 -0
  15. package/dist/constants/index.js.map +1 -1
  16. package/dist/manager/run/persistence.d.ts +11 -0
  17. package/dist/manager/run/persistence.d.ts.map +1 -1
  18. package/dist/manager/run/persistence.js +30 -1
  19. package/dist/manager/run/persistence.js.map +1 -1
  20. package/dist/provider/__tests__/registry.test.d.ts +5 -0
  21. package/dist/provider/__tests__/registry.test.d.ts.map +1 -1
  22. package/dist/provider/__tests__/registry.test.js +186 -1
  23. package/dist/provider/__tests__/registry.test.js.map +1 -1
  24. package/dist/provider/capabilities.d.ts +27 -0
  25. package/dist/provider/capabilities.d.ts.map +1 -0
  26. package/dist/provider/capabilities.js +29 -0
  27. package/dist/provider/capabilities.js.map +1 -0
  28. package/dist/provider/index.d.ts +3 -1
  29. package/dist/provider/index.d.ts.map +1 -1
  30. package/dist/provider/index.js +2 -1
  31. package/dist/provider/index.js.map +1 -1
  32. package/dist/provider/registry.d.ts +81 -1
  33. package/dist/provider/registry.d.ts.map +1 -1
  34. package/dist/provider/registry.js +173 -7
  35. package/dist/provider/registry.js.map +1 -1
  36. package/dist/public-runtime.d.ts +3 -2
  37. package/dist/public-runtime.d.ts.map +1 -1
  38. package/dist/public-runtime.js +2 -2
  39. package/dist/public-runtime.js.map +1 -1
  40. package/dist/run/reporter.d.ts.map +1 -1
  41. package/dist/run/reporter.js +8 -0
  42. package/dist/run/reporter.js.map +1 -1
  43. package/dist/runtime/query/__tests__/capability-negotiation.test.d.ts +2 -0
  44. package/dist/runtime/query/__tests__/capability-negotiation.test.d.ts.map +1 -0
  45. package/dist/runtime/query/__tests__/capability-negotiation.test.js +184 -0
  46. package/dist/runtime/query/__tests__/capability-negotiation.test.js.map +1 -0
  47. package/dist/runtime/query/__tests__/checkpoint-store.test.d.ts +2 -0
  48. package/dist/runtime/query/__tests__/checkpoint-store.test.d.ts.map +1 -0
  49. package/dist/runtime/query/__tests__/checkpoint-store.test.js +259 -0
  50. package/dist/runtime/query/__tests__/checkpoint-store.test.js.map +1 -0
  51. package/dist/runtime/query/__tests__/checkpoint.test.js +9 -3
  52. package/dist/runtime/query/__tests__/checkpoint.test.js.map +1 -1
  53. package/dist/runtime/query/__tests__/orphan-task-turn-end.test.d.ts +2 -0
  54. package/dist/runtime/query/__tests__/orphan-task-turn-end.test.d.ts.map +1 -0
  55. package/dist/runtime/query/__tests__/orphan-task-turn-end.test.js +110 -0
  56. package/dist/runtime/query/__tests__/orphan-task-turn-end.test.js.map +1 -0
  57. package/dist/runtime/query/checkpoint.d.ts +16 -2
  58. package/dist/runtime/query/checkpoint.d.ts.map +1 -1
  59. package/dist/runtime/query/checkpoint.js +21 -7
  60. package/dist/runtime/query/checkpoint.js.map +1 -1
  61. package/dist/runtime/query/context.d.ts +7 -0
  62. package/dist/runtime/query/context.d.ts.map +1 -1
  63. package/dist/runtime/query/context.js +1 -0
  64. package/dist/runtime/query/context.js.map +1 -1
  65. package/dist/runtime/query/index.d.ts +20 -0
  66. package/dist/runtime/query/index.d.ts.map +1 -1
  67. package/dist/runtime/query/index.js +75 -6
  68. package/dist/runtime/query/index.js.map +1 -1
  69. package/dist/runtime/query/iteration/index.d.ts +3 -57
  70. package/dist/runtime/query/iteration/index.d.ts.map +1 -1
  71. package/dist/runtime/query/iteration/index.js +246 -735
  72. package/dist/runtime/query/iteration/index.js.map +1 -1
  73. package/dist/runtime/query/iteration/phases/checkpoint.d.ts.map +1 -1
  74. package/dist/runtime/query/iteration/phases/checkpoint.js +22 -0
  75. package/dist/runtime/query/iteration/phases/checkpoint.js.map +1 -1
  76. package/dist/runtime/query/iteration/phases/context.d.ts +1 -2
  77. package/dist/runtime/query/iteration/phases/context.d.ts.map +1 -1
  78. package/dist/runtime/query/iteration/phases/context.js.map +1 -1
  79. package/dist/runtime/query/iteration/stream-turn.d.ts +35 -0
  80. package/dist/runtime/query/iteration/stream-turn.d.ts.map +1 -0
  81. package/dist/runtime/query/iteration/stream-turn.js +341 -0
  82. package/dist/runtime/query/iteration/stream-turn.js.map +1 -0
  83. package/dist/runtime/query/replay/list.d.ts +17 -1
  84. package/dist/runtime/query/replay/list.d.ts.map +1 -1
  85. package/dist/runtime/query/replay/list.js +21 -4
  86. package/dist/runtime/query/replay/list.js.map +1 -1
  87. package/dist/runtime/query/replay/prepare.d.ts +10 -0
  88. package/dist/runtime/query/replay/prepare.d.ts.map +1 -1
  89. package/dist/runtime/query/replay/prepare.js +20 -4
  90. package/dist/runtime/query/replay/prepare.js.map +1 -1
  91. package/dist/store/index.d.ts +1 -0
  92. package/dist/store/index.d.ts.map +1 -1
  93. package/dist/store/index.js +1 -0
  94. package/dist/store/index.js.map +1 -1
  95. package/dist/store/run/checkpoint-disk.d.ts +28 -0
  96. package/dist/store/run/checkpoint-disk.d.ts.map +1 -0
  97. package/dist/store/run/checkpoint-disk.js +55 -0
  98. package/dist/store/run/checkpoint-disk.js.map +1 -0
  99. package/dist/types/provider/config.d.ts +51 -0
  100. package/dist/types/provider/config.d.ts.map +1 -1
  101. package/dist/types/provider/index.d.ts +1 -1
  102. package/dist/types/provider/index.d.ts.map +1 -1
  103. package/dist/types/provider/interface.d.ts +10 -0
  104. package/dist/types/provider/interface.d.ts.map +1 -1
  105. package/dist/types/run/checkpoint-store.d.ts +64 -0
  106. package/dist/types/run/checkpoint-store.d.ts.map +1 -0
  107. package/dist/types/run/checkpoint-store.js +12 -0
  108. package/dist/types/run/checkpoint-store.js.map +1 -0
  109. package/dist/types/run/config.d.ts +27 -0
  110. package/dist/types/run/config.d.ts.map +1 -1
  111. package/dist/types/run/events.d.ts +6 -0
  112. package/dist/types/run/events.d.ts.map +1 -1
  113. package/dist/types/run/events.js.map +1 -1
  114. package/dist/types/run/index.d.ts +1 -0
  115. package/dist/types/run/index.d.ts.map +1 -1
  116. package/dist/types/run/index.js +1 -0
  117. package/dist/types/run/index.js.map +1 -1
  118. package/package.json +1 -1
  119. package/src/bridge/a2a/mapper.ts +3 -0
  120. package/src/bridge/sse/mapper.ts +5 -0
  121. package/src/{runtime/query → constants}/continuation.ts +5 -0
  122. package/src/constants/index.ts +1 -0
  123. package/src/manager/run/persistence.ts +34 -1
  124. package/src/provider/__tests__/registry.test.ts +266 -1
  125. package/src/provider/capabilities.ts +47 -0
  126. package/src/provider/index.ts +12 -1
  127. package/src/provider/registry.ts +202 -7
  128. package/src/public-runtime.ts +6 -0
  129. package/src/run/reporter.ts +9 -0
  130. package/src/runtime/query/__tests__/capability-negotiation.test.ts +248 -0
  131. package/src/runtime/query/__tests__/checkpoint-store.test.ts +322 -0
  132. package/src/runtime/query/__tests__/checkpoint.test.ts +15 -6
  133. package/src/runtime/query/__tests__/orphan-task-turn-end.test.ts +127 -0
  134. package/src/runtime/query/checkpoint.ts +23 -9
  135. package/src/runtime/query/context.ts +9 -0
  136. package/src/runtime/query/index.ts +115 -28
  137. package/src/runtime/query/iteration/index.ts +298 -905
  138. package/src/runtime/query/iteration/phases/checkpoint.ts +25 -0
  139. package/src/runtime/query/iteration/phases/context.ts +1 -3
  140. package/src/runtime/query/iteration/stream-turn.ts +396 -0
  141. package/src/runtime/query/replay/list.ts +38 -4
  142. package/src/runtime/query/replay/prepare.ts +37 -4
  143. package/src/store/index.ts +1 -0
  144. package/src/store/run/checkpoint-disk.ts +67 -0
  145. package/src/types/provider/config.ts +54 -0
  146. package/src/types/provider/index.ts +3 -0
  147. package/src/types/provider/interface.ts +11 -0
  148. package/src/types/run/checkpoint-store.ts +72 -0
  149. package/src/types/run/config.ts +30 -0
  150. package/src/types/run/events.ts +13 -0
  151. package/src/types/run/index.ts +1 -0
  152. package/dist/runtime/query/continuation.d.ts.map +0 -1
  153. package/dist/runtime/query/continuation.js.map +0 -1
@@ -1,13 +1,12 @@
1
1
  import { SpanStatusCode } from '@opentelemetry/api';
2
2
  import { extractFromAssistantMessage } from '../../../compaction/extractor.js';
3
+ import { AUTO_CONTINUATION_USER_MESSAGE } from '../../../constants/continuation.js';
3
4
  import { collect } from '../../../provider/collect.js';
4
5
  import { GENAI, NAMZU, agentIterationSpanName } from '../../../telemetry/attributes.js';
5
6
  import { getTracer } from '../../../telemetry/runtime-accessors.js';
6
- import { mergeTokenUsage } from '../../../types/common/index.js';
7
7
  import { createAssistantMessage, createUserMessage } from '../../../types/message/index.js';
8
8
  import { toErrorMessage } from '../../../utils/error.js';
9
9
  import { generateMessageId } from '../../../utils/id.js';
10
- import { AUTO_CONTINUATION_USER_MESSAGE } from '../continuation.js';
11
10
  import { applyLifecycleHookResults } from '../plugin-hooks.js';
12
11
  import { runAdvisoryPhase } from './phases/advisory.js';
13
12
  import { runIterationCheckpoint } from './phases/checkpoint.js';
@@ -15,635 +14,205 @@ import { runCompactionCheck } from './phases/compaction.js';
15
14
  import { runPlanGate } from './phases/plan.js';
16
15
  import { runToolReview } from './phases/tool-review.js';
17
16
  import { refreshWorkingMemory } from './phases/working-memory.js';
18
- /**
19
- * Escape the five XML metacharacters so an interpolated value cannot
20
- * break out of a tag. Used for the simple identifier fields in the
21
- * `<task-notification>` envelope (taskId, agentId, status) — values
22
- * here are controlled enums / opaque ids in practice, but escaping
23
- * keeps the envelope robust against any future producer that lets a
24
- * `<` or `&` leak in.
25
- */
26
- function xmlEscape(value) {
27
- return value
28
- .replace(/&/g, '&amp;')
29
- .replace(/</g, '&lt;')
30
- .replace(/>/g, '&gt;')
31
- .replace(/"/g, '&quot;')
32
- .replace(/'/g, '&apos;');
33
- }
34
- /**
35
- * Wrap free-form worker output in a CDATA section. CDATA preserves
36
- * the raw text — code, markdown angle brackets, ampersands — so the
37
- * supervisor sees what the worker actually produced instead of an
38
- * escape-encoded approximation. The only termination CDATA forbids
39
- * is the literal `]]>` sequence; we split-and-rejoin around it to
40
- * keep the section well-formed regardless of payload.
41
- */
42
- function cdataWrap(value) {
43
- return `<![CDATA[${value.replace(/]]>/g, ']]]]><![CDATA[>')}]]>`;
44
- }
45
- /**
46
- * Map a provider's coarse `finishReason` plus the orchestrator's
47
- * `forceFinalize` flag onto the per-message {@link MessageStopReason}
48
- * union the v3 `message_completed` event surfaces.
49
- */
50
- function synthesizeMessageStopReason(finishReason, forceFinalize) {
51
- if (forceFinalize)
52
- return 'forced_finalize';
53
- switch (finishReason) {
54
- case 'tool_calls':
55
- return 'tool_use';
56
- case 'length':
57
- return 'max_tokens';
58
- case 'content_filter':
59
- return 'refusal';
60
- default:
61
- return 'end_turn';
62
- }
63
- }
64
- /**
65
- * Consume a provider's streaming response and emit the v3 RunEvent
66
- * lifecycle natively (message_started → text_delta* + tool_input_*
67
- * → message_completed). Returns the aggregated `ChatCompletionResponse`
68
- * for downstream code that still expects the legacy shape (assistant
69
- * message construction, working-state extraction, telemetry attribute
70
- * stamping).
71
- *
72
- * Per-delta `emitEvent` calls are followed by a `drainPending()`
73
- * yield so SSE consumers see live progress instead of a burst at
74
- * end-of-message. The bus's ephemeral filter (D1) ensures these
75
- * deltas never hit transcript.jsonl.
76
- *
77
- * Edge cases (codex A3, A4, A5):
78
- * - Stream ends without `finishReason` (anthropic-sdk-typescript#842
79
- * dropped message_stop): we still emit `message_completed` from a
80
- * finally-style fall-through path with `stopReason: 'refusal'`.
81
- * - `tool_input_delta` with no `toolUseId` registered yet: we drop
82
- * the fragment and log a warning (proxies seen to misorder events).
83
- * - `chunk.error`: when no tool input is recoverable, we surface as
84
- * a thrown error after emitting the message_completed terminator so
85
- * consumer cards still close. If a tool-use block was already open,
86
- * we instead synthesize a tool call with runtime truncation metadata
87
- * so the executor can return a model-readable retry hint.
88
- */
89
- async function* streamProviderTurn(provider, params, emitEvent, drainPending, runId, iteration, forceFinalize, log) {
90
- const messageId = generateMessageId();
91
- await emitEvent({ type: 'message_started', runId, iteration, messageId });
92
- yield* drainPending();
93
- let id = '';
94
- const model = '';
95
- let textBuf = '';
96
- let finishReason = 'stop';
97
- let usage = {
98
- promptTokens: 0,
99
- completionTokens: 0,
100
- totalTokens: 0,
101
- cachedTokens: 0,
102
- cacheWriteTokens: 0,
103
- };
104
- const toolBuckets = new Map();
105
- let streamError;
106
- const stream = provider.chatStream({ ...params, stream: true });
107
- // Drive the stream manually so each `.next()` can be RACED against the run
108
- // abort: a Stop tears the in-flight model request down (the provider got
109
- // `params.signal`), and we ALSO stop pulling within a tick even if a
110
- // transport buffers or ignores the signal. The abort rejection propagates
111
- // out of this generator so the run loop settles the turn as cancelled.
112
- // `{ once: true }` keeps a multi-iteration run from leaking a listener/turn.
113
- const it = stream[Symbol.asyncIterator]();
114
- const signal = params.signal;
115
- let onAbort;
116
- const aborted = signal
117
- ? new Promise((_resolve, reject) => {
118
- if (signal.aborted) {
119
- reject(signal.reason);
120
- return;
121
- }
122
- onAbort = () => reject(signal.reason);
123
- signal.addEventListener('abort', onAbort, { once: true });
124
- })
125
- : undefined;
126
- try {
127
- for (;;) {
128
- const next = it.next();
129
- // Neutralize the dangling loser so an eventual rejection of the
130
- // un-awaited `next` is never an unhandled rejection.
131
- if (aborted)
132
- next.catch(() => { });
133
- const res = await (aborted ? Promise.race([next, aborted]) : next);
134
- if (res.done)
135
- break;
136
- const chunk = res.value;
137
- if (chunk.error) {
138
- streamError = chunk.error;
139
- break;
140
- }
141
- if (!id && chunk.id)
142
- id = chunk.id;
143
- if (chunk.delta.content) {
144
- textBuf += chunk.delta.content;
145
- await emitEvent({
146
- type: 'text_delta',
147
- runId,
148
- iteration,
149
- messageId,
150
- text: chunk.delta.content,
151
- });
152
- yield* drainPending();
153
- }
154
- for (const tc of chunk.delta.toolCalls ?? []) {
155
- let bucket = toolBuckets.get(tc.index);
156
- if (!bucket) {
157
- bucket = {
158
- id: tc.id ?? '',
159
- name: tc.function?.name ?? '',
160
- argsBuf: '',
161
- started: false,
162
- completed: false,
163
- parsed: null,
164
- inputTruncated: false,
165
- };
166
- toolBuckets.set(tc.index, bucket);
167
- }
168
- if (tc.id && !bucket.id)
169
- bucket.id = tc.id;
170
- if (tc.function?.name && !bucket.name)
171
- bucket.name = tc.function.name;
172
- if (!bucket.started && bucket.id && bucket.name) {
173
- bucket.started = true;
174
- await emitEvent({
175
- type: 'tool_input_started',
176
- runId,
177
- iteration,
178
- messageId,
179
- toolUseId: bucket.id,
180
- toolName: bucket.name,
181
- });
182
- yield* drainPending();
183
- }
184
- const fragment = tc.function?.arguments;
185
- if (fragment) {
186
- if (!bucket.id) {
187
- log.warn('tool_input_delta arrived before tool id was known; dropping fragment', {
188
- runId,
189
- index: tc.index,
190
- length: fragment.length,
191
- });
192
- }
193
- else {
194
- bucket.argsBuf += fragment;
195
- await emitEvent({
196
- type: 'tool_input_delta',
197
- runId,
198
- toolUseId: bucket.id,
199
- partialJson: fragment,
200
- });
201
- yield* drainPending();
202
- }
203
- }
204
- }
205
- if (chunk.delta.toolCallEnd) {
206
- const { index, id: endId } = chunk.delta.toolCallEnd;
207
- const bucket = toolBuckets.get(index);
208
- if (bucket && !bucket.completed) {
209
- bucket.completed = true;
210
- let parsed = {};
211
- try {
212
- parsed = bucket.argsBuf ? JSON.parse(bucket.argsBuf) : {};
213
- }
214
- catch (err) {
215
- bucket.inputTruncated = true;
216
- log.warn('tool input JSON parse failed at content_block_stop', {
217
- runId,
218
- toolUseId: endId,
219
- error: err instanceof Error ? err.message : String(err),
220
- });
221
- }
222
- bucket.parsed = parsed;
223
- await emitEvent({
224
- type: 'tool_input_completed',
225
- runId,
226
- toolUseId: endId,
227
- input: parsed,
228
- ...(bucket.inputTruncated ? { inputTruncated: true } : {}),
229
- });
230
- yield* drainPending();
231
- }
232
- }
233
- if (chunk.finishReason)
234
- finishReason = chunk.finishReason;
235
- // Merge (per-field max), not last-write-wins: a late usage frame that
236
- // omits input/cache tokens must not zero the counts seen earlier in the
237
- // stream, which would under-report this turn's accumulated usage.
238
- if (chunk.usage)
239
- usage = mergeTokenUsage(usage, chunk.usage);
240
- }
241
- }
242
- catch (err) {
243
- // An abort tears the turn down: propagate it so the run loop settles the
244
- // run as cancelled rather than recording a normal (errored) turn. Any
245
- // other stream error is captured into the synthesized response as before.
246
- if (signal?.aborted)
247
- throw err;
248
- streamError = err instanceof Error ? err.message : String(err);
249
- }
250
- finally {
251
- if (onAbort)
252
- signal?.removeEventListener('abort', onAbort);
253
- // Release the underlying connection on every exit (natural end, error,
254
- // or abort). `for await` did this implicitly on natural completion; the
255
- // manual drive must do it explicitly. `.return()` on an already-finished
256
- // provider generator is a no-op.
257
- await it.return?.().catch(() => { });
258
- }
259
- // Flush any tool buckets the provider failed to close (no toolCallEnd
260
- // arrived — defensive against providers that don't yet emit it, and
261
- // the load-bearing path when the provider stream ends with
262
- // `stop_reason: "max_tokens"` mid-`input_json_delta`. In that case
263
- // Anthropic's SSE never sends `content_block_stop` for the open
264
- // tool_use block: the upstream model ran out of completion tokens
265
- // before it could close the JSON literal, so the buffered
266
- // `argsBuf` ends with something like `"content":"…some prefix` —
267
- // not parseable.
268
- //
269
- // Two cases coalesce here:
270
- // 1. The buffer parses cleanly (the provider just forgot to emit
271
- // `content_block_stop` but the args are intact) — keep parsed.
272
- // 2. The buffer is truncated mid-literal — `parsed = {}` is the
273
- // safe fallback so the executor's `JSON.parse(arguments)`
274
- // succeeds and downstream consumers don't crash. The PRICE
275
- // we used to pay was the model getting back a generic
276
- // "<field> is required" Zod error and not realising its
277
- // previous tool call was truncated server-side, so it would
278
- // retry with the SAME long input and hit the same cutoff in
279
- // a loop. Detect the truncation case and mark the tool call
280
- // with runtime metadata; the executor surfaces a specific
281
- // "your tool call was cut off by max_tokens — retry with
282
- // shorter input or split into smaller calls" message that the
283
- // model can act on.
284
- for (const bucket of toolBuckets.values()) {
285
- if (bucket.started && !bucket.completed) {
286
- bucket.completed = true;
287
- let parsed = {};
288
- let truncated = false;
289
- if (bucket.argsBuf) {
290
- try {
291
- parsed = JSON.parse(bucket.argsBuf);
292
- }
293
- catch {
294
- // argsBuf had content but didn't parse — almost
295
- // certainly the max_tokens-mid-literal cutoff. Mark
296
- // the bucket so the executor can return a model-
297
- // readable hint instead of a generic Zod error.
298
- truncated = true;
299
- parsed = {};
300
- }
301
- }
302
- bucket.parsed = parsed;
303
- bucket.inputTruncated = truncated;
304
- if (truncated) {
305
- log.warn('tool input truncated by upstream cutoff (no toolCallEnd, argsBuf unparsable)', {
306
- runId,
307
- toolUseId: bucket.id,
308
- toolName: bucket.name,
309
- bufferLength: bucket.argsBuf.length,
310
- });
311
- }
312
- await emitEvent({
313
- type: 'tool_input_completed',
314
- runId,
315
- toolUseId: bucket.id,
316
- input: parsed,
317
- ...(truncated ? { inputTruncated: true } : {}),
318
- });
319
- yield* drainPending();
320
- }
321
- }
322
- // `arguments` MUST be valid JSON for the executor's `JSON.parse`
323
- // (`runtime/query/executor.ts:executeSingle`) to succeed. We
324
- // always serialise from the bucket's `parsed` object (filled by
325
- // either the `toolCallEnd` branch above or the post-stream flush
326
- // loop) instead of re-emitting `argsBuf`. When the provider
327
- // stream truncated mid-input, `metadata.inputTruncated` carries that
328
- // state; the executor parses cleanly and returns a specific
329
- // model-readable retry hint instead of the generic "Invalid JSON in
330
- // tool arguments" intercept.
331
- const toolCalls = [...toolBuckets.entries()]
332
- .sort(([a], [b]) => a - b)
333
- .map(([, b]) => ({
334
- id: b.id,
335
- type: 'function',
336
- function: {
337
- name: b.name,
338
- arguments: JSON.stringify(b.parsed ?? {}),
339
- },
340
- ...(b.inputTruncated ? { metadata: { inputTruncated: true } } : {}),
341
- }));
342
- const recoveredToolInputFromStreamError = streamError !== undefined && toolCalls.some((tc) => tc.id && tc.function.name);
343
- const effectiveFinishReason = recoveredToolInputFromStreamError ? 'tool_calls' : finishReason;
344
- if (recoveredToolInputFromStreamError) {
345
- log.warn('provider stream failed after tool input; surfacing tool call to executor', {
346
- runId,
347
- iteration,
348
- error: streamError,
349
- toolCallCount: toolCalls.length,
350
- });
351
- }
352
- const stopReason = streamError
353
- ? recoveredToolInputFromStreamError
354
- ? 'tool_use'
355
- : 'refusal'
356
- : synthesizeMessageStopReason(effectiveFinishReason, forceFinalize);
357
- await emitEvent({
358
- type: 'message_completed',
359
- runId,
360
- iteration,
361
- messageId,
362
- stopReason,
363
- usage,
364
- content: textBuf || undefined,
365
- });
366
- yield* drainPending();
367
- if (streamError && !recoveredToolInputFromStreamError) {
368
- throw new Error(`Provider stream error: ${streamError}`);
369
- }
370
- const response = {
371
- id: id || messageId,
372
- model: model || params.model,
373
- message: {
374
- role: 'assistant',
375
- content: textBuf.length > 0 ? textBuf : null,
376
- toolCalls: toolCalls.length > 0 ? toolCalls : undefined,
377
- },
378
- finishReason: effectiveFinishReason,
379
- usage,
380
- };
381
- return { response, messageId };
382
- }
17
+ import { streamProviderTurn } from './stream-turn.js';
383
18
  export class IterationOrchestrator {
384
19
  ctx;
385
- constructor(config, runMgr, toolExecutor, guard, activityStore, emitEvent, drainPending, abortController, log, resumeHandler, checkpointMgr, planManager) {
386
- this.ctx = {
387
- provider: config.provider,
388
- runConfig: config.runConfig,
389
- tools: config.tools,
390
- allowedTools: config.allowedTools,
391
- runMgr,
392
- toolExecutor,
393
- guard,
394
- activityStore,
395
- emitEvent,
396
- drainPending,
397
- abortController,
398
- log,
399
- resumeHandler,
400
- checkpointMgr,
401
- planManager,
402
- taskGateway: config.taskGateway,
403
- taskStore: config.taskStore,
404
- pendingNotifications: [],
405
- launchedTasks: config.launchedTasks ?? new Map(),
406
- compactionConfig: config.compactionConfig,
407
- workingStateManager: config.workingStateManager,
408
- workingMemoryProvider: config.workingMemoryProvider,
409
- advisoryCtx: config.advisoryCtx,
410
- agentBus: config.agentBus,
411
- verificationGate: config.verificationGate,
412
- pluginManager: config.pluginManager,
413
- };
20
+ constructor(ctx) {
21
+ this.ctx = ctx;
414
22
  }
415
23
  async *runLoop() {
416
24
  const { runConfig, runMgr } = this.ctx;
417
25
  const { model } = runConfig;
418
26
  const tracer = getTracer();
419
- // Worker-completion delivery used to fan out through a global
420
- // onTaskCompleted listener that pushed handles onto
421
- // `pendingNotifications`; the iteration loop then drained
422
- // them as <task-notification> envelopes. Both `create_task`
423
- // and the `Agent` tool are now blocking and return their
424
- // worker output as the dispatching tool_use's canonical
425
- // tool_result, so the listener path would only DUPLICATE
426
- // every completion (once as tool_result, once as injected
427
- // envelope user-message). Leaving the binding out closes
428
- // the duplicate notification surface entirely; the dormant
429
- // drain stays as a no-op until a follow-up tears it out.
430
- let unsubscribeTaskListener;
431
- void unsubscribeTaskListener;
432
- try {
433
- const planSignal = yield* runPlanGate(this.ctx);
434
- if (planSignal === 'stop')
435
- return;
436
- while (true) {
437
- const guardResult = this.ctx.guard.beforeIteration(runMgr, this.ctx.abortController.signal);
438
- if (guardResult.shouldStop) {
439
- if (guardResult.isCancelled) {
440
- this.ctx.log.info('Run cancelled by signal', { runId: runMgr.id });
441
- runMgr.setStopReason('cancelled');
442
- runMgr.markCancelled();
443
- break;
444
- }
445
- const stopReason = guardResult.stopReason ?? 'end_turn';
446
- this.ctx.log.info('Guard enforcing stop', {
447
- runId: runMgr.id,
448
- stopReason,
449
- iteration: runMgr.currentIteration,
450
- inputTokens: runMgr.tokenUsage.promptTokens,
451
- outputTokens: runMgr.tokenUsage.completionTokens,
452
- });
453
- await this.requestFinalResponse(model, stopReason);
454
- yield* this.ctx.drainPending();
455
- runMgr.setStopReason(stopReason);
27
+ const planSignal = yield* runPlanGate(this.ctx);
28
+ if (planSignal === 'stop')
29
+ return;
30
+ while (true) {
31
+ const guardResult = this.ctx.guard.beforeIteration(runMgr, this.ctx.abortController.signal);
32
+ if (guardResult.shouldStop) {
33
+ if (guardResult.isCancelled) {
34
+ this.ctx.log.info('Run cancelled by signal', { runId: runMgr.id });
35
+ runMgr.setStopReason('cancelled');
36
+ runMgr.markCancelled();
456
37
  break;
457
38
  }
458
- const forceFinalize = guardResult.forceFinalize;
459
- const iterationNum = runMgr.incrementIteration();
460
- this.ctx.log.debug('Iteration started', {
39
+ const stopReason = guardResult.stopReason ?? 'end_turn';
40
+ this.ctx.log.info('Guard enforcing stop', {
461
41
  runId: runMgr.id,
462
- iteration: iterationNum,
463
- model,
464
- forceFinalize,
465
- messageCount: runMgr.messages.length,
42
+ stopReason,
43
+ iteration: runMgr.currentIteration,
44
+ inputTokens: runMgr.tokenUsage.promptTokens,
45
+ outputTokens: runMgr.tokenUsage.completionTokens,
466
46
  });
467
- const iterationActivity = this.ctx.activityStore.create({
468
- type: 'llm_turn',
469
- description: `LLM iteration ${iterationNum}`,
470
- });
471
- if (iterationActivity) {
472
- this.ctx.activityStore.start(iterationActivity.id);
47
+ await this.requestFinalResponse(model, stopReason);
48
+ yield* this.ctx.drainPending();
49
+ runMgr.setStopReason(stopReason);
50
+ break;
51
+ }
52
+ const forceFinalize = guardResult.forceFinalize;
53
+ const iterationNum = runMgr.incrementIteration();
54
+ this.ctx.log.debug('Iteration started', {
55
+ runId: runMgr.id,
56
+ iteration: iterationNum,
57
+ model,
58
+ forceFinalize,
59
+ messageCount: runMgr.messages.length,
60
+ });
61
+ const iterationActivity = this.ctx.activityStore.create({
62
+ type: 'llm_turn',
63
+ description: `LLM iteration ${iterationNum}`,
64
+ });
65
+ if (iterationActivity) {
66
+ this.ctx.activityStore.start(iterationActivity.id);
67
+ }
68
+ const iterSpan = tracer.startSpan(agentIterationSpanName(iterationNum));
69
+ iterSpan.setAttributes({
70
+ [NAMZU.ITERATION]: iterationNum,
71
+ [NAMZU.RUN_ID]: runMgr.id,
72
+ [GENAI.REQUEST_MODEL]: model,
73
+ });
74
+ await this.ctx.emitEvent({
75
+ type: 'iteration_started',
76
+ runId: runMgr.id,
77
+ iteration: iterationNum,
78
+ });
79
+ yield* this.ctx.drainPending();
80
+ try {
81
+ if (this.ctx.pluginManager) {
82
+ const hookResults = await this.ctx.pluginManager.executeHooks('iteration_start', { runId: runMgr.id, iteration: iterationNum }, this.ctx.emitEvent);
83
+ applyLifecycleHookResults('iteration_start', hookResults);
84
+ yield* this.ctx.drainPending();
473
85
  }
474
- const iterSpan = tracer.startSpan(agentIterationSpanName(iterationNum));
475
- iterSpan.setAttributes({
476
- [NAMZU.ITERATION]: iterationNum,
477
- [NAMZU.RUN_ID]: runMgr.id,
478
- [GENAI.REQUEST_MODEL]: model,
86
+ // Re-pin the working-memory block from ground truth at the primacy
87
+ // edge BEFORE compaction runs (so the refreshed slot is what
88
+ // compaction preserves). No-op when no provider is configured.
89
+ await refreshWorkingMemory(this.ctx);
90
+ await runCompactionCheck(this.ctx);
91
+ // Cache discipline: keep the tools param byte-stable even on the
92
+ // forced-final iteration and forbid tool use via tool_choice
93
+ // 'none' instead. Dropping the tools array would invalidate the
94
+ // entire prompt-cache prefix (tools render at position 0) and
95
+ // risks a 400 because the history still carries
96
+ // tool_use/tool_result blocks.
97
+ const openAITools = this.ctx.tools.toLLMTools(this.ctx.allowedTools);
98
+ const messages = forceFinalize
99
+ ? [
100
+ ...runMgr.messages,
101
+ 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.'),
102
+ ]
103
+ : runMgr.messages;
104
+ if (this.ctx.pluginManager) {
105
+ const hookResults = await this.ctx.pluginManager.executeHooks('pre_llm_call', { runId: runMgr.id, iteration: iterationNum }, this.ctx.emitEvent);
106
+ applyLifecycleHookResults('pre_llm_call', hookResults);
107
+ yield* this.ctx.drainPending();
108
+ }
109
+ // Phase 4 (ses_001-tool-stream-events): consume the
110
+ // streaming response natively, emitting message and
111
+ // tool-input lifecycle events as deltas arrive. The
112
+ // helper yields RunEvents through drainPending() so SSE
113
+ // consumers see live progress; its return value is the
114
+ // aggregated `ChatCompletionResponse` for the legacy
115
+ // downstream paths (assistantMsg construction, working
116
+ // state extraction, telemetry attribute stamping).
117
+ const { response } = yield* streamProviderTurn(this.ctx.provider, {
118
+ model,
119
+ messages,
120
+ tools: openAITools.length > 0 ? openAITools : undefined,
121
+ toolChoice: forceFinalize && openAITools.length > 0 ? 'none' : undefined,
122
+ temperature: runConfig.temperature,
123
+ maxTokens: runConfig.maxResponseTokens,
124
+ cacheControl: { type: 'auto' },
125
+ // Thread the run abort into the model call so a Stop tears the
126
+ // in-flight turn down (provider passes it to fetch; the consumer
127
+ // also races it). Inert when never aborted.
128
+ signal: this.ctx.abortController.signal,
129
+ }, this.ctx.emitEvent, this.ctx.drainPending, runMgr.id, iterationNum, forceFinalize, this.ctx.log);
130
+ runMgr.accumulateUsage(response.usage);
131
+ if (this.ctx.pluginManager) {
132
+ const hookResults = await this.ctx.pluginManager.executeHooks('post_llm_call', { runId: runMgr.id, iteration: iterationNum }, this.ctx.emitEvent);
133
+ applyLifecycleHookResults('post_llm_call', hookResults);
134
+ yield* this.ctx.drainPending();
135
+ }
136
+ this.ctx.log.debug('LLM response received', {
137
+ runId: runMgr.id,
138
+ iteration: iterationNum,
139
+ finishReason: response.finishReason,
140
+ hasContent: response.message.content !== null && response.message.content.length > 0,
141
+ toolCallCount: response.message.toolCalls?.length ?? 0,
142
+ promptTokens: response.usage.promptTokens,
143
+ completionTokens: response.usage.completionTokens,
144
+ totalTokens: runMgr.tokenUsage.totalTokens,
145
+ totalCost: runMgr.costInfo.totalCost,
479
146
  });
480
147
  await this.ctx.emitEvent({
481
- type: 'iteration_started',
148
+ type: 'token_usage_updated',
482
149
  runId: runMgr.id,
483
- iteration: iterationNum,
150
+ usage: runMgr.tokenUsage,
151
+ cost: runMgr.costInfo,
484
152
  });
153
+ const assistantMsg = createAssistantMessage(response.message.content, forceFinalize ? undefined : response.message.toolCalls);
154
+ runMgr.pushMessage(assistantMsg);
155
+ if (this.ctx.workingStateManager && this.ctx.compactionConfig && assistantMsg.content) {
156
+ extractFromAssistantMessage(this.ctx.workingStateManager, assistantMsg.content, this.ctx.compactionConfig);
157
+ }
485
158
  yield* this.ctx.drainPending();
486
- try {
487
- if (this.ctx.pluginManager) {
488
- const hookResults = await this.ctx.pluginManager.executeHooks('iteration_start', { runId: runMgr.id, iteration: iterationNum }, this.ctx.emitEvent);
489
- applyLifecycleHookResults('iteration_start', hookResults);
490
- yield* this.ctx.drainPending();
491
- }
492
- if (this.ctx.pendingNotifications.length > 0) {
493
- await this.injectOneTaskNotification();
494
- }
495
- // Re-pin the working-memory block from ground truth at the primacy
496
- // edge BEFORE compaction runs (so the refreshed slot is what
497
- // compaction preserves). No-op when no provider is configured.
498
- await refreshWorkingMemory(this.ctx);
499
- await runCompactionCheck(this.ctx);
500
- // Cache discipline: keep the tools param byte-stable even on the
501
- // forced-final iteration and forbid tool use via tool_choice
502
- // 'none' instead. Dropping the tools array would invalidate the
503
- // entire prompt-cache prefix (tools render at position 0) and
504
- // risks a 400 because the history still carries
505
- // tool_use/tool_result blocks.
506
- const openAITools = this.ctx.tools.toLLMTools(this.ctx.allowedTools);
507
- const messages = forceFinalize
508
- ? [
509
- ...runMgr.messages,
510
- 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.'),
511
- ]
512
- : runMgr.messages;
513
- if (this.ctx.pluginManager) {
514
- const hookResults = await this.ctx.pluginManager.executeHooks('pre_llm_call', { runId: runMgr.id, iteration: iterationNum }, this.ctx.emitEvent);
515
- applyLifecycleHookResults('pre_llm_call', hookResults);
516
- yield* this.ctx.drainPending();
517
- }
518
- // Phase 4 (ses_001-tool-stream-events): consume the
519
- // streaming response natively, emitting message and
520
- // tool-input lifecycle events as deltas arrive. The
521
- // helper yields RunEvents through drainPending() so SSE
522
- // consumers see live progress; its return value is the
523
- // aggregated `ChatCompletionResponse` for the legacy
524
- // downstream paths (assistantMsg construction, working
525
- // state extraction, telemetry attribute stamping).
526
- const { response } = yield* streamProviderTurn(this.ctx.provider, {
527
- model,
528
- messages,
529
- tools: openAITools.length > 0 ? openAITools : undefined,
530
- toolChoice: forceFinalize && openAITools.length > 0 ? 'none' : undefined,
531
- temperature: runConfig.temperature,
532
- maxTokens: runConfig.maxResponseTokens,
533
- cacheControl: { type: 'auto' },
534
- // Thread the run abort into the model call so a Stop tears the
535
- // in-flight turn down (provider passes it to fetch; the consumer
536
- // also races it). Inert when never aborted.
537
- signal: this.ctx.abortController.signal,
538
- }, this.ctx.emitEvent, this.ctx.drainPending, runMgr.id, iterationNum, forceFinalize, this.ctx.log);
539
- runMgr.accumulateUsage(response.usage);
540
- if (this.ctx.pluginManager) {
541
- const hookResults = await this.ctx.pluginManager.executeHooks('post_llm_call', { runId: runMgr.id, iteration: iterationNum }, this.ctx.emitEvent);
542
- applyLifecycleHookResults('post_llm_call', hookResults);
543
- yield* this.ctx.drainPending();
544
- }
545
- this.ctx.log.debug('LLM response received', {
546
- runId: runMgr.id,
547
- iteration: iterationNum,
548
- finishReason: response.finishReason,
549
- hasContent: response.message.content !== null && response.message.content.length > 0,
550
- toolCallCount: response.message.toolCalls?.length ?? 0,
551
- promptTokens: response.usage.promptTokens,
552
- completionTokens: response.usage.completionTokens,
553
- totalTokens: runMgr.tokenUsage.totalTokens,
554
- totalCost: runMgr.costInfo.totalCost,
555
- });
556
- await this.ctx.emitEvent({
557
- type: 'token_usage_updated',
558
- runId: runMgr.id,
559
- usage: runMgr.tokenUsage,
560
- cost: runMgr.costInfo,
561
- });
562
- const assistantMsg = createAssistantMessage(response.message.content, forceFinalize ? undefined : response.message.toolCalls);
563
- runMgr.pushMessage(assistantMsg);
564
- if (this.ctx.workingStateManager && this.ctx.compactionConfig && assistantMsg.content) {
565
- extractFromAssistantMessage(this.ctx.workingStateManager, assistantMsg.content, this.ctx.compactionConfig);
566
- }
567
- yield* this.ctx.drainPending();
568
- iterSpan.setAttributes({
569
- [GENAI.USAGE_INPUT_TOKENS]: response.usage.promptTokens,
570
- [GENAI.USAGE_OUTPUT_TOKENS]: response.usage.completionTokens,
159
+ iterSpan.setAttributes({
160
+ [GENAI.USAGE_INPUT_TOKENS]: response.usage.promptTokens,
161
+ [GENAI.USAGE_OUTPUT_TOKENS]: response.usage.completionTokens,
162
+ });
163
+ iterSpan.setStatus({ code: SpanStatusCode.OK });
164
+ if (iterationActivity) {
165
+ this.ctx.activityStore.complete(iterationActivity.id, {
166
+ content: response.message.content,
167
+ hasToolCalls: forceFinalize ? false : !!response.message.toolCalls?.length,
571
168
  });
572
- iterSpan.setStatus({ code: SpanStatusCode.OK });
573
- if (iterationActivity) {
574
- this.ctx.activityStore.complete(iterationActivity.id, {
575
- content: response.message.content,
576
- hasToolCalls: forceFinalize ? false : !!response.message.toolCalls?.length,
169
+ }
170
+ if (forceFinalize ||
171
+ response.finishReason === 'stop' ||
172
+ !response.message.toolCalls ||
173
+ response.message.toolCalls.length === 0) {
174
+ // Every task-dispatch tool (create_task, continue_task, Agent)
175
+ // is BLOCKING: the worker's output returns as the dispatching
176
+ // tool_use's canonical tool_result, so by the time the model
177
+ // ends its turn nothing launched by this run should still be
178
+ // in flight. A running task here is an orphan (interrupted
179
+ // tool execution, cancel race) with no delivery path back to
180
+ // the parent — the <task-notification> producer was removed
181
+ // in dc16d58, so waiting on the queue could only ever time
182
+ // out. Log the orphans honestly and end the turn normally.
183
+ if (!forceFinalize && this.hasRunningAgentTasks()) {
184
+ this.ctx.log.warn('LLM ended turn with agent tasks still running — ending run without waiting (orphan tasks have no delivery path)', {
185
+ runId: runMgr.id,
186
+ iteration: iterationNum,
577
187
  });
578
188
  }
579
- if (forceFinalize ||
580
- response.finishReason === 'stop' ||
581
- !response.message.toolCalls ||
582
- response.message.toolCalls.length === 0) {
583
- const hasRunningTasks = this.hasRunningAgentTasks();
584
- const hasPendingNotifications = this.ctx.pendingNotifications.length > 0;
585
- if (!forceFinalize && (hasRunningTasks || hasPendingNotifications)) {
586
- this.ctx.log.info('LLM ended turn but agent tasks still running — waiting for notifications', {
587
- runId: runMgr.id,
588
- runningTasks: hasRunningTasks,
589
- pendingNotifications: hasPendingNotifications,
590
- });
591
- await this.ctx.emitEvent({
592
- type: 'iteration_completed',
593
- runId: runMgr.id,
594
- iteration: iterationNum,
595
- hasToolCalls: false,
596
- });
597
- yield* this.ctx.drainPending();
598
- iterSpan.end();
599
- yield* this.waitAndInjectNotifications();
600
- continue;
601
- }
602
- const hasContent = response.message.content !== null && response.message.content.length > 0;
603
- // Auto-continuation on `stop_reason: max_tokens`. The
604
- // model hit its per-call output cap mid-text (NOT
605
- // mid-tool-use — that path is handled separately
606
- // below via `inputTruncated`). Push a synthetic
607
- // "continue" user message and let the loop fire
608
- // another turn. The provider receives the partial
609
- // assistant content + the continue prompt and
610
- // resumes from where it left off, mirroring the
611
- // Claude.ai "Continue" affordance.
612
- //
613
- // Guards:
614
- // - `hasContent` so we don't loop forever on an
615
- // empty cutoff (Anthropic occasionally emits
616
- // `stop_reason: max_tokens` with no content
617
- // when an injected pre-fill blocks the model).
618
- // - `!forceFinalize` so the forced-finalize path
619
- // never auto-continues — that path is invoked
620
- // specifically to extract a closing summary.
621
- // - max_iterations bounds the loop in any case.
622
- if (!forceFinalize && response.finishReason === 'length' && hasContent) {
623
- this.ctx.log.info('LLM hit max_tokens mid-text — auto-continuing', {
624
- runId: runMgr.id,
625
- iteration: iterationNum,
626
- completionTokens: response.usage.completionTokens,
627
- });
628
- runMgr.pushMessage(createUserMessage(AUTO_CONTINUATION_USER_MESSAGE));
629
- await this.ctx.emitEvent({
630
- type: 'iteration_completed',
631
- runId: runMgr.id,
632
- iteration: iterationNum,
633
- hasToolCalls: false,
634
- });
635
- yield* this.ctx.drainPending();
636
- iterSpan.end();
637
- continue;
638
- }
639
- if (!hasContent && !forceFinalize) {
640
- this.ctx.log.warn('Empty completion detected — requesting final summary', {
641
- iteration: iterationNum,
642
- finishReason: response.finishReason,
643
- });
644
- await this.requestFinalResponse(model, 'end_turn');
645
- yield* this.ctx.drainPending();
646
- }
189
+ const hasContent = response.message.content !== null && response.message.content.length > 0;
190
+ // Auto-continuation on `stop_reason: max_tokens`. The
191
+ // model hit its per-call output cap mid-text (NOT
192
+ // mid-tool-use that path is handled separately
193
+ // below via `inputTruncated`). Push a synthetic
194
+ // "continue" user message and let the loop fire
195
+ // another turn. The provider receives the partial
196
+ // assistant content + the continue prompt and
197
+ // resumes from where it left off, mirroring the
198
+ // Claude.ai "Continue" affordance.
199
+ //
200
+ // Guards:
201
+ // - `hasContent` so we don't loop forever on an
202
+ // empty cutoff (Anthropic occasionally emits
203
+ // `stop_reason: max_tokens` with no content
204
+ // when an injected pre-fill blocks the model).
205
+ // - `!forceFinalize` so the forced-finalize path
206
+ // never auto-continues — that path is invoked
207
+ // specifically to extract a closing summary.
208
+ // - max_iterations bounds the loop in any case.
209
+ if (!forceFinalize && response.finishReason === 'length' && hasContent) {
210
+ this.ctx.log.info('LLM hit max_tokens mid-text — auto-continuing', {
211
+ runId: runMgr.id,
212
+ iteration: iterationNum,
213
+ completionTokens: response.usage.completionTokens,
214
+ });
215
+ runMgr.pushMessage(createUserMessage(AUTO_CONTINUATION_USER_MESSAGE));
647
216
  await this.ctx.emitEvent({
648
217
  type: 'iteration_completed',
649
218
  runId: runMgr.id,
@@ -651,78 +220,92 @@ export class IterationOrchestrator {
651
220
  hasToolCalls: false,
652
221
  });
653
222
  yield* this.ctx.drainPending();
654
- // A Stop that lands AFTER the final turn streamed but before
655
- // this break must settle the run as cancelled, not end_turn —
656
- // otherwise the just-produced answer is recorded as a clean
657
- // completion. Mirrors the between-iteration cancel at :511.
658
- if (this.ctx.abortController.signal.aborted) {
659
- runMgr.setStopReason('cancelled');
660
- runMgr.markCancelled();
661
- iterSpan.end();
662
- break;
663
- }
664
- runMgr.setStopReason('end_turn');
665
- iterSpan.end();
666
- break;
667
- }
668
- const reviewOutcome = yield* runToolReview(this.ctx, response, iterationNum);
669
- if (reviewOutcome === 'stop') {
670
- iterSpan.end();
671
- return;
672
- }
673
- if (reviewOutcome === 'rejected') {
674
223
  iterSpan.end();
675
224
  continue;
676
225
  }
677
- const checkpointSignal = yield* runIterationCheckpoint(this.ctx, iterationNum);
678
- if (checkpointSignal === 'stop') {
679
- iterSpan.end();
680
- return;
681
- }
682
- await runAdvisoryPhase(this.ctx, iterationNum, response);
683
- if (this.ctx.pluginManager) {
684
- const hookResults = await this.ctx.pluginManager.executeHooks('iteration_end', { runId: runMgr.id, iteration: iterationNum }, this.ctx.emitEvent);
685
- applyLifecycleHookResults('iteration_end', hookResults);
226
+ if (!hasContent && !forceFinalize) {
227
+ this.ctx.log.warn('Empty completion detected — requesting final summary', {
228
+ iteration: iterationNum,
229
+ finishReason: response.finishReason,
230
+ });
231
+ await this.requestFinalResponse(model, 'end_turn');
686
232
  yield* this.ctx.drainPending();
687
233
  }
688
234
  await this.ctx.emitEvent({
689
235
  type: 'iteration_completed',
690
236
  runId: runMgr.id,
691
237
  iteration: iterationNum,
692
- hasToolCalls: true,
238
+ hasToolCalls: false,
693
239
  });
694
240
  yield* this.ctx.drainPending();
695
- iterSpan.end();
696
- }
697
- catch (err) {
698
- // A Stop that aborted the in-flight turn surfaces here as a
699
- // thrown abort (the provider stream was raced against the run
700
- // signal). Settle it as a CANCELLATION — mirroring the
701
- // between-iteration cancel at the top of the loop — rather than
702
- // recording it as an SDK failure (error span + failed activity)
703
- // and re-throwing. The run then returns cleanly with a
704
- // 'cancelled' stop reason instead of propagating an error.
241
+ // A Stop that lands AFTER the final turn streamed but before
242
+ // this break must settle the run as cancelled, not end_turn —
243
+ // otherwise the just-produced answer is recorded as a clean
244
+ // completion. Mirrors the between-iteration cancel at :511.
705
245
  if (this.ctx.abortController.signal.aborted) {
706
246
  runMgr.setStopReason('cancelled');
707
247
  runMgr.markCancelled();
708
248
  iterSpan.end();
709
249
  break;
710
250
  }
711
- if (iterationActivity) {
712
- this.ctx.activityStore.fail(iterationActivity.id, toErrorMessage(err));
713
- }
714
- iterSpan.setStatus({
715
- code: SpanStatusCode.ERROR,
716
- message: toErrorMessage(err),
717
- });
718
- iterSpan.recordException(err instanceof Error ? err : new Error(String(err)));
251
+ runMgr.setStopReason('end_turn');
252
+ iterSpan.end();
253
+ break;
254
+ }
255
+ const reviewOutcome = yield* runToolReview(this.ctx, response, iterationNum);
256
+ if (reviewOutcome === 'stop') {
257
+ iterSpan.end();
258
+ return;
259
+ }
260
+ if (reviewOutcome === 'rejected') {
261
+ iterSpan.end();
262
+ continue;
263
+ }
264
+ const checkpointSignal = yield* runIterationCheckpoint(this.ctx, iterationNum);
265
+ if (checkpointSignal === 'stop') {
719
266
  iterSpan.end();
720
- throw err;
267
+ return;
268
+ }
269
+ await runAdvisoryPhase(this.ctx, iterationNum, response);
270
+ if (this.ctx.pluginManager) {
271
+ const hookResults = await this.ctx.pluginManager.executeHooks('iteration_end', { runId: runMgr.id, iteration: iterationNum }, this.ctx.emitEvent);
272
+ applyLifecycleHookResults('iteration_end', hookResults);
273
+ yield* this.ctx.drainPending();
721
274
  }
275
+ await this.ctx.emitEvent({
276
+ type: 'iteration_completed',
277
+ runId: runMgr.id,
278
+ iteration: iterationNum,
279
+ hasToolCalls: true,
280
+ });
281
+ yield* this.ctx.drainPending();
282
+ iterSpan.end();
283
+ }
284
+ catch (err) {
285
+ // A Stop that aborted the in-flight turn surfaces here as a
286
+ // thrown abort (the provider stream was raced against the run
287
+ // signal). Settle it as a CANCELLATION — mirroring the
288
+ // between-iteration cancel at the top of the loop — rather than
289
+ // recording it as an SDK failure (error span + failed activity)
290
+ // and re-throwing. The run then returns cleanly with a
291
+ // 'cancelled' stop reason instead of propagating an error.
292
+ if (this.ctx.abortController.signal.aborted) {
293
+ runMgr.setStopReason('cancelled');
294
+ runMgr.markCancelled();
295
+ iterSpan.end();
296
+ break;
297
+ }
298
+ if (iterationActivity) {
299
+ this.ctx.activityStore.fail(iterationActivity.id, toErrorMessage(err));
300
+ }
301
+ iterSpan.setStatus({
302
+ code: SpanStatusCode.ERROR,
303
+ message: toErrorMessage(err),
304
+ });
305
+ iterSpan.recordException(err instanceof Error ? err : new Error(String(err)));
306
+ iterSpan.end();
307
+ throw err;
722
308
  }
723
- }
724
- finally {
725
- unsubscribeTaskListener?.();
726
309
  }
727
310
  }
728
311
  hasRunningAgentTasks() {
@@ -732,78 +315,6 @@ export class IterationOrchestrator {
732
315
  .listTasks()
733
316
  .some((t) => t.state !== 'completed' && t.state !== 'failed' && t.state !== 'canceled');
734
317
  }
735
- async *waitAndInjectNotifications() {
736
- const deadline = Date.now() + (this.ctx.runConfig.timeoutMs ?? 120_000);
737
- while (this.ctx.pendingNotifications.length === 0 &&
738
- Date.now() < deadline &&
739
- !this.ctx.abortController.signal.aborted) {
740
- await new Promise((r) => setTimeout(r, 250));
741
- }
742
- await this.injectOneTaskNotification();
743
- }
744
- /**
745
- * Canonical async completion delivery (ses_009-task-notification-envelope).
746
- *
747
- * Drains every pending task completion in one pass and emits each as
748
- * a plain USER text message wrapped in the `<task-notification>`
749
- * envelope the supervisor prompt expects.
750
- *
751
- * Why not a `tool_result` block bound to the dispatching tool_use_id:
752
- * `create_task` is documented as NON-BLOCKING and returns
753
- * "Task launched: …" immediately. That immediate return is already
754
- * recorded as the canonical tool_result for that tool_use, so a
755
- * second tool_result for the SAME tool_use_id — emitted later, after
756
- * intervening assistant turns — is rejected by Anthropic with
757
- * `messages.<n>.content.0: unexpected tool_use_id found in
758
- * tool_result blocks` because the immediately-prior assistant
759
- * message no longer carries the matching tool_use. Wrapping as a
760
- * user text envelope sidesteps the pairing rule entirely.
761
- *
762
- * Coalescing N drops into one drain replaces the previous
763
- * one-at-a-time pattern which forced a separate orchestrator
764
- * iteration per completed task on wide fan-outs.
765
- */
766
- async injectOneTaskNotification() {
767
- if (this.ctx.pendingNotifications.length === 0)
768
- return;
769
- const handles = this.ctx.pendingNotifications.splice(0);
770
- for (const handle of handles) {
771
- const meta = this.ctx.launchedTasks.get(handle.taskId);
772
- const resultText = handle.result?.result ??
773
- handle.result?.lastError ??
774
- `Task finished with state: ${handle.state}`;
775
- if (meta?.planTaskId && this.ctx.taskStore) {
776
- const success = handle.state === 'completed';
777
- await this.ctx.taskStore.update(meta.planTaskId, {
778
- status: 'completed',
779
- description: success ? undefined : `Failed: ${resultText.substring(0, 200)}`,
780
- });
781
- }
782
- this.ctx.launchedTasks.delete(handle.taskId);
783
- // `remaining-tasks` = inflight workers still pending after this
784
- // one drains. `launchedTasks` is the single source of truth:
785
- // it holds every dispatched worker that has NOT yet been
786
- // drained + delete()'d. The drain batch entries are still
787
- // inside launchedTasks until each iteration's delete() above
788
- // removes them, so reading the size right after that delete
789
- // gives the honest count. Adding `handles.length - 1 - i`
790
- // here used to double-count this same queue.
791
- const remainingTasks = this.ctx.launchedTasks.size;
792
- const envelope = `<task-notification>\n<task-id>${xmlEscape(handle.taskId)}</task-id>\n` +
793
- `<agent-id>${xmlEscape(handle.agentId)}</agent-id>\n` +
794
- `<status>${xmlEscape(handle.state)}</status>\n` +
795
- `<result>${cdataWrap(resultText)}</result>\n` +
796
- `<remaining-tasks>${remainingTasks}</remaining-tasks>\n</task-notification>`;
797
- this.ctx.runMgr.pushMessage(createUserMessage(envelope));
798
- this.ctx.log.info('Task notification injected', {
799
- taskId: handle.taskId,
800
- agentId: handle.agentId,
801
- state: handle.state,
802
- planTaskId: meta?.planTaskId,
803
- remainingNotifications: remainingTasks,
804
- });
805
- }
806
- }
807
318
  async requestFinalResponse(model, reason) {
808
319
  const lastAssistant = [...this.ctx.runMgr.messages]
809
320
  .reverse()