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