@qduc/term2 0.32.2 → 0.33.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 (269) hide show
  1. package/dist/build-info.json +2 -2
  2. package/dist/components/menu/ModelSelectionMenu.d.ts.map +1 -1
  3. package/dist/components/menu/ModelSelectionMenu.js +12 -14
  4. package/dist/components/menu/ModelSelectionMenu.js.map +1 -1
  5. package/dist/lib/agent-chat-service.d.ts.map +1 -1
  6. package/dist/lib/agent-chat-service.js +2 -6
  7. package/dist/lib/agent-chat-service.js.map +1 -1
  8. package/dist/lib/agent-client.d.ts +2 -1
  9. package/dist/lib/agent-client.d.ts.map +1 -1
  10. package/dist/lib/agent-client.js +71 -84
  11. package/dist/lib/agent-client.js.map +1 -1
  12. package/dist/lib/agent-factory.d.ts.map +1 -1
  13. package/dist/lib/agent-factory.js +17 -19
  14. package/dist/lib/agent-factory.js.map +1 -1
  15. package/dist/prompts/tool-surface-guidance.js +1 -1
  16. package/dist/prompts/tool-surface-guidance.js.map +1 -1
  17. package/dist/prompts/tool-surface-guidance.ts +1 -1
  18. package/dist/providers/ai-sdk-anthropic.provider.d.ts.map +1 -1
  19. package/dist/providers/ai-sdk-anthropic.provider.js +16 -2
  20. package/dist/providers/ai-sdk-anthropic.provider.js.map +1 -1
  21. package/dist/providers/ai-sdk-google.provider.d.ts.map +1 -1
  22. package/dist/providers/ai-sdk-google.provider.js +16 -2
  23. package/dist/providers/ai-sdk-google.provider.js.map +1 -1
  24. package/dist/providers/ai-sdk-openrouter.provider.d.ts.map +1 -1
  25. package/dist/providers/ai-sdk-openrouter.provider.js +16 -1
  26. package/dist/providers/ai-sdk-openrouter.provider.js.map +1 -1
  27. package/dist/providers/ai-sdk-provider-settings.d.ts +2 -0
  28. package/dist/providers/ai-sdk-provider-settings.d.ts.map +1 -1
  29. package/dist/providers/ai-sdk-provider-settings.js +5 -0
  30. package/dist/providers/ai-sdk-provider-settings.js.map +1 -1
  31. package/dist/providers/ai-sdk-streamed-model.d.ts +3 -3
  32. package/dist/providers/ai-sdk-streamed-model.d.ts.map +1 -1
  33. package/dist/providers/ai-sdk-streamed-model.js +5 -69
  34. package/dist/providers/ai-sdk-streamed-model.js.map +1 -1
  35. package/dist/providers/anthropic-chat-caching.d.ts +5 -0
  36. package/dist/providers/anthropic-chat-caching.d.ts.map +1 -0
  37. package/dist/providers/{common/openai-compatible-messages.js → anthropic-chat-caching.js} +19 -1
  38. package/dist/providers/anthropic-chat-caching.js.map +1 -0
  39. package/dist/providers/codex.provider.js +2 -0
  40. package/dist/providers/codex.provider.js.map +1 -1
  41. package/dist/providers/custom-provider-runtime.d.ts +18 -0
  42. package/dist/providers/custom-provider-runtime.d.ts.map +1 -0
  43. package/dist/providers/custom-provider-runtime.js +26 -0
  44. package/dist/providers/custom-provider-runtime.js.map +1 -0
  45. package/dist/providers/llama-cpp.provider.d.ts +3 -0
  46. package/dist/providers/llama-cpp.provider.d.ts.map +1 -1
  47. package/dist/providers/llama-cpp.provider.js +17 -0
  48. package/dist/providers/llama-cpp.provider.js.map +1 -1
  49. package/dist/providers/neuralwatt.provider.d.ts +6 -0
  50. package/dist/providers/neuralwatt.provider.d.ts.map +1 -0
  51. package/dist/providers/neuralwatt.provider.js +8 -0
  52. package/dist/providers/neuralwatt.provider.js.map +1 -0
  53. package/dist/providers/openai-compatible-lazy.d.ts.map +1 -1
  54. package/dist/providers/openai-compatible-lazy.js +2 -0
  55. package/dist/providers/openai-compatible-lazy.js.map +1 -1
  56. package/dist/providers/openai-compatible-middleware.d.ts +4 -9
  57. package/dist/providers/openai-compatible-middleware.d.ts.map +1 -1
  58. package/dist/providers/openai-compatible-middleware.js +25 -48
  59. package/dist/providers/openai-compatible-middleware.js.map +1 -1
  60. package/dist/providers/openai-compatible-response-normalizer.d.ts +1 -1
  61. package/dist/providers/openai-compatible-response-normalizer.d.ts.map +1 -1
  62. package/dist/providers/openai-compatible-response-normalizer.js +6 -6
  63. package/dist/providers/openai-compatible-response-normalizer.js.map +1 -1
  64. package/dist/providers/openai-compatible.provider.d.ts +2 -32
  65. package/dist/providers/openai-compatible.provider.d.ts.map +1 -1
  66. package/dist/providers/openai-compatible.provider.js +18 -144
  67. package/dist/providers/openai-compatible.provider.js.map +1 -1
  68. package/dist/providers/openai.provider.d.ts.map +1 -1
  69. package/dist/providers/openai.provider.js +1 -0
  70. package/dist/providers/openai.provider.js.map +1 -1
  71. package/dist/providers/opencode-middleware.d.ts +9 -0
  72. package/dist/providers/opencode-middleware.d.ts.map +1 -0
  73. package/dist/providers/opencode-middleware.js +15 -0
  74. package/dist/providers/opencode-middleware.js.map +1 -0
  75. package/dist/providers/opencode-model-provider.d.ts +19 -0
  76. package/dist/providers/opencode-model-provider.d.ts.map +1 -0
  77. package/dist/providers/opencode-model-provider.js +128 -0
  78. package/dist/providers/opencode-model-provider.js.map +1 -0
  79. package/dist/providers/opencode.provider.d.ts +2 -0
  80. package/dist/providers/opencode.provider.d.ts.map +1 -1
  81. package/dist/providers/opencode.provider.js +4 -0
  82. package/dist/providers/opencode.provider.js.map +1 -1
  83. package/dist/providers/openrouter-decision-models.d.ts +7 -0
  84. package/dist/providers/openrouter-decision-models.d.ts.map +1 -0
  85. package/dist/providers/openrouter-decision-models.js +31 -0
  86. package/dist/providers/openrouter-decision-models.js.map +1 -0
  87. package/dist/providers/openrouter.provider.js +2 -1
  88. package/dist/providers/openrouter.provider.js.map +1 -1
  89. package/dist/providers/orcarouter-models.d.ts +12 -0
  90. package/dist/providers/orcarouter-models.d.ts.map +1 -0
  91. package/dist/providers/orcarouter-models.js +34 -0
  92. package/dist/providers/orcarouter-models.js.map +1 -0
  93. package/dist/providers/registry.d.ts +7 -0
  94. package/dist/providers/registry.d.ts.map +1 -1
  95. package/dist/providers/registry.js.map +1 -1
  96. package/dist/services/agent-runtime/application-run-loop.d.ts +20 -1
  97. package/dist/services/agent-runtime/application-run-loop.d.ts.map +1 -1
  98. package/dist/services/agent-runtime/application-run-loop.js +871 -759
  99. package/dist/services/agent-runtime/application-run-loop.js.map +1 -1
  100. package/dist/services/agent-runtime/tool-call-execution.d.ts +1 -1
  101. package/dist/services/agent-runtime/tool-call-execution.d.ts.map +1 -1
  102. package/dist/services/agent-runtime/tool-call-execution.js +6 -3
  103. package/dist/services/agent-runtime/tool-call-execution.js.map +1 -1
  104. package/dist/services/approval/approval-flow-coordinator.d.ts +4 -2
  105. package/dist/services/approval/approval-flow-coordinator.d.ts.map +1 -1
  106. package/dist/services/approval/approval-flow-coordinator.js +2 -1
  107. package/dist/services/approval/approval-flow-coordinator.js.map +1 -1
  108. package/dist/services/approval/approval-state.d.ts +0 -13
  109. package/dist/services/approval/approval-state.d.ts.map +1 -1
  110. package/dist/services/approval/approval-state.js +0 -5
  111. package/dist/services/approval/approval-state.js.map +1 -1
  112. package/dist/services/approval/background-subagent-approval-controller.d.ts.map +1 -1
  113. package/dist/services/approval/background-subagent-approval-controller.js +0 -2
  114. package/dist/services/approval/background-subagent-approval-controller.js.map +1 -1
  115. package/dist/services/approval/nested-approval-owner.d.ts.map +1 -1
  116. package/dist/services/approval/nested-approval-owner.js +5 -1
  117. package/dist/services/approval/nested-approval-owner.js.map +1 -1
  118. package/dist/services/approval/shell-auto-approval-resolver.d.ts +2 -2
  119. package/dist/services/approval/shell-auto-approval-resolver.d.ts.map +1 -1
  120. package/dist/services/approval/shell-auto-approval-resolver.js +2 -2
  121. package/dist/services/approval/shell-auto-approval-resolver.js.map +1 -1
  122. package/dist/services/approval/tool-approval-batch-coordinator.d.ts +2 -2
  123. package/dist/services/approval/tool-approval-batch-coordinator.d.ts.map +1 -1
  124. package/dist/services/approval/tool-approval-batch-coordinator.js +4 -4
  125. package/dist/services/approval/tool-approval-batch-coordinator.js.map +1 -1
  126. package/dist/services/conversation/conversation-adapter.d.ts +17 -47
  127. package/dist/services/conversation/conversation-adapter.d.ts.map +1 -1
  128. package/dist/services/conversation/conversation-adapter.js +114 -88
  129. package/dist/services/conversation/conversation-adapter.js.map +1 -1
  130. package/dist/services/conversation/conversation-orchestrator.d.ts.map +1 -1
  131. package/dist/services/conversation/conversation-orchestrator.js +48 -121
  132. package/dist/services/conversation/conversation-orchestrator.js.map +1 -1
  133. package/dist/services/conversation/conversation-record-stream.d.ts +21 -0
  134. package/dist/services/conversation/conversation-record-stream.d.ts.map +1 -0
  135. package/dist/services/conversation/conversation-record-stream.js +186 -0
  136. package/dist/services/conversation/conversation-record-stream.js.map +1 -0
  137. package/dist/services/conversation/conversation-record.d.ts +160 -0
  138. package/dist/services/conversation/conversation-record.d.ts.map +1 -0
  139. package/dist/services/conversation/conversation-record.js +371 -0
  140. package/dist/services/conversation/conversation-record.js.map +1 -0
  141. package/dist/services/conversation/conversation-result-builder.d.ts +0 -4
  142. package/dist/services/conversation/conversation-result-builder.d.ts.map +1 -1
  143. package/dist/services/conversation/conversation-result-builder.js +0 -10
  144. package/dist/services/conversation/conversation-result-builder.js.map +1 -1
  145. package/dist/services/conversation/conversation-service.d.ts +2 -1
  146. package/dist/services/conversation/conversation-service.d.ts.map +1 -1
  147. package/dist/services/conversation/conversation-service.js +3 -0
  148. package/dist/services/conversation/conversation-service.js.map +1 -1
  149. package/dist/services/conversation-agent-client.d.ts +17 -7
  150. package/dist/services/conversation-agent-client.d.ts.map +1 -1
  151. package/dist/services/logging/conversation-logger.d.ts +1 -7
  152. package/dist/services/logging/conversation-logger.d.ts.map +1 -1
  153. package/dist/services/logging/conversation-logger.js +0 -18
  154. package/dist/services/logging/conversation-logger.js.map +1 -1
  155. package/dist/services/model-service.d.ts +2 -6
  156. package/dist/services/model-service.d.ts.map +1 -1
  157. package/dist/services/model-service.js +8 -21
  158. package/dist/services/model-service.js.map +1 -1
  159. package/dist/services/models/decision-model-listing.d.ts +2 -12
  160. package/dist/services/models/decision-model-listing.d.ts.map +1 -1
  161. package/dist/services/models/decision-model-listing.js +2 -29
  162. package/dist/services/models/decision-model-listing.js.map +1 -1
  163. package/dist/services/provider-continuity.d.ts +26 -0
  164. package/dist/services/provider-continuity.d.ts.map +1 -1
  165. package/dist/services/provider-continuity.js +47 -0
  166. package/dist/services/provider-continuity.js.map +1 -1
  167. package/dist/services/retry/recovery-executor.d.ts +3 -4
  168. package/dist/services/retry/recovery-executor.d.ts.map +1 -1
  169. package/dist/services/retry/recovery-executor.js +13 -75
  170. package/dist/services/retry/recovery-executor.js.map +1 -1
  171. package/dist/services/retry/retry-contracts.d.ts +2 -10
  172. package/dist/services/retry/retry-contracts.d.ts.map +1 -1
  173. package/dist/services/session/continuation-plan-applier.d.ts +5 -9
  174. package/dist/services/session/continuation-plan-applier.d.ts.map +1 -1
  175. package/dist/services/session/continuation-plan-applier.js +9 -43
  176. package/dist/services/session/continuation-plan-applier.js.map +1 -1
  177. package/dist/services/session/continuation-recovery-handler.d.ts +4 -6
  178. package/dist/services/session/continuation-recovery-handler.d.ts.map +1 -1
  179. package/dist/services/session/continuation-recovery-handler.js +3 -15
  180. package/dist/services/session/continuation-recovery-handler.js.map +1 -1
  181. package/dist/services/session/initial-input-preparer.d.ts +2 -2
  182. package/dist/services/session/initial-input-preparer.d.ts.map +1 -1
  183. package/dist/services/session/initial-input-preparer.js +3 -3
  184. package/dist/services/session/initial-input-preparer.js.map +1 -1
  185. package/dist/services/session/initial-turn-recovery-handler.d.ts +2 -4
  186. package/dist/services/session/initial-turn-recovery-handler.d.ts.map +1 -1
  187. package/dist/services/session/initial-turn-recovery-handler.js +4 -5
  188. package/dist/services/session/initial-turn-recovery-handler.js.map +1 -1
  189. package/dist/services/session/live-run.d.ts +9 -3
  190. package/dist/services/session/live-run.d.ts.map +1 -1
  191. package/dist/services/session/live-run.js +19 -4
  192. package/dist/services/session/live-run.js.map +1 -1
  193. package/dist/services/session/session-composition.d.ts +8 -12
  194. package/dist/services/session/session-composition.d.ts.map +1 -1
  195. package/dist/services/session/session-composition.js +41 -87
  196. package/dist/services/session/session-composition.js.map +1 -1
  197. package/dist/services/session/session-input-planner.d.ts +2 -2
  198. package/dist/services/session/session-input-planner.d.ts.map +1 -1
  199. package/dist/services/session/session-input-planner.js +6 -13
  200. package/dist/services/session/session-input-planner.js.map +1 -1
  201. package/dist/services/session/session-lifecycle.d.ts +73 -23
  202. package/dist/services/session/session-lifecycle.d.ts.map +1 -1
  203. package/dist/services/session/session-lifecycle.js +127 -45
  204. package/dist/services/session/session-lifecycle.js.map +1 -1
  205. package/dist/services/session/session-stream-processor.d.ts +4 -30
  206. package/dist/services/session/session-stream-processor.d.ts.map +1 -1
  207. package/dist/services/session/session-stream-processor.js +11 -300
  208. package/dist/services/session/session-stream-processor.js.map +1 -1
  209. package/dist/services/session/session-tool-tracker.d.ts +9 -144
  210. package/dist/services/session/session-tool-tracker.d.ts.map +1 -1
  211. package/dist/services/session/session-tool-tracker.js +17 -251
  212. package/dist/services/session/session-tool-tracker.js.map +1 -1
  213. package/dist/services/session/{turn-workflow.d.ts → session-turn-adapter.d.ts} +10 -43
  214. package/dist/services/session/session-turn-adapter.d.ts.map +1 -0
  215. package/dist/services/session/session-turn-adapter.js +734 -0
  216. package/dist/services/session/session-turn-adapter.js.map +1 -0
  217. package/dist/services/session/turn-attempt-factory.d.ts +2 -11
  218. package/dist/services/session/turn-attempt-factory.d.ts.map +1 -1
  219. package/dist/services/session/turn-attempt-factory.js +4 -12
  220. package/dist/services/session/turn-attempt-factory.js.map +1 -1
  221. package/dist/services/session/turn-attempt.d.ts +0 -3
  222. package/dist/services/session/turn-attempt.d.ts.map +1 -1
  223. package/dist/services/session/turn-attempt.js +0 -5
  224. package/dist/services/session/turn-attempt.js.map +1 -1
  225. package/dist/services/session/turn-coordinator.d.ts +3 -3
  226. package/dist/services/session/turn-coordinator.d.ts.map +1 -1
  227. package/dist/services/session/turn-coordinator.js +31 -26
  228. package/dist/services/session/turn-coordinator.js.map +1 -1
  229. package/dist/services/session/turn-projection-state.d.ts +45 -0
  230. package/dist/services/session/turn-projection-state.d.ts.map +1 -0
  231. package/dist/services/session/turn-projection-state.js +61 -0
  232. package/dist/services/session/turn-projection-state.js.map +1 -0
  233. package/dist/services/subagents/tool-policy.d.ts +5 -5
  234. package/dist/services/subagents/tool-policy.d.ts.map +1 -1
  235. package/dist/services/subagents/tool-policy.js.map +1 -1
  236. package/dist/tools/system/run-code/run-code-runtime.d.ts.map +1 -1
  237. package/dist/tools/system/run-code/run-code-runtime.js +90 -19
  238. package/dist/tools/system/run-code/run-code-runtime.js.map +1 -1
  239. package/dist/tools/system/shell.d.ts +2 -2
  240. package/dist/tools/system/shell.d.ts.map +1 -1
  241. package/dist/tools/system/shell.js +1 -0
  242. package/dist/tools/system/shell.js.map +1 -1
  243. package/dist/tools/tool-names.d.ts +1 -1
  244. package/dist/tools/tool-names.d.ts.map +1 -1
  245. package/dist/utils/ai/token-usage.js +9 -8
  246. package/dist/utils/ai/token-usage.js.map +1 -1
  247. package/package.json +1 -1
  248. package/dist/providers/anthropic-middleware.d.ts +0 -25
  249. package/dist/providers/anthropic-middleware.d.ts.map +0 -1
  250. package/dist/providers/anthropic-middleware.js +0 -32
  251. package/dist/providers/anthropic-middleware.js.map +0 -1
  252. package/dist/providers/common/openai-compatible-messages.d.ts +0 -2
  253. package/dist/providers/common/openai-compatible-messages.d.ts.map +0 -1
  254. package/dist/providers/common/openai-compatible-messages.js.map +0 -1
  255. package/dist/services/session/continuation-call-id-resolver.d.ts +0 -29
  256. package/dist/services/session/continuation-call-id-resolver.d.ts.map +0 -1
  257. package/dist/services/session/continuation-call-id-resolver.js +0 -69
  258. package/dist/services/session/continuation-call-id-resolver.js.map +0 -1
  259. package/dist/services/session/continuation-state.d.ts +0 -69
  260. package/dist/services/session/continuation-state.d.ts.map +0 -1
  261. package/dist/services/session/continuation-state.js +0 -78
  262. package/dist/services/session/continuation-state.js.map +0 -1
  263. package/dist/services/session/session-manager.d.ts +0 -87
  264. package/dist/services/session/session-manager.d.ts.map +0 -1
  265. package/dist/services/session/session-manager.js +0 -146
  266. package/dist/services/session/session-manager.js.map +0 -1
  267. package/dist/services/session/turn-workflow.d.ts.map +0 -1
  268. package/dist/services/session/turn-workflow.js +0 -1055
  269. package/dist/services/session/turn-workflow.js.map +0 -1
@@ -46,11 +46,16 @@ export class MaxTurnsExceededError extends Error {
46
46
  }
47
47
  }
48
48
  class EventQueue {
49
+ acceptBeforePublish;
50
+ constructor(acceptBeforePublish) {
51
+ this.acceptBeforePublish = acceptBeforePublish;
52
+ }
49
53
  #items = [];
50
54
  #waiters = [];
51
55
  #closed = false;
52
56
  #failure;
53
57
  push(item) {
58
+ this.acceptBeforePublish?.(item);
54
59
  const waiter = this.#waiters.shift();
55
60
  if (waiter)
56
61
  waiter.resolve({ done: false, value: item });
@@ -98,6 +103,7 @@ export class ApplicationRunLoop {
98
103
  #contextCompactionSessionState;
99
104
  #turnInputMailbox;
100
105
  #activeAbortController = null;
106
+ #activeTurnController = null;
101
107
  #segmentGeneration = 0;
102
108
  /** The run whose budget an out-of-band grant applies to, held across a pause. */
103
109
  #activeRunBudgetState;
@@ -143,6 +149,7 @@ export class ApplicationRunLoop {
143
149
  this.#turnInputMailbox.closeTurn();
144
150
  }
145
151
  abort() {
152
+ this.#activeTurnController?.abort();
146
153
  this.abortSegment();
147
154
  // An aborted turn will not resume, so nothing may keep waiting on it.
148
155
  this.#turnInputMailbox.abortTurn();
@@ -192,7 +199,6 @@ export class ApplicationRunLoop {
192
199
  #createToolCallExecution() {
193
200
  return new ToolCallExecution({
194
201
  toolLifecycle: this.#deps.toolLifecycle,
195
- getOnToolDispatch: this.#deps.getOnToolDispatch,
196
202
  resolveMaxParallelToolCalls: this.#deps.resolveMaxParallelToolCalls,
197
203
  isMaxTurnsExceeded: (error) => error instanceof MaxTurnsExceededError,
198
204
  logDiagnostic: this.#deps.logDiagnostic,
@@ -256,12 +262,9 @@ export class ApplicationRunLoop {
256
262
  * the tool results of the round that was in flight when they typed.
257
263
  */
258
264
  startStream(agent, input, options = {}) {
259
- // Without a declared turn scope, a fresh stream is the only evidence this
260
- // loop gets that a new turn has begun: anything still waiting belonged to
261
- // the previous one and must not leak into work the user did not aim it at.
262
- // With one, this call may equally be the same turn restarting after a
263
- // retry, which the loop cannot tell apart — so `openTurn`/`closeTurn` own
264
- // the decision instead.
265
+ return this.#run(this.#createState(agent, input, options), options);
266
+ }
267
+ #createState(agent, input, options) {
265
268
  this.#turnInputMailbox.startStream();
266
269
  const state = {
267
270
  agent,
@@ -329,24 +332,31 @@ export class ApplicationRunLoop {
329
332
  state.approvalDecisionInterruption = interruption;
330
333
  state.approvalMessage = approvalOptions?.message;
331
334
  };
332
- return this.#run(state, options);
335
+ return state;
336
+ }
337
+ /** Root turns use the same loop; session operations never dispatch a run. */
338
+ async runTurn(session) {
339
+ const controller = new AbortController();
340
+ this.#activeTurnController = controller;
341
+ try {
342
+ await this.#runTurn(undefined, session, controller.signal);
343
+ }
344
+ finally {
345
+ if (this.#activeTurnController === controller)
346
+ this.#activeTurnController = null;
347
+ }
333
348
  }
334
349
  continueRunStream(handle, options = {}) {
335
350
  const state = unwrapContinuationHandle(handle);
336
351
  if (!state || typeof state !== 'object' || !('agent' in state) || !('input' in state)) {
337
352
  throw new Error('Invalid application continuation state');
338
353
  }
339
- // Handles created before the ledger existed cannot resume meaningfully;
340
- // give them a fresh ledger rather than crashing on `approvals` access.
341
- if (!state.approvals)
342
- state.approvals = new ApprovalLedger();
343
- // A continuation handle from before approval batching carried only the
344
- // current pending item. Normalize it into the queue used by current runs.
345
- if (!state.toolExecution)
346
- state.toolExecution = this.#createToolCallExecution();
347
- // Handles created before cost accounting existed have no record list.
348
- if (!state.costRecords)
349
- state.costRecords = [];
354
+ if (!state.live?.resume)
355
+ throw new Error('Application turn is not waiting for continuation');
356
+ this.#refreshContinuation(state, options);
357
+ return this.#run(state, options);
358
+ }
359
+ #refreshContinuation(state, options) {
350
360
  if (!state.runBudget && options.runBudget) {
351
361
  state.runBudget = new RunBudget(options.runBudget);
352
362
  }
@@ -382,13 +392,7 @@ export class ApplicationRunLoop {
382
392
  state.turnCount = 0;
383
393
  if (state.maxTurns === undefined)
384
394
  state.maxTurns = options.maxTurns;
385
- if (typeof state.supportsConversationChaining !== 'boolean') {
386
- state.supportsConversationChaining = options.supportsConversationChaining === true;
387
- }
388
- else {
389
- // AgentClient supplies the current provider capability on every resume.
390
- state.supportsConversationChaining = options.supportsConversationChaining === true;
391
- }
395
+ state.supportsConversationChaining = options.supportsConversationChaining === true;
392
396
  // A continuation receives a fresh immutable snapshot from the session;
393
397
  // refresh the preparation closure when supplied, while preserving the
394
398
  // root closure for callers that do not provide one again.
@@ -409,12 +413,12 @@ export class ApplicationRunLoop {
409
413
  state.turnId = options.turnId;
410
414
  if (options.hookScope !== undefined)
411
415
  state.hookScope = options.hookScope;
412
- return this.#run(state, options);
413
416
  }
414
- #run(state, options) {
415
- const queue = new EventQueue();
417
+ #createView(state, options, acceptEvent) {
418
+ const queue = new EventQueue(acceptEvent);
416
419
  const segmentGeneration = ++this.#segmentGeneration;
417
- this.abortSegment();
420
+ if (!state.live)
421
+ this.abortSegment();
418
422
  const controller = new AbortController();
419
423
  this.#activeAbortController = controller;
420
424
  this.#turnInputMailbox.startSegment();
@@ -465,46 +469,137 @@ export class ApplicationRunLoop {
465
469
  return state.costRecords ?? [];
466
470
  },
467
471
  });
468
- let exitError;
469
- stream.completed = this.#execute(state, stream, queue, effectiveOptions, toolContext, controller)
470
- .catch((error) => {
471
- exitError = error;
472
- stream.cancelled = error instanceof Error && error.name === 'AbortError';
472
+ let resolveSegment;
473
+ let rejectSegment;
474
+ stream.completed = new Promise((resolve, reject) => {
475
+ resolveSegment = resolve;
476
+ rejectSegment = reject;
477
+ });
478
+ // The iterator may report a failure before the caller observes completed.
479
+ void stream.completed.catch(() => undefined);
480
+ const current = {
481
+ state,
482
+ stream,
483
+ queue,
484
+ options: effectiveOptions,
485
+ toolContext,
486
+ requestAbortController: controller,
487
+ generation: segmentGeneration,
488
+ resolveSegment,
489
+ rejectSegment,
490
+ };
491
+ this.#activeRunBudgetState = state.runBudget ? state : undefined;
492
+ return current;
493
+ }
494
+ #run(state, options) {
495
+ const current = this.#createView(state, options);
496
+ if (state.live) {
497
+ const resume = state.live.resume;
498
+ Object.assign(state.live, current, { resume: undefined });
499
+ resume();
500
+ }
501
+ else {
502
+ state.live = current;
503
+ void this.#runTurn(current).then((value) => this.#settleSegment(current, value), (error) => this.#settleSegment(current, undefined, error));
504
+ }
505
+ return current.stream;
506
+ }
507
+ #observe(context) {
508
+ context.session.beginSegment(context.stream);
509
+ context.observation = context.session.consume(context.stream);
510
+ void context.observation.catch(() => undefined);
511
+ }
512
+ async #completeTurn(context) {
513
+ const value = finish(context.stream, context.state, context.queue);
514
+ if (context.session) {
515
+ this.#settleSegment(context, value);
516
+ await context.observation;
517
+ await context.session.completed(context.stream);
518
+ }
519
+ return value;
520
+ }
521
+ /** Complete an event view; a pause leaves the owning async task alive. */
522
+ #settleSegment(context, value, error, waiting = false) {
523
+ const { state, stream, queue, generation } = context;
524
+ if (error === undefined)
525
+ context.session?.acceptSegment(stream);
526
+ state.runBudget?.pause();
527
+ const cancelled = error instanceof Error && error.name === 'AbortError';
528
+ const paused = (waiting || context.resume !== undefined) && !cancelled && error === undefined;
529
+ if (error !== undefined) {
530
+ stream.cancelled = cancelled;
473
531
  queue.close(error);
474
- throw error;
475
- })
476
- .finally(() => {
477
- state.runBudget?.pause();
478
- if (segmentGeneration !== this.#segmentGeneration)
479
- return;
480
- if (this.#activeAbortController === controller)
481
- this.#activeAbortController = null;
482
- // A segment that ends holding approvals has paused the turn, not ended
483
- // it: the caller resumes through continueRunStream, which offers
484
- // another request boundary. Injections wait for it. Only a segment
485
- // that ends with nothing outstanding has truly finished the turn, and
486
- // then anything still waiting has to be sent as its own turn instead.
487
- const pendingApprovals = state.toolExecution.pendingApprovals.length;
488
- const pendingBudgetInteraction = state.pendingRunBudgetInteraction !== undefined;
489
- const cancelled = stream.cancelled === true;
490
- const turnPaused = (pendingApprovals > 0 || pendingBudgetInteraction) && !cancelled && exitError === undefined;
491
- this.#turnInputMailbox.settleSegment({
492
- paused: turnPaused,
493
- reason: {
494
- pendingApprovals,
495
- pendingBudgetInteraction,
496
- cancelled,
497
- error: exitError instanceof Error ? exitError.name : exitError ? String(exitError) : undefined,
498
- turnCount: state.turnCount,
499
- },
500
- });
501
- if (turnPaused)
532
+ context.rejectSegment(error);
533
+ }
534
+ else {
535
+ context.resolveSegment(value);
536
+ }
537
+ if (generation !== this.#segmentGeneration)
538
+ return;
539
+ if (!paused && this.#activeAbortController === context.requestAbortController)
540
+ this.#activeAbortController = null;
541
+ this.#turnInputMailbox.settleSegment({
542
+ paused,
543
+ reason: {
544
+ pendingApprovals: state.toolExecution.pendingApprovals.length,
545
+ pendingBudgetInteraction: state.pendingRunBudgetInteraction !== undefined,
546
+ cancelled,
547
+ error: error instanceof Error ? error.name : error ? String(error) : undefined,
548
+ turnCount: state.turnCount,
549
+ },
550
+ });
551
+ if (!paused && this.#activeRunBudgetState === state)
552
+ this.#activeRunBudgetState = undefined;
553
+ }
554
+ /** Publish a paused view and suspend here until its owner reconnects or cancels. */
555
+ async #waitForDecision(context) {
556
+ if (context.session) {
557
+ this.#settleSegment(context, finish(context.stream, context.state, context.queue), undefined, true);
558
+ await context.observation;
559
+ const options = await context.session.interrupted(context.stream);
560
+ this.#assertLive(context);
561
+ this.#refreshContinuation(context.state, options);
562
+ Object.assign(context, this.#createView(context.state, { ...options, signal: context.turnSignal }, (event) => context.session.acceptEvent(event)));
563
+ this.#observe(context);
564
+ return;
565
+ }
566
+ const { state, stream, queue, options } = context;
567
+ await new Promise((resolve, reject) => {
568
+ const signal = options.signal;
569
+ const abort = () => {
570
+ context.resume = undefined;
571
+ reject(Object.assign(new Error('Operation aborted'), { name: 'AbortError' }));
572
+ };
573
+ context.resume = () => {
574
+ signal?.removeEventListener('abort', abort);
575
+ resolve();
576
+ };
577
+ signal?.addEventListener('abort', abort, { once: true });
578
+ if (signal?.aborted) {
579
+ abort();
502
580
  return;
503
- if (this.#activeRunBudgetState === state)
504
- this.#activeRunBudgetState = undefined;
581
+ }
582
+ this.#settleSegment(context, finish(stream, state, queue));
505
583
  });
506
- this.#activeRunBudgetState = state.runBudget ? state : undefined;
507
- return stream;
584
+ }
585
+ /** A budget decision resumes this exact boundary without recounting it. */
586
+ async #waitForBudget(context) {
587
+ const { state } = context;
588
+ while (state.pendingRunBudgetInteraction) {
589
+ if (state.runBudgetInteractionDecision === 'stop') {
590
+ state.pendingRunBudgetInteraction = undefined;
591
+ state.runBudgetInteractionDecision = undefined;
592
+ return true;
593
+ }
594
+ if (state.runBudgetInteractionDecision === 'continue') {
595
+ state.pendingRunBudgetInteraction = undefined;
596
+ state.runBudgetInteractionDecision = undefined;
597
+ return false;
598
+ }
599
+ context.stream.interruptions = [state.pendingRunBudgetInteraction];
600
+ await this.#waitForDecision(context);
601
+ }
602
+ return false;
508
603
  }
509
604
  /** Route boundary advice through the same admission lane as mode notices. */
510
605
  #queuePendingSystemNotice(text) {
@@ -517,741 +612,764 @@ export class ApplicationRunLoop {
517
612
  outputPush(stream, queue, { type: 'item', item });
518
613
  }, state.turnCount);
519
614
  }
520
- async #execute(state, stream, queue, options, toolContext, requestAbortController) {
615
+ /** The single model → tools → model cycle, shared by root and nested turns. */
616
+ async #runTurn(context, session, turnSignal) {
521
617
  while (true) {
522
- if (options.signal?.aborted)
523
- throw Object.assign(new Error('Operation aborted'), { name: 'AbortError' });
524
- if (state.pendingRunBudgetInteraction) {
525
- if (state.runBudgetInteractionDecision === 'stop') {
526
- state.pendingRunBudgetInteraction = undefined;
527
- state.runBudgetInteractionDecision = undefined;
528
- return finish(stream, state, queue);
529
- }
530
- if (state.runBudgetInteractionDecision === 'continue') {
531
- state.pendingRunBudgetInteraction = undefined;
532
- state.runBudgetInteractionDecision = undefined;
533
- }
534
- else {
535
- return this.#pauseForRunBudgetInteraction(state, stream, queue);
536
- }
537
- }
538
- if (state.toolExecution.pendingApprovals.length > 0 && state.approvalDecision) {
539
- state.toolExecution.resolveApproval({ callId: state.approvalDecisionCallId, interruption: state.approvalDecisionInterruption }, state.approvalDecision, state.approvalMessage, state.approvals);
540
- state.approvalDecision = undefined;
541
- state.approvalDecisionCallId = undefined;
542
- state.approvalDecisionInterruption = undefined;
543
- state.approvalMessage = undefined;
544
- await state.toolExecution.settle(this.#toolCallContext(state, stream, queue, toolContext));
545
- stream.interruptions = state.toolExecution.pendingApprovals.map((item) => item.interruption);
546
- if (state.toolExecution.pendingApprovals.length > 0) {
547
- return finish(stream, state, queue);
618
+ try {
619
+ turnSignal?.throwIfAborted();
620
+ if (!context) {
621
+ const start = await session.prepare(turnSignal);
622
+ turnSignal?.throwIfAborted();
623
+ if (!start)
624
+ return;
625
+ const options = { ...start.options, signal: turnSignal };
626
+ const state = this.#createState(start.agent, start.input, options);
627
+ this.#refreshContinuation(state, options);
628
+ context = this.#createView(state, options, (event) => session.acceptEvent(event));
629
+ context.session = session;
630
+ context.turnSignal = turnSignal;
631
+ state.live = context;
632
+ this.#observe(context);
548
633
  }
549
- // Nothing is outstanding, so this segment is terminal — see
550
- // `stopAfterApprovalResolution`. Stopping here rather than at the
551
- // request boundary below is what keeps the just-recorded tool result
552
- // in the committed history.
553
- if (options.stopAfterApprovalResolution)
554
- return finish(stream, state, queue);
634
+ this.#assertLive(context);
635
+ if (await this.#prepareRequest(context))
636
+ return await this.#completeTurn(context);
637
+ const response = await this.#askModel(context);
638
+ this.#assertLive(context);
639
+ const calls = this.#recordResponse(context, response);
640
+ if (await this.#executeTools(context, response, calls))
641
+ return await this.#completeTurn(context);
642
+ if (calls.length === 0 && this.#finishTurn(context))
643
+ return await this.#completeTurn(context);
555
644
  }
556
- // A budget interaction can park a response after its tool calls are
557
- // recorded but before any body runs. Resume those retained calls only
558
- // after processing an ordinary approval decision for that same plan.
559
- if (state.toolExecution.hasPlan) {
560
- await state.toolExecution.settle(this.#toolCallContext(state, stream, queue, toolContext));
561
- if (state.toolExecution.pendingApprovals.length) {
562
- stream.interruptions = state.toolExecution.pendingApprovals.map((item) => item.interruption);
563
- return finish(stream, state, queue);
645
+ catch (error) {
646
+ if (!session)
647
+ throw error;
648
+ if (context) {
649
+ this.#settleSegment(context, undefined, error);
650
+ await context.observation?.catch(() => undefined);
564
651
  }
652
+ turnSignal?.throwIfAborted();
653
+ if (!(await session.recover(error)))
654
+ return;
655
+ context = undefined;
565
656
  }
566
- // The request boundary: every tool result of the previous round is in
567
- // history, and the next request has not been built. A user message
568
- // admitted here reaches the model in sequence, mid-turn.
569
- if (!state.requestBoundaryReady) {
570
- this.#admitPendingSteers(state, stream, queue);
571
- this.#evaluateRunBudget(state, stream, queue);
572
- if (state.pendingRunBudgetInteraction)
573
- return this.#pauseForRunBudgetInteraction(state, stream, queue);
574
- const compactionHistoryResult = state.usesDeltaHistory
575
- ? state.compactionHistory
576
- ? mergeChainedCompactionHistory(state.compactionHistory, state.history)
577
- : { kind: 'skipped', reason: 'missing_snapshot' }
578
- : { kind: 'ready', history: state.history };
579
- const boundaryDecision = options.onRequestBoundary?.(compactionHistoryResult.kind === 'ready' ? compactionHistoryResult.history : state.history, (text) => this.#queuePendingSystemNotice(text), {
580
- ...(state.lastCompletedInputTokens !== undefined
581
- ? { lastCompletedInputTokens: state.lastCompletedInputTokens }
582
- : {}),
583
- ...(compactionHistoryResult.kind === 'skipped'
584
- ? { compactionSkipReason: compactionHistoryResult.reason }
585
- : {}),
586
- });
587
- const compactionHistory = compactionHistoryResult.kind === 'ready' ? compactionHistoryResult.history : undefined;
588
- if (options.boundaryCompaction && compactionHistory && !boundaryDecision?.deferCompaction) {
589
- const compactionStartedAt = Date.now();
590
- let costsRecordedLive = false;
591
- const compaction = await options.boundaryCompaction.compact({
592
- history: compactionHistory,
593
- instructions: state.agent.resolveInstructionsForRequest?.() ?? state.agent.instructions,
594
- tools: toModelTools(state.agent.tools),
595
- recordCostRecords: (records) => {
596
- if (records.length) {
597
- costsRecordedLive = true;
598
- state.costRecords ??= [];
599
- state.costRecords.push(...records);
600
- for (const record of records)
601
- queue.push({ type: 'cost_update', record });
602
- this.#evaluateRunBudget(state, stream, queue);
603
- }
604
- return !state.pendingRunBudgetInteraction && !state.criticalWrapUpPending;
605
- },
606
- automaticCompactionsThisRun: state.automaticCompactionsThisRun ?? 0,
607
- lastCompletedInputTokens: state.lastCompletedInputTokens,
608
- signal: options.signal,
609
- onStarted: (provider) => outputPush(stream, queue, { type: 'context_compaction_started', provider, strategy: 'local' }),
610
- });
611
- if (compaction.kind !== 'unchanged' && !costsRecordedLive && compaction.costRecords?.length) {
657
+ }
658
+ }
659
+ #assertLive(context) {
660
+ if (context.options.signal?.aborted)
661
+ throw Object.assign(new Error('Operation aborted'), { name: 'AbortError' });
662
+ }
663
+ /** Admit input and prepare context once for the next logical model round. */
664
+ async #prepareRequest(context) {
665
+ let { state, stream, queue, options } = context;
666
+ // The request boundary: every tool result of the previous round is in
667
+ // history, and the next request has not been built. A user message
668
+ // admitted here reaches the model in sequence, mid-turn.
669
+ this.#admitPendingSteers(state, stream, queue);
670
+ this.#evaluateRunBudget(state, stream, queue);
671
+ if (await this.#waitForBudget(context))
672
+ return true;
673
+ ({ state, stream, queue, options } = context);
674
+ const compactionHistoryResult = state.usesDeltaHistory
675
+ ? state.compactionHistory
676
+ ? mergeChainedCompactionHistory(state.compactionHistory, state.history)
677
+ : { kind: 'skipped', reason: 'missing_snapshot' }
678
+ : { kind: 'ready', history: state.history };
679
+ const boundaryDecision = options.onRequestBoundary?.(compactionHistoryResult.kind === 'ready' ? compactionHistoryResult.history : state.history, (text) => this.#queuePendingSystemNotice(text), {
680
+ ...(state.lastCompletedInputTokens !== undefined
681
+ ? { lastCompletedInputTokens: state.lastCompletedInputTokens }
682
+ : {}),
683
+ ...(compactionHistoryResult.kind === 'skipped' ? { compactionSkipReason: compactionHistoryResult.reason } : {}),
684
+ });
685
+ const compactionHistory = compactionHistoryResult.kind === 'ready' ? compactionHistoryResult.history : undefined;
686
+ if (options.boundaryCompaction && compactionHistory && !boundaryDecision?.deferCompaction) {
687
+ const compactionStartedAt = Date.now();
688
+ let costsRecordedLive = false;
689
+ const compaction = await options.boundaryCompaction.compact({
690
+ history: compactionHistory,
691
+ instructions: state.agent.resolveInstructionsForRequest?.() ?? state.agent.instructions,
692
+ tools: toModelTools(state.agent.tools),
693
+ recordCostRecords: (records) => {
694
+ if (records.length) {
695
+ costsRecordedLive = true;
612
696
  state.costRecords ??= [];
613
- state.costRecords.push(...compaction.costRecords);
697
+ state.costRecords.push(...records);
698
+ for (const record of records)
699
+ queue.push({ type: 'cost_update', record });
700
+ this.#evaluateRunBudget(state, stream, queue);
614
701
  }
615
- if (compaction.kind === 'compacted') {
616
- state.inputUsageInvalidatedByCompaction = true;
617
- state.lastCompletedInputTokens = undefined;
618
- state.compactedDuringRun = true;
619
- state.history.splice(0, state.history.length, ...compaction.history);
620
- state.input.splice(0, state.input.length, ...normalizeApplicationInput(compaction.modelInput));
621
- state.responseId = undefined;
622
- state.responseProviderId = undefined;
623
- state.usesDeltaHistory = false;
624
- state.automaticCompactionsThisRun = (state.automaticCompactionsThisRun ?? 0) + 1;
625
- outputPush(stream, queue, {
626
- type: 'context_compaction_completed',
627
- provider: state.currentProviderId ?? 'unknown',
628
- strategy: 'local',
629
- durationMs: Math.max(0, Date.now() - compactionStartedAt),
630
- });
631
- }
632
- else if (compaction.kind === 'failed') {
633
- outputPush(stream, queue, {
634
- type: 'context_compaction_failed',
635
- provider: compaction.provider,
636
- strategy: 'local',
637
- durationMs: Math.max(0, Date.now() - compactionStartedAt),
638
- });
639
- if (compaction.error)
640
- throw compaction.error;
641
- }
642
- else if (compaction.notice && compaction.notice !== state.compactionNotice) {
643
- state.compactionNotice = compaction.notice;
644
- this.#queuePendingSystemNotice(compaction.notice);
645
- }
646
- // Summarization is an asynchronous part of this same request
647
- // boundary. Admit anything that arrived while it was in flight after
648
- // applying a replacement, so the steer cannot be overwritten by the
649
- // checkpoint or miss a terminal model request.
650
- this.#admitPendingSteers(state, stream, queue);
651
- this.#evaluateRunBudget(state, stream, queue);
652
- if (state.pendingRunBudgetInteraction)
653
- return this.#pauseForRunBudgetInteraction(state, stream, queue);
654
- }
655
- this.#admitPendingSteers(state, stream, queue);
656
- state.turnCount += 1;
657
- this.#evaluateRunBudget(state, stream, queue);
658
- if (state.pendingRunBudgetInteraction) {
659
- state.requestBoundaryReady = true;
660
- return this.#pauseForRunBudgetInteraction(state, stream, queue);
661
- }
662
- // Retain the legacy error only for callers that have not adopted staged
663
- // budgets. A configured run budget turns the same count into critical
664
- // evidence and lets the decision surface choose what happens next.
665
- if (!state.runBudget && state.maxTurns !== undefined && state.turnCount > state.maxTurns) {
666
- throw new MaxTurnsExceededError(state.maxTurns);
667
- }
702
+ return !state.pendingRunBudgetInteraction && !state.criticalWrapUpPending;
703
+ },
704
+ automaticCompactionsThisRun: state.automaticCompactionsThisRun ?? 0,
705
+ lastCompletedInputTokens: state.lastCompletedInputTokens,
706
+ signal: options.signal,
707
+ onStarted: (provider) => outputPush(stream, queue, { type: 'context_compaction_started', provider, strategy: 'local' }),
708
+ });
709
+ if (compaction.kind !== 'unchanged' && !costsRecordedLive && compaction.costRecords?.length) {
710
+ state.costRecords ??= [];
711
+ state.costRecords.push(...compaction.costRecords);
668
712
  }
669
- else {
670
- state.requestBoundaryReady = undefined;
713
+ if (compaction.kind === 'compacted') {
714
+ state.inputUsageInvalidatedByCompaction = true;
715
+ state.lastCompletedInputTokens = undefined;
716
+ state.compactedDuringRun = true;
717
+ state.history.splice(0, state.history.length, ...compaction.history);
718
+ state.input.splice(0, state.input.length, ...normalizeApplicationInput(compaction.modelInput));
719
+ state.responseId = undefined;
720
+ state.responseProviderId = undefined;
721
+ state.usesDeltaHistory = false;
722
+ state.automaticCompactionsThisRun = (state.automaticCompactionsThisRun ?? 0) + 1;
723
+ outputPush(stream, queue, {
724
+ type: 'context_compaction_completed',
725
+ provider: state.currentProviderId ?? 'unknown',
726
+ strategy: 'local',
727
+ durationMs: Math.max(0, Date.now() - compactionStartedAt),
728
+ });
671
729
  }
672
- const model = await this.#deps.resolveModel(state.agent.model);
673
- const maxRetries = state.agent.modelSettings?.retry?.maxRetries ?? 0;
674
- let attempt = 0;
675
- let streamedToolCalls = [];
676
- let completion;
677
- let sawToolCall = false;
678
- let activeRequestId = '';
679
- let activeRequest;
680
- let pendingNativeReasoning;
681
- let deferredNativeReasoning = [];
682
- let criticalWrapUp = false;
683
- while (true) {
684
- streamedToolCalls = [];
685
- completion = undefined;
686
- sawToolCall = false;
687
- pendingNativeReasoning = undefined;
688
- deferredNativeReasoning = [];
689
- // Stable process-unique request id, allocated immediately before dispatch
690
- // so a cost record can be settled exactly once (success or failure).
691
- activeRequestId = this.#nextRequestId();
692
- const requestId = activeRequestId;
693
- const outputLengthBefore = stream.output.length;
694
- const newItemsLengthBefore = stream.newItems.length;
695
- const inputLengthBefore = state.input.length;
696
- const historyLengthBefore = state.history.length;
697
- const criticalWrapUpDispatchedBefore = state.criticalWrapUpDispatched;
698
- let provisionalTextCharacters = 0;
699
- let provisionalReasoningCharacters = 0;
700
- let provisionalTextDeltas = 0;
701
- let provisionalReasoningDeltas = 0;
702
- let lastTextPartId;
703
- criticalWrapUp = state.criticalWrapUpPending === true && state.criticalWrapUpDispatched !== true;
704
- if (criticalWrapUp)
705
- state.criticalWrapUpDispatched = true;
706
- const disableChaining = state.disableChainingForAttempt === true;
707
- if (disableChaining) {
708
- state.disableChainingForAttempt = false;
709
- state.responseId = undefined;
710
- state.responseProviderId = undefined;
711
- }
712
- // A per-request signal lets a recoverable guard trip cancel only this
713
- // provider request; the segment signal still cancels everything.
714
- const requestSignal = new AbortController();
715
- const abortRequest = () => requestSignal.abort();
716
- if (options.signal?.aborted)
717
- requestSignal.abort();
718
- else
719
- options.signal?.addEventListener('abort', abortRequest, { once: true });
720
- const requestInstructions = state.agent.resolveInstructionsForRequest?.() ?? state.agent.instructions;
721
- // Same synchronous step as the instructions above, so the snapshot is
722
- // exactly the state the model is shown in this request.
723
- state.requestSnapshot = state.agent.resolveRequestSnapshot?.();
724
- const requestCatalog = getCatalogModel(state.currentProviderId ?? 'openai', state.agent.model);
725
- const effectiveOutput = resolveRequestOutput(requestCatalog, state.agent.modelSettings?.maxTokens);
726
- const request = {
727
- instructions: criticalWrapUp
728
- ? `${requestInstructions}\n\nBudget containment is terminal. Do not call tools. In this one final response, summarize what you completed, the evidence you have, and what remains.`
729
- : requestInstructions,
730
- ...(state.supportsConversationChaining &&
731
- !disableChaining &&
732
- state.responseId &&
733
- state.responseProviderId !== undefined &&
734
- state.responseProviderId === state.currentProviderId
735
- ? { previousResponseId: state.responseId }
736
- : {}),
737
- ...(disableChaining ? { disableChaining: true } : {}),
738
- input: projectModelRequestHistory(state.input),
739
- tools: toModelTools(criticalWrapUp ? [] : state.agent.tools),
740
- applicationTools: criticalWrapUp ? [] : state.agent.tools,
741
- ...(state.agent.modelSettings?.temperature !== undefined
742
- ? { temperature: state.agent.modelSettings.temperature }
743
- : {}),
744
- ...(state.agent.modelSettings?.reasoning ? { reasoning: state.agent.modelSettings.reasoning } : {}),
745
- ...(effectiveOutput !== undefined ? { maxTokens: effectiveOutput } : {}),
746
- ...(state.agent.outputType !== undefined ? { outputType: state.agent.outputType } : {}),
747
- ...(state.agent.modelSettings?.codex ? { codex: state.agent.modelSettings.codex } : {}),
748
- ...(state.agent.modelSettings?.providerData
749
- ? {
750
- providerOptions: this.#contextCompactionSessionState.disabled
751
- ? Object.fromEntries(Object.entries(state.agent.modelSettings.providerData).filter(([key]) => key !== 'contextCompaction'))
752
- : state.agent.modelSettings.providerData,
753
- }
754
- : {}),
755
- signal: requestSignal.signal,
756
- ...(state.recoveryBudget ? { recoveryBudget: state.recoveryBudget } : {}),
757
- };
758
- activeRequest = request;
759
- const generationGuard = new GenerationGuard({
760
- maxOutputCharacters: state.agent.modelSettings?.maxStreamOutputChars,
761
- maxTextCharacters: state.agent.modelSettings?.maxStreamOutputChars,
762
- maxReasoningCharacters: state.agent.modelSettings?.maxStreamOutputChars,
763
- maxToolArgumentCharacters: state.agent.modelSettings?.maxStreamOutputChars,
764
- maxCumulativeToolArgumentCharacters: state.agent.modelSettings?.maxStreamOutputChars,
765
- requestDeadlineMs: state.agent.modelSettings?.maxModelRequestDurationMs,
766
- maxStreamIdleMs: state.agent.modelSettings?.maxModelStreamIdleMs,
767
- ...options.generationGuard,
730
+ else if (compaction.kind === 'failed') {
731
+ outputPush(stream, queue, {
732
+ type: 'context_compaction_failed',
733
+ provider: compaction.provider,
734
+ strategy: 'local',
735
+ durationMs: Math.max(0, Date.now() - compactionStartedAt),
768
736
  });
769
- const consume = async () => {
770
- const deadline = new GenerationStreamDeadlines({ totalMs: generationGuard.requestDeadlineMs, idleMs: generationGuard.maxStreamIdleMs }, () => requestAbortController.abort(), () => generationGuard.progress);
771
- const toolArgumentRunaway = isLunaChainedRequest(state, request)
772
- ? new ToolArgumentRunawayGuard({
773
- ...DEFAULT_TOOL_ARGUMENT_RUNAWAY_SIGNATURE,
774
- timeoutMs: generationGuard.toolArgumentRunawayMs,
775
- }, () => requestAbortController.abort())
776
- : undefined;
777
- let iterator;
778
- let iteratorFinished = false;
779
- try {
780
- iterator = model.stream(request)[Symbol.asyncIterator]();
781
- while (true) {
782
- const nextOperation = iterator.next();
783
- const next = await deadline.wait(toolArgumentRunaway ? toolArgumentRunaway.wait(nextOperation) : nextOperation);
784
- if (next.done) {
785
- iteratorFinished = true;
786
- return;
787
- }
788
- // Any streamed event is transport activity: re-arm the inactivity
789
- // window so long legitimate reasoning is never mistaken for a stall.
790
- deadline.recordActivity();
791
- const event = next.value;
792
- if (event.type === 'completion') {
793
- toolArgumentRunaway?.observeToolCallCompleted();
794
- generationGuard.observeCompletion(event.output);
795
- completion = event;
796
- continue;
797
- }
798
- if (event.type === 'text_delta') {
799
- const startsNewPart = event.partId !== undefined &&
800
- lastTextPartId !== undefined &&
801
- event.partId !== lastTextPartId &&
802
- event.text.length > 0;
803
- if (event.partId !== undefined && event.text.length > 0)
804
- lastTextPartId = event.partId;
805
- const text = startsNewPart ? `${ASSISTANT_TEXT_PART_SEPARATOR}${event.text}` : event.text;
806
- toolArgumentRunaway?.observeText();
807
- generationGuard.observeText(text);
808
- provisionalTextCharacters += text.length;
809
- provisionalTextDeltas++;
810
- outputPush(stream, queue, { type: 'text_delta', text });
811
- continue;
812
- }
813
- if (event.type === 'codex_rate_limits') {
814
- outputPush(stream, queue, { type: 'codex_rate_limits', rateLimits: event.rateLimits });
815
- continue;
816
- }
817
- if (event.type === 'reasoning_delta') {
818
- const accepted = generationGuard.observeReasoning(event.text);
819
- if (!accepted)
820
- continue;
821
- const forwarded = accepted === event.text ? event : { ...event, text: accepted };
822
- pendingNativeReasoning = appendNativeReasoning(pendingNativeReasoning, forwarded);
823
- provisionalReasoningCharacters += accepted.length;
824
- provisionalReasoningDeltas++;
825
- outputPush(stream, queue, { type: 'reasoning_delta', text: accepted });
826
- continue;
827
- }
828
- if (event.type === 'tool_call_streaming_delta') {
829
- toolArgumentRunaway?.observeToolArgumentProgress(event.argumentCharCount);
830
- generationGuard.observeToolArgumentProgress(event.argumentCharCount);
831
- outputPush(stream, queue, event);
832
- continue;
833
- }
834
- if (event.type === 'context_compaction_started' || event.type === 'context_compaction_completed') {
835
- outputPush(stream, queue, event);
836
- continue;
837
- }
838
- if (event.type === 'tool_call') {
839
- toolArgumentRunaway?.observeToolCallCompleted();
840
- generationGuard.observeToolCall(event.arguments);
841
- // The authoritative completion may introduce a native
842
- // replacement boundary before this call's reasoning.
843
- if (pendingNativeReasoning)
844
- deferredNativeReasoning.push(pendingNativeReasoning);
845
- pendingNativeReasoning = undefined;
846
- sawToolCall = true;
847
- streamedToolCalls.push(event);
848
- }
849
- }
850
- }
851
- finally {
852
- deadline.dispose();
853
- toolArgumentRunaway?.dispose();
854
- if (iterator && !iteratorFinished)
855
- void iterator.return?.().catch(() => undefined);
737
+ if (compaction.error)
738
+ throw compaction.error;
739
+ }
740
+ else if (compaction.notice && compaction.notice !== state.compactionNotice) {
741
+ state.compactionNotice = compaction.notice;
742
+ this.#queuePendingSystemNotice(compaction.notice);
743
+ }
744
+ // Summarization is an asynchronous part of this same request
745
+ // boundary. Admit anything that arrived while it was in flight after
746
+ // applying a replacement, so the steer cannot be overwritten by the
747
+ // checkpoint or miss a terminal model request.
748
+ this.#admitPendingSteers(state, stream, queue);
749
+ this.#evaluateRunBudget(state, stream, queue);
750
+ if (await this.#waitForBudget(context))
751
+ return true;
752
+ ({ state, stream, queue, options } = context);
753
+ }
754
+ this.#admitPendingSteers(state, stream, queue);
755
+ state.turnCount += 1;
756
+ this.#evaluateRunBudget(state, stream, queue);
757
+ if (await this.#waitForBudget(context))
758
+ return true;
759
+ ({ state, stream, queue, options } = context);
760
+ // Retain the legacy error only for callers that have not adopted staged
761
+ // budgets. A configured run budget turns the same count into critical
762
+ // evidence and lets the decision surface choose what happens next.
763
+ if (!state.runBudget && state.maxTurns !== undefined && state.turnCount > state.maxTurns) {
764
+ throw new MaxTurnsExceededError(state.maxTurns);
765
+ }
766
+ return false;
767
+ }
768
+ /** Own streaming and safe request attempts; never dispatch tools here. */
769
+ async #askModel(context) {
770
+ const { state, stream, queue, options, requestAbortController } = context;
771
+ const model = await this.#deps.resolveModel(state.agent.model);
772
+ const maxRetries = state.agent.modelSettings?.retry?.maxRetries ?? 0;
773
+ let attempt = 0;
774
+ let streamedToolCalls = [];
775
+ let completion;
776
+ let sawToolCall = false;
777
+ let activeRequestId = '';
778
+ let activeRequest;
779
+ let pendingNativeReasoning;
780
+ let deferredNativeReasoning = [];
781
+ let criticalWrapUp = false;
782
+ while (true) {
783
+ streamedToolCalls = [];
784
+ completion = undefined;
785
+ sawToolCall = false;
786
+ pendingNativeReasoning = undefined;
787
+ deferredNativeReasoning = [];
788
+ // Stable process-unique request id, allocated immediately before dispatch
789
+ // so a cost record can be settled exactly once (success or failure).
790
+ activeRequestId = this.#nextRequestId();
791
+ const requestId = activeRequestId;
792
+ const outputLengthBefore = stream.output.length;
793
+ const newItemsLengthBefore = stream.newItems.length;
794
+ const inputLengthBefore = state.input.length;
795
+ const historyLengthBefore = state.history.length;
796
+ const criticalWrapUpDispatchedBefore = state.criticalWrapUpDispatched;
797
+ let provisionalTextCharacters = 0;
798
+ let provisionalReasoningCharacters = 0;
799
+ let provisionalTextDeltas = 0;
800
+ let provisionalReasoningDeltas = 0;
801
+ let lastTextPartId;
802
+ criticalWrapUp = state.criticalWrapUpPending === true && state.criticalWrapUpDispatched !== true;
803
+ if (criticalWrapUp)
804
+ state.criticalWrapUpDispatched = true;
805
+ const disableChaining = state.disableChainingForAttempt === true;
806
+ if (disableChaining) {
807
+ state.disableChainingForAttempt = false;
808
+ state.responseId = undefined;
809
+ state.responseProviderId = undefined;
810
+ }
811
+ // A per-request signal lets a recoverable guard trip cancel only this
812
+ // provider request; the segment signal still cancels everything.
813
+ const requestSignal = new AbortController();
814
+ const abortRequest = () => requestSignal.abort();
815
+ if (options.signal?.aborted)
816
+ requestSignal.abort();
817
+ else
818
+ options.signal?.addEventListener('abort', abortRequest, { once: true });
819
+ const requestInstructions = state.agent.resolveInstructionsForRequest?.() ?? state.agent.instructions;
820
+ // Same synchronous step as the instructions above, so the snapshot is
821
+ // exactly the state the model is shown in this request.
822
+ state.requestSnapshot = state.agent.resolveRequestSnapshot?.();
823
+ const requestCatalog = getCatalogModel(state.currentProviderId ?? 'openai', state.agent.model);
824
+ const effectiveOutput = resolveRequestOutput(requestCatalog, state.agent.modelSettings?.maxTokens);
825
+ const request = {
826
+ instructions: criticalWrapUp
827
+ ? `${requestInstructions}\n\nBudget containment is terminal. Do not call tools. In this one final response, summarize what you completed, the evidence you have, and what remains.`
828
+ : requestInstructions,
829
+ ...(state.supportsConversationChaining &&
830
+ !disableChaining &&
831
+ state.responseId &&
832
+ state.responseProviderId !== undefined &&
833
+ state.responseProviderId === state.currentProviderId
834
+ ? { previousResponseId: state.responseId }
835
+ : {}),
836
+ ...(disableChaining ? { disableChaining: true } : {}),
837
+ input: projectModelRequestHistory(state.input),
838
+ tools: toModelTools(criticalWrapUp ? [] : state.agent.tools),
839
+ applicationTools: criticalWrapUp ? [] : state.agent.tools,
840
+ ...(state.agent.modelSettings?.temperature !== undefined
841
+ ? { temperature: state.agent.modelSettings.temperature }
842
+ : {}),
843
+ ...(state.agent.modelSettings?.reasoning ? { reasoning: state.agent.modelSettings.reasoning } : {}),
844
+ ...(effectiveOutput !== undefined ? { maxTokens: effectiveOutput } : {}),
845
+ ...(state.agent.outputType !== undefined ? { outputType: state.agent.outputType } : {}),
846
+ ...(state.agent.modelSettings?.codex ? { codex: state.agent.modelSettings.codex } : {}),
847
+ ...(state.agent.modelSettings?.providerData
848
+ ? {
849
+ providerOptions: this.#contextCompactionSessionState.disabled
850
+ ? Object.fromEntries(Object.entries(state.agent.modelSettings.providerData).filter(([key]) => key !== 'contextCompaction'))
851
+ : state.agent.modelSettings.providerData,
856
852
  }
857
- };
858
- const dispatch = async () => {
859
- state.requestPreparation?.prepare(request);
860
- // A response ID can be established after a self-contained request.
861
- // It does not make this run's full history a delta needing a snapshot.
862
- const needsChainedSnapshot = Boolean(request.previousResponseId && state.usesDeltaHistory);
863
- const fullContext = needsChainedSnapshot && state.compactionHistory
864
- ? mergeChainedCompactionHistory(state.compactionHistory, state.history)
865
- : undefined;
866
- const contextCatalog = getCatalogModel(state.currentProviderId ?? 'openai', state.agent.model);
867
- enforceRequestInputLimit({
868
- contextWindow: contextCatalog?.contextWindow,
869
- maxOutputTokens: request.maxTokens,
870
- limit: state.requestInputLimit
871
- ? state.requestInputLimit()
872
- : state.agent.modelSettings?.maxRequestInputTokens,
873
- history: fullContext?.kind === 'ready'
874
- ? fullContext.history
875
- : needsChainedSnapshot
876
- ? [...(state.compactionHistory ?? []), ...request.input]
877
- : request.input,
878
- instructions: request.instructions ?? '',
879
- tools: request.tools,
880
- lastCompletedInputTokens: state.inputUsageInvalidatedByCompaction
881
- ? undefined
882
- : state.lastCompletedInputTokens,
883
- chainedContextKnown: !needsChainedSnapshot || fullContext?.kind === 'ready',
884
- });
885
- await consume();
886
- };
853
+ : {}),
854
+ signal: requestSignal.signal,
855
+ ...(state.recoveryBudget ? { recoveryBudget: state.recoveryBudget } : {}),
856
+ };
857
+ activeRequest = request;
858
+ const generationGuard = new GenerationGuard({
859
+ maxOutputCharacters: state.agent.modelSettings?.maxStreamOutputChars,
860
+ maxTextCharacters: state.agent.modelSettings?.maxStreamOutputChars,
861
+ maxReasoningCharacters: state.agent.modelSettings?.maxStreamOutputChars,
862
+ maxToolArgumentCharacters: state.agent.modelSettings?.maxStreamOutputChars,
863
+ maxCumulativeToolArgumentCharacters: state.agent.modelSettings?.maxStreamOutputChars,
864
+ requestDeadlineMs: state.agent.modelSettings?.maxModelRequestDurationMs,
865
+ maxStreamIdleMs: state.agent.modelSettings?.maxModelStreamIdleMs,
866
+ ...options.generationGuard,
867
+ });
868
+ const consume = async () => {
869
+ const deadline = new GenerationStreamDeadlines({ totalMs: generationGuard.requestDeadlineMs, idleMs: generationGuard.maxStreamIdleMs }, () => requestAbortController.abort(), () => generationGuard.progress);
870
+ const toolArgumentRunaway = isLunaChainedRequest(state, request)
871
+ ? new ToolArgumentRunawayGuard({
872
+ ...DEFAULT_TOOL_ARGUMENT_RUNAWAY_SIGNATURE,
873
+ timeoutMs: generationGuard.toolArgumentRunawayMs,
874
+ }, () => requestAbortController.abort())
875
+ : undefined;
876
+ let iterator;
877
+ let iteratorFinished = false;
887
878
  try {
888
- if (state.requestPreparation)
889
- await state.requestPreparation.run(dispatch);
890
- else
891
- await dispatch();
892
- break;
893
- }
894
- catch (error) {
895
- if (error instanceof RequestInputLimitError) {
896
- this.#deps.logDiagnostic?.('Request input admission refused', {
897
- code: error.code,
898
- action: error.action,
899
- reason: error.reason,
900
- guardClass: 'admission',
901
- setting: 'agent.maxRequestInputTokens',
902
- executionScope: state.hookScope ?? 'root',
903
- recovery: 'retained_work',
904
- limit: error.limit,
905
- estimatedTokens: error.estimatedTokens,
906
- observedTokens: error.observedTokens,
907
- capacity: error.capacity,
908
- outputReserve: error.outputReserve,
909
- estimationReserve: error.estimationReserve,
910
- });
911
- throw error;
912
- }
913
- if (isProviderContextOverflow(error)) {
914
- this.#observeTerminalFailure(error, {
915
- requestId: activeRequestId,
916
- provider: state.currentProviderId,
917
- model: state.agent.model,
918
- tier: resolveServiceTier(request),
919
- });
920
- throw new ProviderContextOverflowError(error);
921
- }
922
- const recoverOversizedToolArgument = isOversizedToolArgumentTrip(error) &&
923
- (state.oversizedToolArgumentRecoveries ?? 0) < MAX_OVERSIZED_TOOL_ARGUMENT_RECOVERIES &&
924
- !options.signal?.aborted;
925
- if (recoverOversizedToolArgument)
926
- requestSignal.abort();
927
- else if (error instanceof GenerationGuardError)
928
- requestAbortController.abort();
929
- const decision = classifyInLoopModelRetry(error, attempt, maxRetries, Math.random, {
930
- previousResponseId: activeRequest?.previousResponseId,
931
- });
932
- const rollbackProvisionalAttempt = () => {
933
- stream.output.splice(outputLengthBefore);
934
- if (stream.newItems !== stream.output) {
935
- stream.newItems.splice(newItemsLengthBefore);
879
+ iterator = model.stream(request)[Symbol.asyncIterator]();
880
+ while (true) {
881
+ const nextOperation = iterator.next();
882
+ const next = await deadline.wait(toolArgumentRunaway ? toolArgumentRunaway.wait(nextOperation) : nextOperation);
883
+ if (next.done) {
884
+ iteratorFinished = true;
885
+ return;
936
886
  }
937
- if (provisionalTextDeltas > 0 || provisionalReasoningDeltas > 0) {
938
- queue.push({
939
- type: 'model_attempt_rollback',
940
- textCharacters: provisionalTextCharacters,
941
- reasoningCharacters: provisionalReasoningCharacters,
942
- textDeltas: provisionalTextDeltas,
943
- reasoningDeltas: provisionalReasoningDeltas,
944
- });
887
+ // Any streamed event is transport activity: re-arm the inactivity
888
+ // window so long legitimate reasoning is never mistaken for a stall.
889
+ deadline.recordActivity();
890
+ const event = next.value;
891
+ if (event.type === 'completion') {
892
+ toolArgumentRunaway?.observeToolCallCompleted();
893
+ generationGuard.observeCompletion(event.output);
894
+ completion = event;
895
+ continue;
945
896
  }
946
- };
947
- if (recoverOversizedToolArgument) {
948
- // No tool ran: a call is dispatched only after its arguments
949
- // complete. Discard the partial response and ask for smaller calls.
950
- state.oversizedToolArgumentRecoveries = (state.oversizedToolArgumentRecoveries ?? 0) + 1;
951
- state.input.splice(inputLengthBefore);
952
- state.history.splice(historyLengthBefore);
953
- state.criticalWrapUpDispatched = criticalWrapUpDispatchedBefore;
954
- rollbackProvisionalAttempt();
955
- this.#deps.logDiagnostic?.('Recovering oversized tool argument in run loop', {
956
- code: error.code,
957
- recovery: state.oversizedToolArgumentRecoveries,
958
- maxRecoveries: MAX_OVERSIZED_TOOL_ARGUMENT_RECOVERIES,
959
- });
960
- this.#queuePendingSystemNotice(oversizedToolArgumentNotice(error.message));
961
- this.#admitPendingSteers(state, stream, queue);
962
- continue;
963
- }
964
- if (decision.retryable && !options.signal?.aborted) {
965
- attempt++;
966
- state.input.splice(inputLengthBefore);
967
- state.history.splice(historyLengthBefore);
968
- state.criticalWrapUpDispatched = criticalWrapUpDispatchedBefore;
969
- rollbackProvisionalAttempt();
970
- if (decision.kind === 'chain_recovery') {
971
- state.disableChainingForAttempt = true;
972
- state.responseId = undefined;
973
- state.responseProviderId = undefined;
897
+ if (event.type === 'text_delta') {
898
+ const startsNewPart = event.partId !== undefined &&
899
+ lastTextPartId !== undefined &&
900
+ event.partId !== lastTextPartId &&
901
+ event.text.length > 0;
902
+ if (event.partId !== undefined && event.text.length > 0)
903
+ lastTextPartId = event.partId;
904
+ const text = startsNewPart ? `${ASSISTANT_TEXT_PART_SEPARATOR}${event.text}` : event.text;
905
+ toolArgumentRunaway?.observeText();
906
+ generationGuard.observeText(text);
907
+ provisionalTextCharacters += text.length;
908
+ provisionalTextDeltas++;
909
+ outputPush(stream, queue, { type: 'text_delta', text });
910
+ continue;
911
+ }
912
+ if (event.type === 'codex_rate_limits') {
913
+ outputPush(stream, queue, { type: 'codex_rate_limits', rateLimits: event.rateLimits });
914
+ continue;
915
+ }
916
+ if (event.type === 'reasoning_delta') {
917
+ const accepted = generationGuard.observeReasoning(event.text);
918
+ if (!accepted)
919
+ continue;
920
+ const forwarded = accepted === event.text ? event : { ...event, text: accepted };
921
+ pendingNativeReasoning = appendNativeReasoning(pendingNativeReasoning, forwarded);
922
+ provisionalReasoningCharacters += accepted.length;
923
+ provisionalReasoningDeltas++;
924
+ outputPush(stream, queue, { type: 'reasoning_delta', text: accepted });
925
+ continue;
926
+ }
927
+ if (event.type === 'tool_call_streaming_delta') {
928
+ toolArgumentRunaway?.observeToolArgumentProgress(event.argumentCharCount);
929
+ generationGuard.observeToolArgumentProgress(event.argumentCharCount);
930
+ outputPush(stream, queue, event);
931
+ continue;
932
+ }
933
+ if (event.type === 'context_compaction_started' || event.type === 'context_compaction_completed') {
934
+ outputPush(stream, queue, event);
935
+ continue;
936
+ }
937
+ if (event.type === 'tool_call') {
938
+ toolArgumentRunaway?.observeToolCallCompleted();
939
+ generationGuard.observeToolCall(event.arguments);
940
+ // The authoritative completion may introduce a native
941
+ // replacement boundary before this call's reasoning.
942
+ if (pendingNativeReasoning)
943
+ deferredNativeReasoning.push(pendingNativeReasoning);
944
+ pendingNativeReasoning = undefined;
945
+ sawToolCall = true;
946
+ streamedToolCalls.push(event);
974
947
  }
975
- this.#deps.logDiagnostic?.('Retrying model request in run loop', {
976
- attempt,
977
- maxRetries,
978
- kind: decision.kind,
979
- delayMs: decision.delayMs,
980
- error: describeError(error),
981
- });
982
- await (this.#deps.waitBeforeModelRetry ?? sleepWithAbort)(decision.delayMs, options.signal);
983
- continue;
984
- }
985
- // Session recovery owns full-history rebuild for a chained delta.
986
- // Retract this cycle's provisional tokens so a later chain_recovery
987
- // does not append a second answer after a half-sentence.
988
- if (!decision.retryable && decision.reason === 'chained_delta_not_self_contained') {
989
- rollbackProvisionalAttempt();
990
948
  }
991
- // Dispatch began, but no terminal completion was accepted. Record an
992
- // unpriced marker so the summary stays honest (partial) rather than
993
- // appearing exact. Observational only: the error still propagates.
994
- const cancelled = error instanceof Error && error.name === 'AbortError';
995
- const record = this.#appendCostRecord(state, {
996
- requestId,
997
- provider: state.currentProviderId,
998
- model: state.agent.model,
999
- tier: resolveServiceTier(request),
1000
- outcome: cancelled ? 'cancelled' : 'failed',
1001
- });
1002
- // Only the live queue sees cost records: `output`/`newItems` feed
1003
- // persistence and replay, and a cost event must never become a
1004
- // restored history item.
1005
- queue.push({ type: 'cost_update', record });
1006
- this.#evaluateRunBudget(state, stream, queue);
1007
- this.#observeTerminalFailure(error, {
1008
- requestId,
1009
- provider: state.currentProviderId,
1010
- model: state.agent.model,
1011
- tier: resolveServiceTier(request),
1012
- });
1013
- throw error;
1014
949
  }
1015
950
  finally {
1016
- options.signal?.removeEventListener('abort', abortRequest);
951
+ deadline.dispose();
952
+ toolArgumentRunaway?.dispose();
953
+ if (iterator && !iteratorFinished)
954
+ void iterator.return?.().catch(() => undefined);
1017
955
  }
956
+ };
957
+ const dispatch = async () => {
958
+ state.requestPreparation?.prepare(request);
959
+ // A response ID can be established after a self-contained request.
960
+ // It does not make this run's full history a delta needing a snapshot.
961
+ const needsChainedSnapshot = Boolean(request.previousResponseId && state.usesDeltaHistory);
962
+ const fullContext = needsChainedSnapshot && state.compactionHistory
963
+ ? mergeChainedCompactionHistory(state.compactionHistory, state.history)
964
+ : undefined;
965
+ const contextCatalog = getCatalogModel(state.currentProviderId ?? 'openai', state.agent.model);
966
+ enforceRequestInputLimit({
967
+ contextWindow: contextCatalog?.contextWindow,
968
+ maxOutputTokens: request.maxTokens,
969
+ limit: state.requestInputLimit ? state.requestInputLimit() : state.agent.modelSettings?.maxRequestInputTokens,
970
+ history: fullContext?.kind === 'ready'
971
+ ? fullContext.history
972
+ : needsChainedSnapshot
973
+ ? [...(state.compactionHistory ?? []), ...request.input]
974
+ : request.input,
975
+ instructions: request.instructions ?? '',
976
+ tools: request.tools,
977
+ lastCompletedInputTokens: state.inputUsageInvalidatedByCompaction
978
+ ? undefined
979
+ : state.lastCompletedInputTokens,
980
+ chainedContextKnown: !needsChainedSnapshot || fullContext?.kind === 'ready',
981
+ });
982
+ await consume();
983
+ };
984
+ try {
985
+ if (state.requestPreparation)
986
+ await state.requestPreparation.run(dispatch);
987
+ else
988
+ await dispatch();
989
+ break;
1018
990
  }
1019
- if (!completion) {
1020
- // A few application-owned model fixtures (and legacy adapters) end a
1021
- // tool-call stream immediately after the call and use the next request
1022
- // as the continuation boundary. Preserve that contract while routing
1023
- // the calls through the same ordered dispatcher. An empty stream still
1024
- // fails closed as an incomplete model turn.
1025
- if (streamedToolCalls.length === 0) {
1026
- const error = new Error('Application model turn ended without completion');
991
+ catch (error) {
992
+ if (error instanceof RequestInputLimitError) {
993
+ this.#deps.logDiagnostic?.('Request input admission refused', {
994
+ code: error.code,
995
+ action: error.action,
996
+ reason: error.reason,
997
+ guardClass: 'admission',
998
+ setting: 'agent.maxRequestInputTokens',
999
+ executionScope: state.hookScope ?? 'root',
1000
+ recovery: 'retained_work',
1001
+ limit: error.limit,
1002
+ estimatedTokens: error.estimatedTokens,
1003
+ observedTokens: error.observedTokens,
1004
+ capacity: error.capacity,
1005
+ outputReserve: error.outputReserve,
1006
+ estimationReserve: error.estimationReserve,
1007
+ });
1008
+ throw error;
1009
+ }
1010
+ if (isProviderContextOverflow(error)) {
1027
1011
  this.#observeTerminalFailure(error, {
1028
1012
  requestId: activeRequestId,
1029
1013
  provider: state.currentProviderId,
1030
1014
  model: state.agent.model,
1031
- tier: activeRequest ? resolveServiceTier(activeRequest) : 'standard',
1015
+ tier: resolveServiceTier(request),
1032
1016
  });
1033
- throw error;
1017
+ throw new ProviderContextOverflowError(error);
1034
1018
  }
1035
- if (criticalWrapUp) {
1036
- return finish(stream, state, queue);
1037
- }
1038
- for (const reasoning of deferredNativeReasoning)
1039
- commitPendingNativeReasoning(state, stream, queue, reasoning);
1040
- pendingNativeReasoning = commitPendingNativeReasoning(state, stream, queue, pendingNativeReasoning);
1041
- await this.#dispatchToolCalls(state, stream, queue, streamedToolCalls, toolContext);
1042
- if (state.toolExecution.terminateAfterExecution)
1043
- return finish(stream, state, queue);
1044
- if (state.toolExecution.pendingApprovals.length > 0) {
1045
- stream.interruptions = state.toolExecution.pendingApprovals.map((item) => item.interruption);
1046
- return finish(stream, state, queue);
1047
- }
1048
- continue;
1049
- }
1050
- const finalCompletion = completion;
1051
- // A terminal response ends recovery, unlike partial tokens or tool
1052
- // activity. Later failures must not inherit an old episode's deadline.
1053
- state.recoveryBudget?.noteModelResponseCompleted();
1054
- // Commit the authoritative terminal state before handling terminal-only
1055
- // tool calls. Approval may pause immediately after this point, and the
1056
- // continuation must still carry the response that produced the calls.
1057
- // Response IDs are provider-chain state, not generic run metadata. Keep
1058
- // them out of continuation handles for providers that do not support
1059
- // chaining, while still exposing the completed provider response on the
1060
- // current stream for diagnostics.
1061
- if (state.supportsConversationChaining && state.currentProviderId !== undefined) {
1062
- state.responseId = finalCompletion.responseId;
1063
- state.responseProviderId = state.currentProviderId;
1064
- }
1065
- else {
1066
- state.responseId = undefined;
1067
- state.responseProviderId = undefined;
1068
- }
1069
- let normalizedCompletionUsage;
1070
- const upstreamProvider = extractAiSdkUpstreamProvider(finalCompletion.providerMetadata);
1071
- if (finalCompletion.usage !== undefined) {
1072
- normalizedCompletionUsage = normalizeModelUsage(finalCompletion.usage);
1073
- if (upstreamProvider) {
1074
- if (normalizedCompletionUsage) {
1075
- normalizedCompletionUsage.upstream_provider = upstreamProvider;
1019
+ const recoverOversizedToolArgument = isOversizedToolArgumentTrip(error) &&
1020
+ (state.oversizedToolArgumentRecoveries ?? 0) < MAX_OVERSIZED_TOOL_ARGUMENT_RECOVERIES &&
1021
+ !options.signal?.aborted;
1022
+ if (recoverOversizedToolArgument)
1023
+ requestSignal.abort();
1024
+ else if (error instanceof GenerationGuardError)
1025
+ requestAbortController.abort();
1026
+ const decision = classifyInLoopModelRetry(error, attempt, maxRetries, Math.random, {
1027
+ previousResponseId: activeRequest?.previousResponseId,
1028
+ });
1029
+ const rollbackProvisionalAttempt = () => {
1030
+ stream.output.splice(outputLengthBefore);
1031
+ if (stream.newItems !== stream.output) {
1032
+ stream.newItems.splice(newItemsLengthBefore);
1076
1033
  }
1077
- else {
1078
- normalizedCompletionUsage = { upstream_provider: upstreamProvider };
1034
+ if (provisionalTextDeltas > 0 || provisionalReasoningDeltas > 0) {
1035
+ queue.push({
1036
+ type: 'model_attempt_rollback',
1037
+ textCharacters: provisionalTextCharacters,
1038
+ reasoningCharacters: provisionalReasoningCharacters,
1039
+ textDeltas: provisionalTextDeltas,
1040
+ reasoningDeltas: provisionalReasoningDeltas,
1041
+ });
1079
1042
  }
1043
+ };
1044
+ if (recoverOversizedToolArgument) {
1045
+ // No tool ran: a call is dispatched only after its arguments
1046
+ // complete. Discard the partial response and ask for smaller calls.
1047
+ state.oversizedToolArgumentRecoveries = (state.oversizedToolArgumentRecoveries ?? 0) + 1;
1048
+ state.input.splice(inputLengthBefore);
1049
+ state.history.splice(historyLengthBefore);
1050
+ state.criticalWrapUpDispatched = criticalWrapUpDispatchedBefore;
1051
+ rollbackProvisionalAttempt();
1052
+ this.#deps.logDiagnostic?.('Recovering oversized tool argument in run loop', {
1053
+ code: error.code,
1054
+ recovery: state.oversizedToolArgumentRecoveries,
1055
+ maxRecoveries: MAX_OVERSIZED_TOOL_ARGUMENT_RECOVERIES,
1056
+ });
1057
+ this.#queuePendingSystemNotice(oversizedToolArgumentNotice(error.message));
1058
+ this.#admitPendingSteers(state, stream, queue);
1059
+ continue;
1080
1060
  }
1081
- if (normalizedCompletionUsage) {
1082
- if (normalizedCompletionUsage.prompt_tokens !== undefined) {
1083
- state.lastCompletedInputTokens = normalizedCompletionUsage.prompt_tokens;
1084
- state.inputUsageInvalidatedByCompaction = false;
1061
+ if (decision.retryable && !options.signal?.aborted) {
1062
+ attempt++;
1063
+ state.input.splice(inputLengthBefore);
1064
+ state.history.splice(historyLengthBefore);
1065
+ state.criticalWrapUpDispatched = criticalWrapUpDispatchedBefore;
1066
+ rollbackProvisionalAttempt();
1067
+ if (decision.kind === 'chain_recovery') {
1068
+ state.disableChainingForAttempt = true;
1069
+ state.responseId = undefined;
1070
+ state.responseProviderId = undefined;
1085
1071
  }
1086
- const accumulated = addTokenUsage(normalizeModelUsage(state.usage), normalizedCompletionUsage);
1087
- state.usage = {
1088
- ...(accumulated.prompt_tokens !== undefined ? { inputTokens: accumulated.prompt_tokens } : {}),
1089
- ...(accumulated.completion_tokens !== undefined ? { outputTokens: accumulated.completion_tokens } : {}),
1090
- // `total_tokens` is the billable normalized total, including cache
1091
- // creation tokens. Keep it alongside the application aliases so the
1092
- // authoritative run accumulator never reconstructs a smaller total.
1093
- ...(accumulated.total_tokens !== undefined ? { totalTokens: accumulated.total_tokens } : {}),
1094
- ...(accumulated.cache_read_tokens !== undefined
1095
- ? { cachedInputTokens: accumulated.cache_read_tokens }
1096
- : {}),
1097
- ...(accumulated.cache_creation_tokens !== undefined
1098
- ? { cacheWriteTokens: accumulated.cache_creation_tokens }
1099
- : {}),
1100
- };
1072
+ this.#deps.logDiagnostic?.('Retrying model request in run loop', {
1073
+ attempt,
1074
+ maxRetries,
1075
+ kind: decision.kind,
1076
+ delayMs: decision.delayMs,
1077
+ error: describeError(error),
1078
+ });
1079
+ await (this.#deps.waitBeforeModelRetry ?? sleepWithAbort)(decision.delayMs, options.signal);
1080
+ continue;
1101
1081
  }
1102
- else {
1103
- // Providers may carry a future/opaque usage shape. Preserve the old
1104
- // pass-through behavior when normalization cannot recognize it.
1105
- state.usage = finalCompletion.usage;
1082
+ // Session recovery owns full-history rebuild for a chained delta.
1083
+ // Retract this cycle's provisional tokens so a later chain_recovery
1084
+ // does not append a second answer after a half-sentence.
1085
+ if (!decision.retryable && decision.reason === 'chained_delta_not_self_contained') {
1086
+ rollbackProvisionalAttempt();
1106
1087
  }
1088
+ // Dispatch began, but no terminal completion was accepted. Record an
1089
+ // unpriced marker so the summary stays honest (partial) rather than
1090
+ // appearing exact. Observational only: the error still propagates.
1091
+ const cancelled = error instanceof Error && error.name === 'AbortError';
1092
+ const record = this.#appendCostRecord(state, {
1093
+ requestId,
1094
+ provider: state.currentProviderId,
1095
+ model: state.agent.model,
1096
+ tier: resolveServiceTier(request),
1097
+ outcome: cancelled ? 'cancelled' : 'failed',
1098
+ });
1099
+ // Only the live queue sees cost records: `output`/`newItems` feed
1100
+ // persistence and replay, and a cost event must never become a
1101
+ // restored history item.
1102
+ queue.push({ type: 'cost_update', record });
1103
+ this.#evaluateRunBudget(state, stream, queue);
1104
+ this.#observeTerminalFailure(error, {
1105
+ requestId,
1106
+ provider: state.currentProviderId,
1107
+ model: state.agent.model,
1108
+ tier: resolveServiceTier(request),
1109
+ });
1110
+ throw error;
1107
1111
  }
1108
- else if (upstreamProvider) {
1109
- normalizedCompletionUsage = { upstream_provider: upstreamProvider };
1110
- }
1111
- else {
1112
- normalizedCompletionUsage = undefined;
1112
+ finally {
1113
+ options.signal?.removeEventListener('abort', abortRequest);
1113
1114
  }
1114
- // Attribute cost while the dispatched request's identity is still known.
1115
- // Emit it immediately so the UI can show this request's cost before the
1116
- // run ends; the terminal result re-delivers the cumulative record list
1117
- // and the session accumulator ignores the duplicates by request id.
1118
- // Only the live queue sees the event: `output`/`newItems` feed
1119
- // persistence and replay, and a cost event must never become a restored
1120
- // history item.
1121
- const record = this.#appendCostRecord(state, {
1122
- requestId: activeRequestId,
1123
- provider: state.currentProviderId,
1124
- model: state.agent.model,
1125
- tier: activeRequest ? resolveServiceTier(activeRequest) : 'standard',
1126
- outcome: 'completed',
1127
- usage: normalizedCompletionUsage,
1128
- providerUsd: finalCompletion.costUsd,
1129
- });
1130
- queue.push({ type: 'cost_update', record });
1131
- // Cost and usage arrive in the same completion metadata, so surface both
1132
- // live. The footer is a per-request indicator: emit this request's own
1133
- // usage, never the run accumulator in `state.usage`.
1134
- if (normalizedCompletionUsage)
1135
- queue.push({ type: 'usage_update', usage: normalizedCompletionUsage });
1136
- this.#evaluateRunBudget(state, stream, queue);
1137
- stream.lastResponseId = finalCompletion.responseId;
1138
- stream.rawResponses?.push(finalCompletion);
1139
- // Some provider adapters report function calls only in the terminal
1140
- // completion rather than as separate stream events. Their reasoning may
1141
- // likewise be terminal-only, so associate it before replaying calls.
1142
- const toolCalls = [...streamedToolCalls];
1143
- const hasTerminalReasoning = finalCompletion.output.some((item) => item.type === 'reasoning' && appendNativeReasoning(undefined, item) !== undefined);
1144
- if (hasTerminalReasoning) {
1145
- // Terminal output supplies native ordering and authoritative metadata.
1146
- // Streamed fragments have already been displayed, but are not history.
1147
- pendingNativeReasoning = undefined;
1115
+ }
1116
+ return {
1117
+ streamedToolCalls,
1118
+ completion,
1119
+ sawToolCall,
1120
+ activeRequestId,
1121
+ activeRequest,
1122
+ pendingNativeReasoning,
1123
+ deferredNativeReasoning,
1124
+ criticalWrapUp,
1125
+ };
1126
+ }
1127
+ /** Accept response facts before any tool from that response can execute. */
1128
+ #recordResponse(context, response) {
1129
+ const { state, stream, queue } = context;
1130
+ const { streamedToolCalls, completion, sawToolCall, activeRequestId, activeRequest, deferredNativeReasoning, criticalWrapUp, } = response;
1131
+ let { pendingNativeReasoning } = response;
1132
+ if (!completion) {
1133
+ if (streamedToolCalls.length === 0) {
1134
+ const error = new Error('Application model turn ended without completion');
1135
+ this.#observeTerminalFailure(error, {
1136
+ requestId: activeRequestId,
1137
+ provider: state.currentProviderId,
1138
+ model: state.agent.model,
1139
+ tier: activeRequest ? resolveServiceTier(activeRequest) : 'standard',
1140
+ });
1141
+ throw error;
1148
1142
  }
1149
- else {
1143
+ if (!criticalWrapUp) {
1150
1144
  for (const reasoning of deferredNativeReasoning)
1151
1145
  commitPendingNativeReasoning(state, stream, queue, reasoning);
1146
+ commitPendingNativeReasoning(state, stream, queue, pendingNativeReasoning);
1152
1147
  }
1153
- for (const item of finalCompletion.output) {
1154
- if (item.type === 'reasoning') {
1155
- // Each terminal native item is independently replayable; encrypted
1156
- // reasoning cannot be concatenated or overwritten by its sibling.
1157
- pendingNativeReasoning = commitPendingNativeReasoning(state, stream, queue, pendingNativeReasoning);
1158
- pendingNativeReasoning = appendNativeReasoning(undefined, item);
1159
- continue;
1160
- }
1161
- if (item.type === 'tool_call') {
1162
- pendingNativeReasoning = commitPendingNativeReasoning(state, stream, queue, pendingNativeReasoning);
1163
- if (!sawToolCall)
1164
- toolCalls.push(item);
1165
- continue;
1148
+ return criticalWrapUp ? [] : streamedToolCalls;
1149
+ }
1150
+ const finalCompletion = completion;
1151
+ // A terminal response ends recovery, unlike partial tokens or tool
1152
+ // activity. Later failures must not inherit an old episode's deadline.
1153
+ state.recoveryBudget?.noteModelResponseCompleted();
1154
+ // Commit the authoritative terminal state before handling terminal-only
1155
+ // tool calls. Approval may pause immediately after this point, and the
1156
+ // continuation must still carry the response that produced the calls.
1157
+ // Response IDs are provider-chain state, not generic run metadata. Keep
1158
+ // them out of continuation handles for providers that do not support
1159
+ // chaining, while still exposing the completed provider response on the
1160
+ // current stream for diagnostics.
1161
+ if (state.supportsConversationChaining && state.currentProviderId !== undefined) {
1162
+ state.responseId = finalCompletion.responseId;
1163
+ state.responseProviderId = state.currentProviderId;
1164
+ }
1165
+ else {
1166
+ state.responseId = undefined;
1167
+ state.responseProviderId = undefined;
1168
+ }
1169
+ let normalizedCompletionUsage;
1170
+ const upstreamProvider = extractAiSdkUpstreamProvider(finalCompletion.providerMetadata);
1171
+ if (finalCompletion.usage !== undefined) {
1172
+ normalizedCompletionUsage = normalizeModelUsage(finalCompletion.usage);
1173
+ if (upstreamProvider) {
1174
+ if (normalizedCompletionUsage) {
1175
+ normalizedCompletionUsage.upstream_provider = upstreamProvider;
1166
1176
  }
1167
- if (item.type !== 'provider_opaque')
1168
- continue;
1169
- pendingNativeReasoning = commitPendingNativeReasoning(state, stream, queue, pendingNativeReasoning);
1170
- const historyItem = {
1171
- ...item.item,
1172
- providerOpaque: {
1173
- provider: item.provider,
1174
- ...(item.sourceProvider ? { sourceProvider: item.sourceProvider } : {}),
1175
- },
1176
- };
1177
- state.history.push(historyItem);
1178
- state.input.push(item);
1179
- if (isOpenAICompaction(item)) {
1180
- // Completion usage describes the request before native replacement.
1181
- // Retain billed usage/costs; invalidate only the next admission hint.
1182
- state.lastCompletedInputTokens = undefined;
1183
- state.inputUsageInvalidatedByCompaction = true;
1177
+ else {
1178
+ normalizedCompletionUsage = { upstream_provider: upstreamProvider };
1184
1179
  }
1185
- outputPush(stream, queue, { type: 'item', item });
1186
1180
  }
1187
- sawToolCall = toolCalls.length > 0;
1188
- pendingNativeReasoning = commitPendingNativeReasoning(state, stream, queue, pendingNativeReasoning);
1189
- const assistantText = finalCompletion.output
1190
- .filter((item) => item.type === 'message')
1191
- .flatMap((item) => item.content)
1192
- // Whitespace-only output (e.g. a background-notification acknowledgment)
1193
- // must not enter the canonical history: the truthy check used to let
1194
- // "\n\n" through, accumulating junk in model context.
1195
- .map((part) => part.text)
1196
- .filter((text) => text.length > 0)
1197
- .join(ASSISTANT_TEXT_PART_SEPARATOR)
1198
- .trim();
1199
- if (assistantText) {
1200
- stream.finalOutput = assistantText;
1201
- const item = {
1202
- type: 'message',
1203
- role: 'assistant',
1204
- content: [{ type: 'output_text', text: assistantText }],
1181
+ if (normalizedCompletionUsage) {
1182
+ if (normalizedCompletionUsage.prompt_tokens !== undefined) {
1183
+ state.lastCompletedInputTokens = normalizedCompletionUsage.prompt_tokens;
1184
+ state.inputUsageInvalidatedByCompaction = false;
1185
+ }
1186
+ const accumulated = addTokenUsage(normalizeModelUsage(state.usage), normalizedCompletionUsage);
1187
+ state.usage = {
1188
+ ...(accumulated.prompt_tokens !== undefined ? { inputTokens: accumulated.prompt_tokens } : {}),
1189
+ ...(accumulated.completion_tokens !== undefined ? { outputTokens: accumulated.completion_tokens } : {}),
1190
+ // `total_tokens` is the billable normalized total, including cache
1191
+ // creation tokens. Keep it alongside the application aliases so the
1192
+ // authoritative run accumulator never reconstructs a smaller total.
1193
+ ...(accumulated.total_tokens !== undefined ? { totalTokens: accumulated.total_tokens } : {}),
1194
+ ...(accumulated.cache_read_tokens !== undefined ? { cachedInputTokens: accumulated.cache_read_tokens } : {}),
1195
+ ...(accumulated.cache_creation_tokens !== undefined
1196
+ ? { cacheWriteTokens: accumulated.cache_creation_tokens }
1197
+ : {}),
1205
1198
  };
1206
- state.history.push(item);
1207
- outputPush(stream, queue, { type: 'item', item });
1208
- state.input.push({ type: 'message', role: 'assistant', content: [{ type: 'text', text: assistantText }] });
1209
1199
  }
1210
- // Tool calls are dispatched only after the reasoning, provider-opaque,
1211
- // and assistant-text items from the same completion are committed. A
1212
- // response that carries both prose and a tool call must serialize as
1213
- // text -> function_call -> function_call_result; dispatching first left
1214
- // the text stranded after its own tool result, where the assistant
1215
- // message merger glued it onto the *next* turn's tool call and every
1216
- // request ended with a bare assistant message. Models read that as a
1217
- // turn truncated mid-sentence and restart instead of progressing.
1218
- if (!state.criticalWrapUpPending && toolCalls.length > 0) {
1219
- await this.#dispatchToolCalls(state, stream, queue, toolCalls, toolContext);
1200
+ else {
1201
+ // Providers may carry a future/opaque usage shape. Preserve the old
1202
+ // pass-through behavior when normalization cannot recognize it.
1203
+ state.usage = finalCompletion.usage;
1220
1204
  }
1221
- if (state.toolExecution.terminateAfterExecution)
1222
- return finish(stream, state, queue);
1223
- // A critical subagent gets exactly this final tool-free model call.
1224
- if (criticalWrapUp)
1225
- return finish(stream, state, queue);
1226
- if (state.pendingRunBudgetInteraction && toolCalls.length > 0) {
1227
- return this.#pauseForRunBudgetInteraction(state, stream, queue);
1205
+ }
1206
+ else if (upstreamProvider) {
1207
+ normalizedCompletionUsage = { upstream_provider: upstreamProvider };
1208
+ }
1209
+ else {
1210
+ normalizedCompletionUsage = undefined;
1211
+ }
1212
+ // Attribute cost while the dispatched request's identity is still known.
1213
+ // Emit it immediately so the UI can show this request's cost before the
1214
+ // run ends; the terminal result re-delivers the cumulative record list
1215
+ // and the session accumulator ignores the duplicates by request id.
1216
+ // Only the live queue sees the event: `output`/`newItems` feed
1217
+ // persistence and replay, and a cost event must never become a restored
1218
+ // history item.
1219
+ const record = this.#appendCostRecord(state, {
1220
+ requestId: activeRequestId,
1221
+ provider: state.currentProviderId,
1222
+ model: state.agent.model,
1223
+ tier: activeRequest ? resolveServiceTier(activeRequest) : 'standard',
1224
+ outcome: 'completed',
1225
+ usage: normalizedCompletionUsage,
1226
+ providerUsd: finalCompletion.costUsd,
1227
+ });
1228
+ queue.push({ type: 'cost_update', record });
1229
+ // Cost and usage arrive in the same completion metadata, so surface both
1230
+ // live. The footer is a per-request indicator: emit this request's own
1231
+ // usage, never the run accumulator in `state.usage`.
1232
+ if (normalizedCompletionUsage)
1233
+ queue.push({ type: 'usage_update', usage: normalizedCompletionUsage });
1234
+ this.#evaluateRunBudget(state, stream, queue);
1235
+ stream.lastResponseId = finalCompletion.responseId;
1236
+ stream.rawResponses?.push(finalCompletion);
1237
+ // Some provider adapters report function calls only in the terminal
1238
+ // completion rather than as separate stream events. Their reasoning may
1239
+ // likewise be terminal-only, so associate it before replaying calls.
1240
+ const toolCalls = [...streamedToolCalls];
1241
+ const hasTerminalReasoning = finalCompletion.output.some((item) => item.type === 'reasoning' && appendNativeReasoning(undefined, item) !== undefined);
1242
+ if (hasTerminalReasoning) {
1243
+ // Terminal output supplies native ordering and authoritative metadata.
1244
+ // Streamed fragments have already been displayed, but are not history.
1245
+ pendingNativeReasoning = undefined;
1246
+ }
1247
+ else {
1248
+ for (const reasoning of deferredNativeReasoning)
1249
+ commitPendingNativeReasoning(state, stream, queue, reasoning);
1250
+ }
1251
+ for (const item of finalCompletion.output) {
1252
+ if (item.type === 'reasoning') {
1253
+ // Each terminal native item is independently replayable; encrypted
1254
+ // reasoning cannot be concatenated or overwritten by its sibling.
1255
+ pendingNativeReasoning = commitPendingNativeReasoning(state, stream, queue, pendingNativeReasoning);
1256
+ pendingNativeReasoning = appendNativeReasoning(undefined, item);
1257
+ continue;
1228
1258
  }
1259
+ if (item.type === 'tool_call') {
1260
+ pendingNativeReasoning = commitPendingNativeReasoning(state, stream, queue, pendingNativeReasoning);
1261
+ if (!sawToolCall)
1262
+ toolCalls.push(item);
1263
+ continue;
1264
+ }
1265
+ if (item.type !== 'provider_opaque')
1266
+ continue;
1267
+ pendingNativeReasoning = commitPendingNativeReasoning(state, stream, queue, pendingNativeReasoning);
1268
+ const historyItem = {
1269
+ ...item.item,
1270
+ providerOpaque: {
1271
+ provider: item.provider,
1272
+ ...(item.sourceProvider ? { sourceProvider: item.sourceProvider } : {}),
1273
+ },
1274
+ };
1275
+ state.history.push(historyItem);
1276
+ state.input.push(item);
1277
+ if (isOpenAICompaction(item)) {
1278
+ // Completion usage describes the request before native replacement.
1279
+ // Retain billed usage/costs; invalidate only the next admission hint.
1280
+ state.lastCompletedInputTokens = undefined;
1281
+ state.inputUsageInvalidatedByCompaction = true;
1282
+ }
1283
+ outputPush(stream, queue, { type: 'item', item });
1284
+ }
1285
+ pendingNativeReasoning = commitPendingNativeReasoning(state, stream, queue, pendingNativeReasoning);
1286
+ const assistantText = finalCompletion.output
1287
+ .filter((item) => item.type === 'message')
1288
+ .flatMap((item) => item.content)
1289
+ // Whitespace-only output (e.g. a background-notification acknowledgment)
1290
+ // must not enter the canonical history: the truthy check used to let
1291
+ // "\n\n" through, accumulating junk in model context.
1292
+ .map((part) => part.text)
1293
+ .filter((text) => text.length > 0)
1294
+ .join(ASSISTANT_TEXT_PART_SEPARATOR)
1295
+ .trim();
1296
+ if (assistantText) {
1297
+ stream.finalOutput = assistantText;
1298
+ const item = {
1299
+ type: 'message',
1300
+ role: 'assistant',
1301
+ content: [{ type: 'output_text', text: assistantText }],
1302
+ };
1303
+ state.history.push(item);
1304
+ outputPush(stream, queue, { type: 'item', item });
1305
+ state.input.push({ type: 'message', role: 'assistant', content: [{ type: 'text', text: assistantText }] });
1306
+ }
1307
+ return toolCalls;
1308
+ }
1309
+ /** Execute and settle one tool plan; approvals suspend this operation in place. */
1310
+ async #executeTools(context, response, calls) {
1311
+ let { state, stream, queue, options, toolContext } = context;
1312
+ if (response.criticalWrapUp)
1313
+ return true;
1314
+ if (!state.criticalWrapUpPending && calls.length > 0) {
1315
+ await this.#dispatchToolCalls(state, stream, queue, calls, toolContext);
1316
+ }
1317
+ if (state.toolExecution.terminateAfterExecution)
1318
+ return true;
1319
+ if (calls.length === 0)
1320
+ return false;
1321
+ while (state.pendingRunBudgetInteraction || state.toolExecution.pendingApprovals.length > 0) {
1322
+ if (await this.#waitForBudget(context))
1323
+ return true;
1324
+ ({ state, stream, queue, options, toolContext } = context);
1325
+ let answeredApproval = false;
1229
1326
  if (state.toolExecution.pendingApprovals.length > 0) {
1230
1327
  stream.interruptions = state.toolExecution.pendingApprovals.map((item) => item.interruption);
1231
- return finish(stream, state, queue);
1232
- }
1233
- if (!sawToolCall) {
1234
- // The normal stop seam. A stop policy may keep the same run going; the
1235
- // reminder is admitted at the next request boundary, where pending
1236
- // budget evidence pauses first and turn accounting applies as usual.
1237
- const stopDecision = options.onNormalStop && !options.signal?.aborted && !state.criticalWrapUpPending
1238
- ? options.onNormalStop(state.history, state.agent)
1239
- : undefined;
1240
- if (stopDecision) {
1241
- this.#deps.logDiagnostic?.('Normal stop policy decided', { action: stopDecision.action, turnCount: state.turnCount, ...stopDecision.diagnostics }, { severity: 'debug', eventType: 'run_loop.normal_stop_policy' });
1328
+ await this.#waitForDecision(context);
1329
+ ({ state, stream, queue, options, toolContext } = context);
1330
+ if (state.approvalDecision) {
1331
+ state.toolExecution.resolveApproval({ callId: state.approvalDecisionCallId, interruption: state.approvalDecisionInterruption }, state.approvalDecision, state.approvalMessage, state.approvals);
1332
+ state.approvalDecision = undefined;
1333
+ state.approvalDecisionCallId = undefined;
1334
+ state.approvalDecisionInterruption = undefined;
1335
+ state.approvalMessage = undefined;
1336
+ answeredApproval = true;
1242
1337
  }
1243
- if (stopDecision?.action === 'continue') {
1244
- this.#queuePendingSystemNotice(stopDecision.reminder);
1245
- continue;
1246
- }
1247
- if (stopDecision?.terminalCause)
1248
- state.terminalCause = stopDecision.terminalCause;
1249
- // Evidence discovered when a response has already completed needs no
1250
- // human boundary: there is no later request or tool to block.
1251
- state.pendingRunBudgetInteraction = undefined;
1252
- return finish(stream, state, queue);
1253
1338
  }
1339
+ this.#assertLive(context);
1340
+ if (state.toolExecution.hasPlan) {
1341
+ await state.toolExecution.settle(this.#toolCallContext(state, stream, queue, toolContext));
1342
+ }
1343
+ stream.interruptions = state.toolExecution.pendingApprovals.map((item) => item.interruption);
1344
+ if (answeredApproval && options.stopAfterApprovalResolution && stream.interruptions.length === 0)
1345
+ return true;
1346
+ if (state.toolExecution.terminateAfterExecution)
1347
+ return true;
1348
+ }
1349
+ return false;
1350
+ }
1351
+ /** Normal no-tool completion; only an explicit stop-policy continuation repeats. */
1352
+ #finishTurn(context) {
1353
+ const { state, options } = context;
1354
+ // The normal stop seam. A stop policy may keep the same run going; the
1355
+ // reminder is admitted at the next request boundary, where pending
1356
+ // budget evidence pauses first and turn accounting applies as usual.
1357
+ const stopDecision = options.onNormalStop && !options.signal?.aborted && !state.criticalWrapUpPending
1358
+ ? options.onNormalStop(state.history, state.agent)
1359
+ : undefined;
1360
+ if (stopDecision) {
1361
+ this.#deps.logDiagnostic?.('Normal stop policy decided', { action: stopDecision.action, turnCount: state.turnCount, ...stopDecision.diagnostics }, { severity: 'debug', eventType: 'run_loop.normal_stop_policy' });
1254
1362
  }
1363
+ if (stopDecision?.action === 'continue') {
1364
+ this.#queuePendingSystemNotice(stopDecision.reminder);
1365
+ return false;
1366
+ }
1367
+ if (stopDecision?.terminalCause)
1368
+ state.terminalCause = stopDecision.terminalCause;
1369
+ // Evidence discovered when a response has already completed needs no
1370
+ // human boundary: there is no later request or tool to block.
1371
+ state.pendingRunBudgetInteraction = undefined;
1372
+ return true;
1255
1373
  }
1256
1374
  async #dispatchToolCalls(state, stream, queue, events, toolContext) {
1257
1375
  const context = this.#toolCallContext(state, stream, queue, toolContext);
@@ -1288,6 +1406,7 @@ export class ApplicationRunLoop {
1288
1406
  });
1289
1407
  outputPush(stream, queue, { type: 'item', item: callItem });
1290
1408
  },
1409
+ onExecutionStarted: (callId) => state.live?.session?.acceptToolDispatch(callId),
1291
1410
  onDispatch: (entry) => {
1292
1411
  outputPush(stream, queue, {
1293
1412
  type: 'tool_call_dispatched',
@@ -1388,13 +1507,6 @@ export class ApplicationRunLoop {
1388
1507
  #requiresHumanBudgetDecision(event) {
1389
1508
  return event.type === 'tool_stall' || (event.type === 'budget_stage' && event.stage !== 'soft');
1390
1509
  }
1391
- #pauseForRunBudgetInteraction(state, stream, queue) {
1392
- const interaction = state.pendingRunBudgetInteraction;
1393
- if (!interaction)
1394
- return finish(stream, state, queue);
1395
- stream.interruptions = [interaction];
1396
- return finish(stream, state, queue);
1397
- }
1398
1510
  }
1399
1511
  function isLunaChainedRequest(state, request) {
1400
1512
  return (state.currentProviderId === 'codex' &&