@openrouter/agent 0.8.0 → 0.10.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 (193) hide show
  1. package/README.md +732 -7
  2. package/THIRD_PARTY_NOTICES.md +25 -0
  3. package/esm/index.d.ts +20 -5
  4. package/esm/index.d.ts.map +1 -1
  5. package/esm/index.js +10 -3
  6. package/esm/index.js.map +1 -1
  7. package/esm/inner-loop/call-model.d.ts.map +1 -1
  8. package/esm/inner-loop/call-model.js +63 -46
  9. package/esm/inner-loop/call-model.js.map +1 -1
  10. package/esm/inner-loop/resume-tool-results.d.ts +128 -0
  11. package/esm/inner-loop/resume-tool-results.d.ts.map +1 -0
  12. package/esm/inner-loop/resume-tool-results.js +244 -0
  13. package/esm/inner-loop/resume-tool-results.js.map +1 -0
  14. package/esm/lib/agent-tool.d.ts +92 -0
  15. package/esm/lib/agent-tool.d.ts.map +1 -0
  16. package/esm/lib/agent-tool.js +211 -0
  17. package/esm/lib/agent-tool.js.map +1 -0
  18. package/esm/lib/async-params.d.ts +96 -0
  19. package/esm/lib/async-params.d.ts.map +1 -1
  20. package/esm/lib/async-params.js +30 -1
  21. package/esm/lib/async-params.js.map +1 -1
  22. package/esm/lib/async-tool-registry.d.ts +134 -0
  23. package/esm/lib/async-tool-registry.d.ts.map +1 -0
  24. package/esm/lib/async-tool-registry.js +352 -0
  25. package/esm/lib/async-tool-registry.js.map +1 -0
  26. package/esm/lib/async-tools.d.ts +23 -0
  27. package/esm/lib/async-tools.d.ts.map +1 -0
  28. package/esm/lib/async-tools.js +19 -0
  29. package/esm/lib/async-tools.js.map +1 -0
  30. package/esm/lib/conversation-state.d.ts +1 -0
  31. package/esm/lib/conversation-state.d.ts.map +1 -1
  32. package/esm/lib/conversation-state.js +8 -0
  33. package/esm/lib/conversation-state.js.map +1 -1
  34. package/esm/lib/doom-loop.d.ts +533 -0
  35. package/esm/lib/doom-loop.d.ts.map +1 -0
  36. package/esm/lib/doom-loop.js +1221 -0
  37. package/esm/lib/doom-loop.js.map +1 -0
  38. package/esm/lib/hooks-schemas.d.ts +33 -0
  39. package/esm/lib/hooks-schemas.d.ts.map +1 -1
  40. package/esm/lib/hooks-schemas.js +54 -0
  41. package/esm/lib/hooks-schemas.js.map +1 -1
  42. package/esm/lib/hooks-types.d.ts +6 -2
  43. package/esm/lib/hooks-types.d.ts.map +1 -1
  44. package/esm/lib/hooks-types.js.map +1 -1
  45. package/esm/lib/model-result.d.ts +648 -12
  46. package/esm/lib/model-result.d.ts.map +1 -1
  47. package/esm/lib/model-result.js +2784 -164
  48. package/esm/lib/model-result.js.map +1 -1
  49. package/esm/lib/next-turn-params.d.ts.map +1 -1
  50. package/esm/lib/next-turn-params.js +3 -1
  51. package/esm/lib/next-turn-params.js.map +1 -1
  52. package/esm/lib/next-turn-params.test.js +72 -0
  53. package/esm/lib/next-turn-params.test.js.map +1 -1
  54. package/esm/lib/reusable-stream.d.ts +9 -0
  55. package/esm/lib/reusable-stream.d.ts.map +1 -1
  56. package/esm/lib/reusable-stream.js +17 -0
  57. package/esm/lib/reusable-stream.js.map +1 -1
  58. package/esm/lib/stream-transformers.d.ts +21 -1
  59. package/esm/lib/stream-transformers.d.ts.map +1 -1
  60. package/esm/lib/stream-transformers.js +45 -0
  61. package/esm/lib/stream-transformers.js.map +1 -1
  62. package/esm/lib/tool-check.d.ts +76 -0
  63. package/esm/lib/tool-check.d.ts.map +1 -0
  64. package/esm/lib/tool-check.js +241 -0
  65. package/esm/lib/tool-check.js.map +1 -0
  66. package/esm/lib/tool-concurrency.d.ts +42 -0
  67. package/esm/lib/tool-concurrency.d.ts.map +1 -0
  68. package/esm/lib/tool-concurrency.js +80 -0
  69. package/esm/lib/tool-concurrency.js.map +1 -0
  70. package/esm/lib/tool-context.d.ts +45 -1
  71. package/esm/lib/tool-context.d.ts.map +1 -1
  72. package/esm/lib/tool-context.js +60 -1
  73. package/esm/lib/tool-context.js.map +1 -1
  74. package/esm/lib/tool-executor.d.ts +52 -9
  75. package/esm/lib/tool-executor.d.ts.map +1 -1
  76. package/esm/lib/tool-executor.js +253 -21
  77. package/esm/lib/tool-executor.js.map +1 -1
  78. package/esm/lib/tool-orchestrator.d.ts.map +1 -1
  79. package/esm/lib/tool-orchestrator.js +87 -50
  80. package/esm/lib/tool-orchestrator.js.map +1 -1
  81. package/esm/lib/tool-set-types.d.ts +298 -0
  82. package/esm/lib/tool-set-types.d.ts.map +1 -0
  83. package/esm/lib/tool-set-types.js +2 -0
  84. package/esm/lib/tool-set-types.js.map +1 -0
  85. package/esm/lib/tool-set.d.ts +157 -0
  86. package/esm/lib/tool-set.d.ts.map +1 -0
  87. package/esm/lib/tool-set.js +473 -0
  88. package/esm/lib/tool-set.js.map +1 -0
  89. package/esm/lib/tool-task.d.ts +157 -0
  90. package/esm/lib/tool-task.d.ts.map +1 -0
  91. package/esm/lib/tool-task.js +206 -0
  92. package/esm/lib/tool-task.js.map +1 -0
  93. package/esm/lib/tool-types.d.ts +667 -38
  94. package/esm/lib/tool-types.d.ts.map +1 -1
  95. package/esm/lib/tool-types.js +59 -10
  96. package/esm/lib/tool-types.js.map +1 -1
  97. package/esm/lib/tool.d.ts +227 -19
  98. package/esm/lib/tool.d.ts.map +1 -1
  99. package/esm/lib/tool.js +206 -6
  100. package/esm/lib/tool.js.map +1 -1
  101. package/esm/mcp/auth/auth-resolver.d.ts +14 -0
  102. package/esm/mcp/auth/auth-resolver.d.ts.map +1 -0
  103. package/esm/mcp/auth/auth-resolver.js +27 -0
  104. package/esm/mcp/auth/auth-resolver.js.map +1 -0
  105. package/esm/mcp/auth/auth-types.d.ts +33 -0
  106. package/esm/mcp/auth/auth-types.d.ts.map +1 -0
  107. package/esm/mcp/auth/auth-types.js +4 -0
  108. package/esm/mcp/auth/auth-types.js.map +1 -0
  109. package/esm/mcp/build-tools.d.ts +29 -0
  110. package/esm/mcp/build-tools.d.ts.map +1 -0
  111. package/esm/mcp/build-tools.js +80 -0
  112. package/esm/mcp/build-tools.js.map +1 -0
  113. package/esm/mcp/cache/cache-store.d.ts +24 -0
  114. package/esm/mcp/cache/cache-store.d.ts.map +1 -0
  115. package/esm/mcp/cache/cache-store.js +20 -0
  116. package/esm/mcp/cache/cache-store.js.map +1 -0
  117. package/esm/mcp/cache/cache-types.d.ts +71 -0
  118. package/esm/mcp/cache/cache-types.d.ts.map +1 -0
  119. package/esm/mcp/cache/cache-types.js +49 -0
  120. package/esm/mcp/cache/cache-types.js.map +1 -0
  121. package/esm/mcp/cache/serialize.d.ts +25 -0
  122. package/esm/mcp/cache/serialize.d.ts.map +1 -0
  123. package/esm/mcp/cache/serialize.js +96 -0
  124. package/esm/mcp/cache/serialize.js.map +1 -0
  125. package/esm/mcp/close-quietly.d.ts +24 -0
  126. package/esm/mcp/close-quietly.d.ts.map +1 -0
  127. package/esm/mcp/close-quietly.js +30 -0
  128. package/esm/mcp/close-quietly.js.map +1 -0
  129. package/esm/mcp/create-mcp-tools.d.ts +13 -0
  130. package/esm/mcp/create-mcp-tools.d.ts.map +1 -0
  131. package/esm/mcp/create-mcp-tools.js +87 -0
  132. package/esm/mcp/create-mcp-tools.js.map +1 -0
  133. package/esm/mcp/elicitation.d.ts +11 -0
  134. package/esm/mcp/elicitation.d.ts.map +1 -0
  135. package/esm/mcp/elicitation.js +51 -0
  136. package/esm/mcp/elicitation.js.map +1 -0
  137. package/esm/mcp/errors.d.ts +91 -0
  138. package/esm/mcp/errors.d.ts.map +1 -0
  139. package/esm/mcp/errors.js +91 -0
  140. package/esm/mcp/errors.js.map +1 -0
  141. package/esm/mcp/handle.d.ts +66 -0
  142. package/esm/mcp/handle.d.ts.map +1 -0
  143. package/esm/mcp/handle.js +323 -0
  144. package/esm/mcp/handle.js.map +1 -0
  145. package/esm/mcp/index.d.ts +13 -0
  146. package/esm/mcp/index.d.ts.map +1 -0
  147. package/esm/mcp/index.js +11 -0
  148. package/esm/mcp/index.js.map +1 -0
  149. package/esm/mcp/mcp-connection.d.ts +150 -0
  150. package/esm/mcp/mcp-connection.d.ts.map +1 -0
  151. package/esm/mcp/mcp-connection.js +478 -0
  152. package/esm/mcp/mcp-connection.js.map +1 -0
  153. package/esm/mcp/mcp-sdk.d.ts +10 -0
  154. package/esm/mcp/mcp-sdk.d.ts.map +1 -0
  155. package/esm/mcp/mcp-sdk.js +37 -0
  156. package/esm/mcp/mcp-sdk.js.map +1 -0
  157. package/esm/mcp/rehydrate.d.ts +76 -0
  158. package/esm/mcp/rehydrate.d.ts.map +1 -0
  159. package/esm/mcp/rehydrate.js +505 -0
  160. package/esm/mcp/rehydrate.js.map +1 -0
  161. package/esm/mcp/resource-tools.d.ts +17 -0
  162. package/esm/mcp/resource-tools.d.ts.map +1 -0
  163. package/esm/mcp/resource-tools.js +155 -0
  164. package/esm/mcp/resource-tools.js.map +1 -0
  165. package/esm/mcp/result-mapper.d.ts +19 -0
  166. package/esm/mcp/result-mapper.d.ts.map +1 -0
  167. package/esm/mcp/result-mapper.js +40 -0
  168. package/esm/mcp/result-mapper.js.map +1 -0
  169. package/esm/mcp/schema/json-schema-guards.d.ts +14 -0
  170. package/esm/mcp/schema/json-schema-guards.d.ts.map +1 -0
  171. package/esm/mcp/schema/json-schema-guards.js +19 -0
  172. package/esm/mcp/schema/json-schema-guards.js.map +1 -0
  173. package/esm/mcp/schema/json-schema-to-zod.d.ts +20 -0
  174. package/esm/mcp/schema/json-schema-to-zod.d.ts.map +1 -0
  175. package/esm/mcp/schema/json-schema-to-zod.js +72 -0
  176. package/esm/mcp/schema/json-schema-to-zod.js.map +1 -0
  177. package/esm/mcp/tool-wrapper.d.ts +41 -0
  178. package/esm/mcp/tool-wrapper.d.ts.map +1 -0
  179. package/esm/mcp/tool-wrapper.js +131 -0
  180. package/esm/mcp/tool-wrapper.js.map +1 -0
  181. package/esm/mcp/transport-types.d.ts +45 -0
  182. package/esm/mcp/transport-types.d.ts.map +1 -0
  183. package/esm/mcp/transport-types.js +2 -0
  184. package/esm/mcp/transport-types.js.map +1 -0
  185. package/esm/mcp/types.d.ts +119 -0
  186. package/esm/mcp/types.d.ts.map +1 -0
  187. package/esm/mcp/types.js +2 -0
  188. package/esm/mcp/types.js.map +1 -0
  189. package/esm/mcp/version.d.ts +3 -0
  190. package/esm/mcp/version.d.ts.map +1 -0
  191. package/esm/mcp/version.js +5 -0
  192. package/esm/mcp/version.js.map +1 -0
  193. package/package.json +70 -5
@@ -1,16 +1,17 @@
1
1
  import { betaResponsesSend } from '@openrouter/sdk/funcs/betaResponsesSend';
2
- import { hasAsyncFunctions, resolveAsyncFunctions } from './async-params.js';
3
- import { appendToMessages, createInitialState, createRejectedResult, createUnsentResult, extractTextFromResponse as extractTextFromResponseState, partitionToolCalls, unsentResultsToAPIFormat, updateState, } from './conversation-state.js';
2
+ import { hasAsyncFunctions, resolveAsyncFunctions, stripToolSetSnapshotMetadata, } from './async-params.js';
3
+ import { AsyncToolRegistry, acquireAll, buildTaskToolStub, defaultCheckResult, hasTaskToolNameCollision, persistedTaskCheckResult, resolveCheckConfig, Semaphore, TASK_RESULT_BOUNDARY, TASK_TOOL_NAME, TaskToolInputSchema, ToolTask, } from './async-tools.js';
4
+ import { appendToMessages, createInitialState, createRejectedResult, createUnsentResult, extractTextFromResponse as extractTextFromResponseState, normalizeInputToArray, partitionToolCalls, unsentResultsToAPIFormat, updateState, } from './conversation-state.js';
5
+ import { canonicalizeKeyMaterial, DoomLoopMonitor, resolveDoomLoopOption, resolveLoopKeyMaterial, } from './doom-loop.js';
4
6
  import { applyNextTurnParamsToRequest, executeNextTurnParamsFunctions, } from './next-turn-params.js';
5
7
  import { ReusableReadableStream } from './reusable-stream.js';
6
8
  import { isStopConditionMet } from './stop-conditions.js';
7
- import { buildItemsStream, buildResponsesMessageStream, buildToolCallStream, consumeStreamForCompletion, extractReasoningDeltas, extractResponsesMessageFromResponse, extractTextDeltas, extractTextFromResponse, extractToolCallsFromResponse, extractToolDeltas, itemsStreamHandlers, streamTerminationEvents, } from './stream-transformers.js';
9
+ import { buildItemsStream, buildResponsesMessageStream, buildToolCallStream, consumeStreamForCompletion, extractCompletionFromBuffer, extractReasoningDeltas, extractResponsesMessageFromResponse, extractTextDeltas, extractTextFromResponse, extractToolCallsFromResponse, extractToolDeltas, itemsStreamHandlers, responseHasToolCalls, streamTerminationEvents, tryExtractCompletionFromBuffer, } from './stream-transformers.js';
8
10
  import { hasTypeProperty, isFunctionCallItem, isFunctionCallOutputItem, isOutputTextDeltaEvent, isReasoningDeltaEvent, isResponseCompletedEvent, isResponseFailedEvent, isResponseIncompleteEvent, isServerToolResultItem, } from './stream-type-guards.js';
9
11
  import { resolveContext, ToolContextStore } from './tool-context.js';
10
12
  import { ToolEventBroadcaster } from './tool-event-broadcaster.js';
11
- import { applyOnResponseReceivedHooks, executeTool } from './tool-executor.js';
12
- import { isAutoResolvableTool, isClientTool, isMcpTool, isServerTool, isToolCallOutputEvent, } from './tool-types.js';
13
- import { normalizeInputToArray } from './turn-context.js';
13
+ import { applyOnResponseReceivedHooks, executeTool, isAsyncToolInvocation, validateToolInput, } from './tool-executor.js';
14
+ import { isAgentTool, isAutoResolvableTool, isClientTool, isLongRunningTool, isMcpTool, isServerTool, isToolCallOutputEvent, isUnifiedTool, } from './tool-types.js';
14
15
  /**
15
16
  * Default directive appended as a final user message on the forced final
16
17
  * turn (`allowFinalResponse` defaulting to on, or explicitly `true`).
@@ -28,11 +29,93 @@ export const DEFAULT_FINAL_RESPONSE_DIRECTIVE = 'You have reached the tool-use l
28
29
  function isRecord(value) {
29
30
  return typeof value === 'object' && value !== null && !Array.isArray(value);
30
31
  }
32
+ /**
33
+ * Relax a tool choice that *forces* a tool call, for follow-up turns after a
34
+ * tool round has executed (DEV-785).
35
+ *
36
+ * - `'required'` → `'auto'`
37
+ * - a specific-tool object (`{ type: 'function', name }`, `{ type: 'shell' }`,
38
+ * …) → `'auto'`
39
+ * - `allowed_tools` with `mode: 'required'` → same set with `mode: 'auto'`
40
+ * (the constraint on *which* tools may be used still applies; only the
41
+ * force-a-call part is spent)
42
+ * - `'auto'`, `'none'`, `allowed_tools` with `mode: 'auto'`, and `undefined`
43
+ * pass through unchanged.
44
+ */
45
+ function relaxForcedToolChoice(toolChoice) {
46
+ if (toolChoice === undefined || typeof toolChoice === 'string') {
47
+ return toolChoice === 'required' ? 'auto' : toolChoice;
48
+ }
49
+ if (toolChoice.type === 'allowed_tools') {
50
+ return toolChoice.mode === 'required'
51
+ ? {
52
+ ...toolChoice,
53
+ mode: 'auto',
54
+ }
55
+ : toolChoice;
56
+ }
57
+ return 'auto';
58
+ }
59
+ /** Whether a caller-configured tool choice requires this turn to call a tool. */
60
+ function isForcedToolChoice(toolChoice) {
61
+ if (toolChoice === undefined || typeof toolChoice === 'string') {
62
+ return toolChoice === 'required';
63
+ }
64
+ return toolChoice.type !== 'allowed_tools' || toolChoice.mode === 'required';
65
+ }
66
+ /** Stable identity for a concrete forced choice; null means tools are not forced. */
67
+ function forcedToolChoiceKey(toolChoice) {
68
+ return isForcedToolChoice(toolChoice) ? canonicalizeKeyMaterial(toolChoice) : null;
69
+ }
70
+ /**
71
+ * Extract the call identity echoed on a server-tool output item, for
72
+ * doom-loop fingerprinting. Server tools never pass through the client
73
+ * execution funnel; their output items echo what was asked (a web-search
74
+ * item carries its `action`/`query`, an MCP server-tool item its
75
+ * `name`/`arguments`, etc.). We take every primitive/JSON field except
76
+ * volatile per-invocation ones (ids, status, timing) and the result payload
77
+ * itself, so identical repeated requests fingerprint identically. Returns
78
+ * null when nothing identifying remains (item types with no echoed
79
+ * request identity are skipped rather than collided onto `{}`).
80
+ */
81
+ function extractServerToolIdentity(item) {
82
+ const record = item;
83
+ // Object.create(null), not `{}`: keys come from a JSON-parsed API response, so
84
+ // a `__proto__` key would hit the prototype setter instead of creating an own
85
+ // property and drop out of the fingerprint. Same reasoning as the
86
+ // Map-backed streak store.
87
+ const identity = Object.create(null);
88
+ for (const [key, value] of Object.entries(record)) {
89
+ if (key === 'id' ||
90
+ key === 'type' ||
91
+ key === 'status' ||
92
+ key === 'output' ||
93
+ key === 'result' ||
94
+ key === 'results' ||
95
+ key === 'error') {
96
+ continue;
97
+ }
98
+ if (value === undefined || typeof value === 'function' || typeof value === 'symbol') {
99
+ continue;
100
+ }
101
+ identity[key] = value;
102
+ }
103
+ return Object.keys(identity).length > 0 ? identity : null;
104
+ }
31
105
  // Cap consecutive Stop-hook forceResume overrides so a misbehaving handler
32
106
  // cannot spin the loop forever. 3 is a conservative upper bound -- enough to
33
107
  // let a hook gather a couple of follow-up actions but small enough that a
34
108
  // buggy handler fails fast with a visible warning.
35
109
  const MAX_FORCE_RESUME_OVERRIDES = 3;
110
+ /**
111
+ * Sentinel marking a tool-call id that appeared MORE THAN ONCE in one batch.
112
+ * Ids are model-emitted and nothing upstream enforces uniqueness; a colliding
113
+ * id must poison the loop-key cache entry rather than let the last write win,
114
+ * or one call's doom-loop identity aliases onto another's and its repetition
115
+ * becomes invisible to the per-call detector. Colliding calls fall through to
116
+ * per-call resolution at the checkpoint.
117
+ */
118
+ const DUPLICATE_CALL_ID = Symbol('duplicate-call-id');
36
119
  /**
37
120
  * Human-readable label for a value that failed the `isRecord` check. Used
38
121
  * exclusively to make `toModelOutput` misuse errors specific.
@@ -198,7 +281,16 @@ export class ModelResult {
198
281
  this.allToolExecutionRounds = [];
199
282
  // Track resolved request after async function resolution
200
283
  this.resolvedRequest = null;
284
+ // A forced caller-configured tool choice is one-shot until its resolved
285
+ // semantic value changes. The consumed key survives state-backed pauses;
286
+ // the configured key/value are refreshed whenever async parameters resolve.
287
+ this.activeConsumedForcedToolChoiceKey = null;
288
+ this.configuredForcedToolChoiceKey = null;
289
+ this.preparedToolChoiceCommit = null;
290
+ this.dispatchedToolChoiceCommit = null;
291
+ this.dispatchedCallerToolChoice = false;
201
292
  this.resumingFromClientTools = false;
293
+ this.resumingFromAsyncTools = false;
202
294
  // State management for multi-turn conversations
203
295
  this.stateAccessor = null;
204
296
  this.currentState = null;
@@ -237,8 +329,63 @@ export class ModelResult {
237
329
  cost: 0,
238
330
  hasCost: false,
239
331
  };
332
+ // Set when a verdict resolves to 'stop'; checked at the loop boundaries.
333
+ // Persisted inside ConversationState.doomLoop (as `stopVerdict`) so a
334
+ // condemned run stays condemned across decision-only resumes; cleared by
335
+ // a fresh conversational turn (see the initStream restore site).
336
+ this.doomLoopStop = null;
337
+ // Steer messages queued by 'steer' verdicts, injected before the next
338
+ // model request (same mechanism as the Stop hook's appendPrompt).
339
+ // Persisted as `pendingSteer` so guidance queued right before a pause is
340
+ // delivered on resume instead of silently dropped.
341
+ this.pendingDoomLoopSteer = [];
342
+ // Monotonic round counter for doom-loop streaks. Bumped at every
343
+ // execution-batch boundary (tool round, approval auto-approve batch,
344
+ // approved-on-resume batch); identical calls within one batch count as
345
+ // ONE piece of loop evidence (a streak measures the model re-issuing a
346
+ // call after seeing its result, which requires a round trip).
347
+ this.doomLoopRound = 0;
348
+ // Final decision for each (tool, fingerprint) in the current round.
349
+ // Duplicates reuse the decision instead of re-incrementing the streak or
350
+ // re-emitting the DoomLoopDetected hook; `message` carries the verdict's
351
+ // explanation for block outputs.
352
+ this.doomLoopRoundDecisions = new Map();
353
+ // Loop-key resolutions computed while declaring the current round, keyed by
354
+ // tool-call id. A `loopKey` function is user code that may count, log, or
355
+ // return something different each time, so it must run at most once per
356
+ // call: the per-call checkpoint reuses what the declaration resolved instead
357
+ // of resolving again. Cleared with the round. An id seen twice in one batch
358
+ // maps to DUPLICATE_CALL_ID — see the write site.
359
+ this.doomLoopRoundKeyMaterial = new Map();
360
+ // Serialization chain for doom evaluations: parallel tool executions
361
+ // append their evaluation here in call order (the .map() over a round's
362
+ // calls runs synchronously to the first await), so streak recording
363
+ // stays a pure function of the transcript even though hashing is async.
364
+ this.doomLoopChain = Promise.resolve();
365
+ // Armed by an 'escalate' verdict; consumed by the next follow-up request
366
+ // as ONE-TURN overrides (model swap and/or forced openrouter:advisor).
367
+ // Deliberately NOT persisted: an escalation is a reaction to the loop's
368
+ // live state; a resumed run re-detects and re-escalates if still stuck
369
+ // (the CONSUMED budget does persist, via the monitor's escalationsUsed).
370
+ this.pendingDoomLoopEscalation = null;
371
+ // Async tool support (tool.background / tool.deferred). The registry
372
+ // tracks task identity and settled outcomes; created lazily on the first
373
+ // async tool call so runs without async tools carry zero overhead.
374
+ this.asyncToolRegistry = null;
375
+ // Concurrency gates: the round gate bounds simultaneous executions within
376
+ // a tool round; per-tool gates bound one tool across the run; the
377
+ // background pool bounds detached background work.
378
+ this.roundGate = null;
379
+ this.backgroundPool = null;
380
+ this.perToolGates = new Map();
381
+ // Per-call abort controllers for in-flight tool executions, keyed by call
382
+ // id. Aborted on run abort / cancel() so cooperative tool bodies stop.
383
+ this.inflightToolControllers = new Map();
240
384
  this.options = options;
241
385
  this.hooksManager = options.hooks;
386
+ const doomLoopConfig = resolveDoomLoopOption(options.doomLoop);
387
+ this.doomLoopMonitor = doomLoopConfig ? new DoomLoopMonitor(doomLoopConfig) : null;
388
+ this.doomLoopEscalation = doomLoopConfig?.escalation ?? null;
242
389
  // Runtime validation: approval decisions require state
243
390
  const hasApprovalDecisions = (options.approveToolCalls && options.approveToolCalls.length > 0) ||
244
391
  (options.rejectToolCalls && options.rejectToolCalls.length > 0);
@@ -251,6 +398,76 @@ export class ModelResult {
251
398
  this.requireApprovalFn = options.requireApproval ?? null;
252
399
  this.approvedToolCalls = options.approveToolCalls ?? [];
253
400
  this.rejectedToolCalls = options.rejectToolCalls ?? [];
401
+ // Concurrency gates. Round default stays unbounded (existing behavior);
402
+ // the background pool always has a cap (default 16 — detached work has
403
+ // no round to bound it).
404
+ const concurrency = options.toolConcurrency;
405
+ const roundLimit = typeof concurrency === 'number' ? concurrency : concurrency?.round;
406
+ if (roundLimit !== undefined) {
407
+ this.roundGate = new Semaphore(roundLimit);
408
+ }
409
+ const backgroundLimit = typeof concurrency === 'object' ? (concurrency.background ?? 16) : 16;
410
+ this.backgroundPool = new Semaphore(backgroundLimit);
411
+ }
412
+ /** Lazily create the async tool registry. */
413
+ ensureAsyncToolRegistry() {
414
+ if (!this.asyncToolRegistry) {
415
+ this.asyncToolRegistry = new AsyncToolRegistry();
416
+ }
417
+ return this.asyncToolRegistry;
418
+ }
419
+ /** Per-tool concurrency gate, created on first use. */
420
+ perToolGate(toolName) {
421
+ const tool = this.options.tools?.find((t) => isClientTool(t) && t.function.name === toolName);
422
+ if (!tool || isServerTool(tool)) {
423
+ return undefined;
424
+ }
425
+ const limit = tool.function.maxConcurrency;
426
+ if (limit === undefined) {
427
+ return undefined;
428
+ }
429
+ let gate = this.perToolGates.get(toolName);
430
+ if (!gate) {
431
+ gate = new Semaphore(limit);
432
+ this.perToolGates.set(toolName, gate);
433
+ }
434
+ return gate;
435
+ }
436
+ /**
437
+ * Compose the per-call abort signal for one tool execution: the run
438
+ * signal plus a per-call controller the engine can abort independently
439
+ * (timeout race, cancelTask, cancel()). The tool deadline deliberately
440
+ * does NOT ride the signal here — the race in `executeSingleToolCall`
441
+ * owns exactly one timer (which aborts the controller), so the round's
442
+ * timeout resolution and the body's abort observation cannot disagree.
443
+ */
444
+ composeToolSignal(toolName, callId) {
445
+ const tool = this.options.tools?.find((t) => isClientTool(t) && t.function.name === toolName);
446
+ const timeoutMs = tool && !isServerTool(tool)
447
+ ? (tool.function.timeoutMs ?? this.options.toolTimeoutMs)
448
+ : this.options.toolTimeoutMs;
449
+ const controller = new AbortController();
450
+ this.inflightToolControllers.set(callId, controller);
451
+ const signal = this.options.signal
452
+ ? AbortSignal.any([
453
+ controller.signal,
454
+ this.options.signal,
455
+ ])
456
+ : controller.signal;
457
+ return {
458
+ controller,
459
+ signal,
460
+ timeoutMs,
461
+ };
462
+ }
463
+ /** Abort every in-flight tool execution and background task. */
464
+ abortAllToolWork(reason) {
465
+ const error = new Error(reason ?? 'Run aborted');
466
+ for (const controller of this.inflightToolControllers.values()) {
467
+ controller.abort(error);
468
+ }
469
+ this.inflightToolControllers.clear();
470
+ this.asyncToolRegistry?.abortAll(reason);
254
471
  }
255
472
  /**
256
473
  * Get or create the unified turn broadcaster (lazy initialization).
@@ -346,10 +563,11 @@ export class ModelResult {
346
563
  * Push a tool result event to both the legacy tool event broadcaster
347
564
  * and the unified turn broadcaster.
348
565
  */
349
- broadcastToolResult(toolCallId, source, result, preliminaryResults) {
566
+ broadcastToolResult(toolCallId, toolName, source, result, preliminaryResults) {
350
567
  this.toolEventBroadcaster?.push({
351
568
  type: 'tool_result',
352
569
  toolCallId,
570
+ toolName,
353
571
  source,
354
572
  result,
355
573
  ...(preliminaryResults?.length && {
@@ -359,6 +577,7 @@ export class ModelResult {
359
577
  this.turnBroadcaster?.push({
360
578
  type: 'tool.result',
361
579
  toolCallId,
580
+ toolName,
362
581
  source,
363
582
  result,
364
583
  timestamp: Date.now(),
@@ -371,15 +590,17 @@ export class ModelResult {
371
590
  * Push a preliminary result event to both the legacy tool event broadcaster
372
591
  * and the unified turn broadcaster.
373
592
  */
374
- broadcastPreliminaryResult(toolCallId, result) {
593
+ broadcastPreliminaryResult(toolCallId, toolName, result) {
375
594
  this.toolEventBroadcaster?.push({
376
595
  type: 'preliminary_result',
377
596
  toolCallId,
597
+ toolName,
378
598
  result,
379
599
  });
380
600
  this.turnBroadcaster?.push({
381
601
  type: 'tool.preliminary_result',
382
602
  toolCallId,
603
+ toolName,
383
604
  result,
384
605
  timestamp: Date.now(),
385
606
  });
@@ -490,6 +711,13 @@ export class ModelResult {
490
711
  this.resumingFromClientTools = false;
491
712
  stateUpdates.status = 'in_progress';
492
713
  }
714
+ if (this.resumingFromAsyncTools) {
715
+ // The resumed request succeeded — the conversation is moving again.
716
+ // Pending tasks that are still unresolved stay on pendingAsyncTools
717
+ // (resumeToolResults maintains that list); only the status advances.
718
+ this.resumingFromAsyncTools = false;
719
+ stateUpdates.status = 'in_progress';
720
+ }
493
721
  await this.saveStateSafely(stateUpdates);
494
722
  }
495
723
  /**
@@ -497,6 +725,9 @@ export class ModelResult {
497
725
  * Sets the conversation status to 'complete' indicating no further tool execution is needed.
498
726
  */
499
727
  async markStateComplete() {
728
+ // A later conversational turn is a new logical run and may force its
729
+ // first tool call again.
730
+ this.resetForcedToolChoicePolicy();
500
731
  await this.saveStateSafely({
501
732
  status: 'complete',
502
733
  });
@@ -533,6 +764,7 @@ export class ModelResult {
533
764
  // Save partial state
534
765
  if (this.currentState) {
535
766
  const currentToolCalls = extractToolCallsFromResponse(currentResponse);
767
+ this.resetForcedToolChoicePolicy();
536
768
  await this.saveStateSafely({
537
769
  status: 'interrupted',
538
770
  partialResponse: {
@@ -659,6 +891,10 @@ export class ModelResult {
659
891
  */
660
892
  hasExecutableToolCalls(toolCalls) {
661
893
  return toolCalls.some((toolCall) => {
894
+ // The universal task tool is engine-intercepted — always executable.
895
+ if (toolCall.name === TASK_TOOL_NAME && this.taskToolActive()) {
896
+ return true;
897
+ }
662
898
  const tool = this.options.tools?.find((t) => isClientTool(t) && t.function.name === toolCall.name);
663
899
  return tool && isAutoResolvableTool(tool);
664
900
  });
@@ -692,7 +928,7 @@ export class ModelResult {
692
928
  * - `execution`: The tool ran. `result` is the ToolExecutionResult.
693
929
  * `effectiveToolCall` reflects any `mutatedInput` piped by PreToolUse.
694
930
  */
695
- async runToolWithHooks(tool, toolCall, turnContext, onPreliminaryResult) {
931
+ async runToolWithHooks(tool, toolCall, turnContext, onPreliminaryResult, extras) {
696
932
  // Reject raw-string arguments before any hook fires. When the model
697
933
  // produces invalid JSON, the parser leaves `toolCall.arguments` as the
698
934
  // raw string; handing that to PreToolUse would either fail payload
@@ -702,6 +938,23 @@ export class ModelResult {
702
938
  // synthetic error without running the tool or firing hooks.
703
939
  const rawArgs = toolCall.arguments;
704
940
  if (typeof rawArgs === 'string') {
941
+ // Malformed calls are classic doom-loop fuel: a model stuck emitting
942
+ // the same invalid JSON re-triggers this parse error forever. Record
943
+ // the raw string as the call's identity so the streak trips the
944
+ // detector instead of bouncing off the parse error unbounded. The
945
+ // parse-error output below already prevents execution, so 'block'
946
+ // needs no extra handling; 'steer'/'stop' side effects are applied
947
+ // inside the ordered evaluation.
948
+ if (this.doomLoopMonitor) {
949
+ await this.enqueueDoomLoopEvaluation({
950
+ toolName: String(toolCall.name),
951
+ keyMaterial: rawArgs,
952
+ fallbackKeyMaterial: null,
953
+ allowBlock: true,
954
+ detector: 'tool-fingerprint',
955
+ toolCall,
956
+ });
957
+ }
705
958
  const errorMessage = `Failed to parse tool call arguments for "${toolCall.name}": The model provided invalid JSON. ` +
706
959
  `Raw arguments received: "${rawArgs}". ` +
707
960
  'Please provide valid JSON arguments for this tool call.';
@@ -719,6 +972,29 @@ export class ModelResult {
719
972
  },
720
973
  };
721
974
  }
975
+ // Doom-loop checkpoint — BEFORE PreToolUse, on the arguments the MODEL
976
+ // issued (pre-mutation): the loop evidence is the model repeating
977
+ // itself, and a PreToolUse hook that rewrites input each call (e.g.
978
+ // injecting a nonce) must not mask that repetition. A 'block' verdict
979
+ // returns the same `hook_blocked` shape as a PreToolUse block, so every
980
+ // caller handles it identically; PreToolUse deliberately does not fire
981
+ // for doom-blocked calls (nothing will execute — mirrors parse_error).
982
+ const doomOutcome = await this.checkDoomLoopBeforeExecution(tool, toolCall);
983
+ if (doomOutcome.blocked) {
984
+ return {
985
+ type: 'hook_blocked',
986
+ toolCall,
987
+ reason: doomOutcome.reason,
988
+ output: {
989
+ type: 'function_call_output',
990
+ id: `output_${toolCall.id}`,
991
+ callId: toolCall.id,
992
+ output: JSON.stringify({
993
+ error: doomOutcome.reason,
994
+ }),
995
+ },
996
+ };
997
+ }
722
998
  let effectiveToolCall = toolCall;
723
999
  // Emit PreToolUse hook -- can block or mutate input.
724
1000
  if (this.hooksManager) {
@@ -764,13 +1040,18 @@ export class ModelResult {
764
1040
  // performance.now() gives monotonic, sub-ms precision and is immune to
765
1041
  // system clock jumps, unlike Date.now().
766
1042
  const startTime = performance.now();
767
- const result = await executeTool(tool, effectiveToolCall, turnContext, onPreliminaryResult, this.contextStore ?? undefined, this.options.sharedContextSchema);
1043
+ const result = await executeTool(tool, effectiveToolCall, turnContext, onPreliminaryResult, this.contextStore ?? undefined, this.options.sharedContextSchema, extras);
768
1044
  const durationMs = performance.now() - startTime;
769
1045
  // HITL tools may pause (executeTool returns null). No output was produced
770
1046
  // yet, so neither PostToolUse nor PostToolUseFailure fires; they will fire
771
1047
  // if/when the tool is resumed and actually executes.
1048
+ // Async invocations (background/deferred) haven't produced their final
1049
+ // output either — Post hooks fire at settlement via
1050
+ // emitAsyncSettledHooks (in-process delivery). Results delivered by
1051
+ // resumeToolResults in ANOTHER process have no hooksManager there;
1052
+ // that path stays uncovered and is documented on resumeToolResults.
772
1053
  // Emit PostToolUse or PostToolUseFailure.
773
- if (this.hooksManager && result !== null) {
1054
+ if (this.hooksManager && result !== null && !isAsyncToolInvocation(result)) {
774
1055
  if (result.error) {
775
1056
  await this.hooksManager.emit('PostToolUseFailure', {
776
1057
  toolName: effectiveToolCall.name,
@@ -793,6 +1074,557 @@ export class ModelResult {
793
1074
  result,
794
1075
  };
795
1076
  }
1077
+ /**
1078
+ * Advance the doom-loop round counter and declare the round's calls. Called
1079
+ * at every execution-batch boundary (main tool round, auto-approve batch
1080
+ * while pausing, approved-on-resume batch). Identical calls WITHIN one round
1081
+ * are duplicates — one piece of loop evidence, one shared decision.
1082
+ *
1083
+ * `batch` is every call the round will make. A round's identity for one tool
1084
+ * is the *set* of fingerprints it was called with, and that set has to be
1085
+ * complete before any of the round's calls is scored: accumulating it call
1086
+ * by call made a round that is a superset of the previous one transiently
1087
+ * match it, blocking calls that represented real progress, with the outcome
1088
+ * depending on emission order. Declaration is best-effort — a call whose key
1089
+ * material is exempt or unhashable is simply left out, and the per-call
1090
+ * fallback chain in `enqueueDoomLoopEvaluation` still governs identity at
1091
+ * record time.
1092
+ *
1093
+ * Only calls that the round will actually CHECK are declared. A `loopKey`
1094
+ * function is user code that may count or log, so it must not run for a call
1095
+ * the detector never evaluates: manual tool calls (no `execute`, no
1096
+ * `onToolCalled`) are handed to the caller and never recorded, and every
1097
+ * execution path skips them with the same `isAutoResolvableTool` predicate
1098
+ * used here. Their absence from the declared set is also correct on its own
1099
+ * terms — they are not evidence, so they are not part of the round.
1100
+ *
1101
+ * INVARIANT — every declared member must eventually be recorded. A call
1102
+ * that is declared but never reaches the doom-loop checkpoint is a phantom
1103
+ * member of the round's identity: the tool's streak silently resets the
1104
+ * moment the phantom stops being emitted (pinned by the "declared-but-
1105
+ * never-recorded member" test in doom-loop-fanout.test.ts). The filters
1106
+ * below therefore MIRROR every path that skips recording — unknown/manual
1107
+ * tools (`isAutoResolvableTool`, also gating executeAutoApproveTools and
1108
+ * the approval-resume loop), `hookDeniedCalls` (consumed before the
1109
+ * checkpoint in executeSingleToolCall), and `loopKey`-exempt calls (early
1110
+ * return in checkDoomLoopBeforeExecution; the same resolution is cached
1111
+ * here so both sides agree). If you add a new short-circuit between
1112
+ * declaration and the checkpoint — a pre-execution gate, a batch filter —
1113
+ * it MUST be reflected here, or fan-out detection degrades silently for
1114
+ * that tool rather than failing loudly.
1115
+ *
1116
+ * KNOWN RESIDUAL (documented, not mirrored): a per-request timeout or
1117
+ * abort can cancel a call AFTER declaration but BEFORE its checkpoint,
1118
+ * leaving a declared phantom for that round. Bounded and fail-safe: the
1119
+ * round-set streak for that tool resets when the phantom stops recurring,
1120
+ * while per-call streaks are unaffected — detection degrades to per-call
1121
+ * for the affected tool rather than being lost. Un-declaring mid-round
1122
+ * would mutate the round's identity while it is being scored, which is the
1123
+ * incremental-set order-dependence this design exists to prevent.
1124
+ */
1125
+ async beginDoomLoopRound(batch = []) {
1126
+ const monitor = this.doomLoopMonitor;
1127
+ if (!monitor) {
1128
+ return;
1129
+ }
1130
+ this.doomLoopRound++;
1131
+ this.doomLoopRoundDecisions.clear();
1132
+ this.doomLoopRoundKeyMaterial.clear();
1133
+ const declared = [];
1134
+ for (const toolCall of batch) {
1135
+ const tool = this.options.tools?.find((t) => isClientTool(t) && t.function.name === toolCall.name);
1136
+ /*
1137
+ * Never checked => never recorded => must not be declared. Declaring a
1138
+ * call that never arrives inflates the round's identity: a sibling that
1139
+ * IS recorded gets scored against a set containing a phantom member, and
1140
+ * the streak resets spuriously once the phantom stops appearing (e.g. the
1141
+ * model drops a malformed call while still repeating the valid one).
1142
+ * Keeping `loopKey` from running for such a call is the same check.
1143
+ *
1144
+ * This gate deliberately precedes the malformed-arguments branch below:
1145
+ * a raw-string call to an unknown or manual tool is still never recorded.
1146
+ */
1147
+ if (tool === undefined || !isAutoResolvableTool(tool)) {
1148
+ continue;
1149
+ }
1150
+ /*
1151
+ * Same reasoning for a call the PermissionRequest hook denied without
1152
+ * pausing: `hookDeniedCalls` is populated before the round begins, and
1153
+ * `runToolWithHooks` synthesizes the rejection before reaching the
1154
+ * doom-loop checkpoint, so the call is never recorded either.
1155
+ */
1156
+ if (this.hookDeniedCalls.has(toolCall.id)) {
1157
+ continue;
1158
+ }
1159
+ const rawArgs = toolCall.arguments;
1160
+ if (typeof rawArgs === 'string') {
1161
+ // Malformed call: its identity is the raw string (see runToolWithHooks).
1162
+ declared.push({
1163
+ toolName: String(toolCall.name),
1164
+ keyMaterial: rawArgs,
1165
+ });
1166
+ continue;
1167
+ }
1168
+ /*
1169
+ * `resolveLoopKeyMaterial` catches a throwing `loopKey`, but not every
1170
+ * throw: the field-list form reads `args[field]`, so a getter on the
1171
+ * arguments object throws out of it uncaught. Declaration runs once for
1172
+ * the whole batch, so letting that escape would fail the entire round —
1173
+ * and the run — over one odd call, breaking the invariant that detection
1174
+ * never affects a run except through its ladder actions. Skip just that
1175
+ * call; the per-call checkpoint hits the same throw inside its own
1176
+ * try/catch and applies the documented fallback chain there.
1177
+ */
1178
+ let resolution;
1179
+ try {
1180
+ resolution = resolveLoopKeyMaterial(isClientTool(tool) ? tool.function.loopKey : undefined, (toolCall.arguments ?? {}));
1181
+ }
1182
+ catch (error) {
1183
+ console.warn(`[DoomLoop] could not resolve loop identity for "${toolCall.name}" while declaring the round; ` +
1184
+ 'excluding it from the round set:', error);
1185
+ continue;
1186
+ }
1187
+ /*
1188
+ * Cache so the per-call checkpoint does not invoke `loopKey` a second
1189
+ * time; keyed by call id. Ids are MODEL-emitted strings and nothing
1190
+ * upstream enforces uniqueness, so a duplicate id must not alias one
1191
+ * call's identity onto another: overwriting here meant the first call's
1192
+ * checkpoint read the second call's key material, its true fingerprint
1193
+ * was never recorded, and a model emitting `(id=X, read a), (id=X,
1194
+ * read b_i)` each round evaded the per-call detector for `a` entirely
1195
+ * (measured: zero detections across four such rounds). On a duplicate
1196
+ * id the cache poisons that id instead: both calls fall through to
1197
+ * per-call resolution at the checkpoint, costing at most a duplicate
1198
+ * `loopKey` invocation for the colliding calls — correctness over the
1199
+ * single-invocation economy, for protocol-malformed input only.
1200
+ */
1201
+ if (this.doomLoopRoundKeyMaterial.has(toolCall.id)) {
1202
+ this.doomLoopRoundKeyMaterial.set(toolCall.id, DUPLICATE_CALL_ID);
1203
+ }
1204
+ else {
1205
+ this.doomLoopRoundKeyMaterial.set(toolCall.id, resolution);
1206
+ }
1207
+ if (resolution.kind === 'exempt') {
1208
+ continue;
1209
+ }
1210
+ declared.push({
1211
+ toolName: String(toolCall.name),
1212
+ keyMaterial: resolution.keyMaterial,
1213
+ });
1214
+ }
1215
+ await monitor.declareRound(this.doomLoopRound, declared);
1216
+ }
1217
+ /**
1218
+ * Evaluate one call against the doom-loop monitor, serialized on
1219
+ * `doomLoopChain` so parallel executions record in the deterministic
1220
+ * order their evaluations were ENQUEUED (the `.map()` over a round's
1221
+ * calls runs synchronously to its first await, i.e. model-emission
1222
+ * order) — not in hash-completion order.
1223
+ *
1224
+ * Applies the fallback invariant: detection must never take down or
1225
+ * alter a run except via its defined actions. Unhashable key material
1226
+ * (bigint, non-finite numbers, circular/too-deep structures from a
1227
+ * computed loopKey) degrades to `fallbackKeyMaterial`; if THAT is also
1228
+ * unhashable (parse-error path passes null), detection is skipped for
1229
+ * the call and it proceeds normally.
1230
+ *
1231
+ * Returns the final decision for this call (`action: 'proceed'` when no
1232
+ * rung fired or the call is exempt/skipped; `message` carries the
1233
+ * verdict's explanation for block outputs). Side effects (steer queue,
1234
+ * stop arm) are applied here so every caller shares them.
1235
+ */
1236
+ enqueueDoomLoopEvaluation(evaluation) {
1237
+ const run = async () => {
1238
+ const monitor = this.doomLoopMonitor;
1239
+ if (!monitor) {
1240
+ return {
1241
+ action: 'proceed',
1242
+ };
1243
+ }
1244
+ let record;
1245
+ try {
1246
+ record = await monitor.recordToolCall(evaluation.toolName, evaluation.keyMaterial, this.doomLoopRound, {
1247
+ allowBlock: evaluation.allowBlock,
1248
+ detector: evaluation.detector,
1249
+ });
1250
+ }
1251
+ catch (error) {
1252
+ // Unhashable key material (bigint/NaN/circular/too-deep loopKey
1253
+ // output). Fall back to the raw-arguments identity; if that also
1254
+ // fails, skip detection for this call — never fail the call itself.
1255
+ console.warn(`[DoomLoop] could not fingerprint call to "${evaluation.toolName}"; ` +
1256
+ `${evaluation.fallbackKeyMaterial !== null ? 'falling back to full arguments' : 'skipping detection for this call'}:`, error);
1257
+ if (evaluation.fallbackKeyMaterial === null) {
1258
+ return {
1259
+ action: 'proceed',
1260
+ };
1261
+ }
1262
+ try {
1263
+ record = await monitor.recordToolCall(evaluation.toolName, evaluation.fallbackKeyMaterial, this.doomLoopRound, {
1264
+ allowBlock: evaluation.allowBlock,
1265
+ detector: evaluation.detector,
1266
+ });
1267
+ }
1268
+ catch (fallbackError) {
1269
+ console.warn(`[DoomLoop] fallback identity for "${evaluation.toolName}" also unhashable; skipping detection for this call:`, fallbackError);
1270
+ return {
1271
+ action: 'proceed',
1272
+ };
1273
+ }
1274
+ }
1275
+ // Same (tool, fingerprint) already decided this round: reuse the
1276
+ // decision — no second streak increment, no second hook emission.
1277
+ const decisionKey = `${evaluation.toolName}\n${record.fingerprint}`;
1278
+ if (record.duplicateInRound) {
1279
+ return (this.doomLoopRoundDecisions.get(decisionKey) ?? {
1280
+ action: 'proceed',
1281
+ });
1282
+ }
1283
+ if (!record.verdict) {
1284
+ const decision = {
1285
+ action: 'proceed',
1286
+ };
1287
+ this.doomLoopRoundDecisions.set(decisionKey, decision);
1288
+ return decision;
1289
+ }
1290
+ const action = await this.applyDoomLoopVerdict(record.verdict, evaluation.toolCall);
1291
+ const decision = {
1292
+ action,
1293
+ message: record.verdict.message,
1294
+ };
1295
+ this.doomLoopRoundDecisions.set(decisionKey, decision);
1296
+ this.applyDoomLoopSideEffects(action, record.verdict);
1297
+ return decision;
1298
+ };
1299
+ // Serialize on the chain; keep the chain alive even if an evaluation
1300
+ // throws unexpectedly (it should not — run() catches everything).
1301
+ const evaluationPromise = this.doomLoopChain.then(run, run);
1302
+ this.doomLoopChain = evaluationPromise.catch(() => undefined);
1303
+ return evaluationPromise;
1304
+ }
1305
+ /**
1306
+ * Doom-loop checkpoint for one tool call, run before PreToolUse/execution.
1307
+ *
1308
+ * Resolves the tool's `loopKey` declaration (function |
1309
+ * false | absent — see {@link resolveLoopKeyMaterial}) and evaluates the
1310
+ * call against the monitor. Ladder actions:
1311
+ *
1312
+ * - `observe` — hook already emitted, nothing else.
1313
+ * - `steer` — queue a corrective user message for the next model turn.
1314
+ * - `block` — return `blocked` so the caller synthesizes an error output
1315
+ * without executing. The model sees the explanation in the tool result.
1316
+ * - `stop` — arm `doomLoopStop`; ALSO blocks this call (executing a
1317
+ * call the detector just condemned, then stopping, would be
1318
+ * incoherent). The loop halts before the next model request.
1319
+ */
1320
+ async checkDoomLoopBeforeExecution(tool, toolCall) {
1321
+ if (!this.doomLoopMonitor) {
1322
+ return {
1323
+ blocked: false,
1324
+ };
1325
+ }
1326
+ // The parse-error guard in runToolWithHooks already rejected raw-string
1327
+ // arguments, so what remains is the parsed record (or null/undefined for
1328
+ // no-args calls — coerced to {} the same way the PreToolUse payload is).
1329
+ const callArguments = (toolCall.arguments ?? {});
1330
+ /*
1331
+ * Reuse what `beginDoomLoopRound` resolved for this call when it declared
1332
+ * the round. `loopKey` is user code — it may count, log, or return a fresh
1333
+ * value each time — so it must run at most once per call. Resolving here
1334
+ * as well would double-invoke it and, for a non-repeatable callback, make
1335
+ * the declared identity and the recorded identity disagree.
1336
+ */
1337
+ const cached = this.doomLoopRoundKeyMaterial.get(toolCall.id);
1338
+ let resolution;
1339
+ if (cached !== undefined && cached !== DUPLICATE_CALL_ID) {
1340
+ resolution = cached;
1341
+ }
1342
+ else {
1343
+ /*
1344
+ * Not declared (undeclared round, or the declaration skipped this call).
1345
+ * `resolveLoopKeyMaterial` can throw despite catching `loopKey` itself —
1346
+ * the field-list form reads `args[field]`, so a getter on the arguments
1347
+ * throws out of it. Detection must never reject a call or fail a run
1348
+ * except through a ladder action, so skip detection for this one call.
1349
+ */
1350
+ try {
1351
+ resolution = resolveLoopKeyMaterial(isClientTool(tool) ? tool.function.loopKey : undefined, callArguments);
1352
+ }
1353
+ catch (error) {
1354
+ console.warn(`[DoomLoop] could not resolve loop identity for "${toolCall.name}"; ` +
1355
+ 'skipping detection for this call:', error);
1356
+ return {
1357
+ blocked: false,
1358
+ };
1359
+ }
1360
+ }
1361
+ if (resolution.kind === 'exempt') {
1362
+ return {
1363
+ blocked: false,
1364
+ };
1365
+ }
1366
+ if (resolution.kind === 'fallback') {
1367
+ console.warn(`[DoomLoop] tool "${toolCall.name}": ${resolution.warning}`);
1368
+ }
1369
+ const decision = await this.enqueueDoomLoopEvaluation({
1370
+ toolName: String(toolCall.name),
1371
+ keyMaterial: resolution.keyMaterial,
1372
+ fallbackKeyMaterial: resolution.keyMaterial === callArguments ? null : callArguments,
1373
+ allowBlock: true,
1374
+ detector: 'tool-fingerprint',
1375
+ toolCall,
1376
+ });
1377
+ if (decision.action === 'block' || decision.action === 'stop') {
1378
+ return {
1379
+ blocked: true,
1380
+ reason: decision.message ??
1381
+ `Doom loop suspected: tool "${toolCall.name}" is repeating identical calls. ` +
1382
+ 'Repeating the call will not change the result. Take a different approach.',
1383
+ };
1384
+ }
1385
+ return {
1386
+ blocked: false,
1387
+ };
1388
+ }
1389
+ /**
1390
+ * Emit the DoomLoopDetected hook for a verdict and resolve the final
1391
+ * action. Handlers may override per event (`overrideAction`), letting
1392
+ * userland escalate or de-escalate without reconfiguring the ladder;
1393
+ * the LAST override in the chain wins (matching PermissionRequest's
1394
+ * last-wins convention). Non-blockable verdicts (text, server tools —
1395
+ * the tokens are already emitted / the tool already ran) downgrade a
1396
+ * 'block' override to 'observe'. An 'escalate' override is honored only
1397
+ * when an escalation mechanism is configured with remaining budget;
1398
+ * otherwise it downgrades to 'observe' (never silently to a stronger
1399
+ * action).
1400
+ */
1401
+ async applyDoomLoopVerdict(verdict, toolCall) {
1402
+ let action = verdict.action;
1403
+ if (this.hooksManager) {
1404
+ const emit = await this.hooksManager.emit('DoomLoopDetected', {
1405
+ detector: verdict.detector,
1406
+ action: verdict.action,
1407
+ streak: verdict.streak,
1408
+ fingerprint: verdict.fingerprint,
1409
+ ...(verdict.toolName !== undefined && {
1410
+ toolName: verdict.toolName,
1411
+ }),
1412
+ ...(toolCall !== undefined &&
1413
+ isRecord(toolCall.arguments) && {
1414
+ toolInput: toolCall.arguments,
1415
+ }),
1416
+ message: verdict.message,
1417
+ }, this.hookEmitContext(verdict.toolName));
1418
+ for (const result of emit.results) {
1419
+ if (result?.overrideAction) {
1420
+ action = result.overrideAction;
1421
+ }
1422
+ }
1423
+ }
1424
+ if (verdict.detector !== 'tool-fingerprint' && action === 'block') {
1425
+ // Nothing to block for text/server-tool verdicts; observe instead of
1426
+ // silently acting stronger/weaker than the handler intended.
1427
+ action = 'observe';
1428
+ }
1429
+ if (action === 'escalate' && !(this.doomLoopMonitor?.canEscalate() ?? false)) {
1430
+ // Ladder verdicts are already budget-gated by the monitor; this
1431
+ // catches hook overrides requesting escalation without config/budget.
1432
+ action = 'observe';
1433
+ }
1434
+ return action;
1435
+ }
1436
+ /**
1437
+ * Apply the side effects of a resolved doom-loop action shared by every
1438
+ * checkpoint: queue steer guidance, arm an escalation for the next
1439
+ * request, or arm the stop verdict. 'block' has no shared side effect —
1440
+ * the calling checkpoint synthesizes the blocked output itself.
1441
+ */
1442
+ applyDoomLoopSideEffects(action, verdict) {
1443
+ if (action === 'steer') {
1444
+ this.queueDoomLoopSteer(verdict.message);
1445
+ }
1446
+ else if (action === 'escalate') {
1447
+ // Latch the FIRST escalation verdict for the next request; a second
1448
+ // verdict in the same window (e.g. tool + text detectors both firing)
1449
+ // must not double-spend the budget.
1450
+ if (this.pendingDoomLoopEscalation === null) {
1451
+ this.pendingDoomLoopEscalation = verdict;
1452
+ // The model should know why its next turn looks different. Rides
1453
+ // the persisted steer path so the transcript stays well-formed.
1454
+ this.queueDoomLoopSteer(`${verdict.message} An escalated turn follows: use the additional guidance to change course.`);
1455
+ }
1456
+ }
1457
+ else if (action === 'stop') {
1458
+ this.doomLoopStop = verdict;
1459
+ }
1460
+ }
1461
+ /**
1462
+ * Consume a pending escalation into ONE-TURN request overrides:
1463
+ *
1464
+ * - `model` — replace the request's model for this dispatch only (the
1465
+ * base `resolvedRequest` is never mutated, so the following turn
1466
+ * reverts automatically).
1467
+ * - `advisor` — append an `openrouter:advisor` server tool (transcript
1468
+ * forwarded, instructions describing the detected loop) and pin
1469
+ * `toolChoice` to it via `allowed_tools`/`required`, so the stuck model
1470
+ * must consult the advisor before doing anything else this turn.
1471
+ *
1472
+ * Burns one unit of the escalation budget (persisted via the monitor) at
1473
+ * APPLICATION time — verdicts the engine never got to apply (run stopped,
1474
+ * paused, or overridden) do not spend.
1475
+ */
1476
+ takeDoomLoopEscalationOverrides() {
1477
+ const verdict = this.pendingDoomLoopEscalation;
1478
+ const config = this.doomLoopEscalation;
1479
+ if (!verdict || !config || !this.doomLoopMonitor?.canEscalate()) {
1480
+ this.pendingDoomLoopEscalation = null;
1481
+ return null;
1482
+ }
1483
+ this.pendingDoomLoopEscalation = null;
1484
+ this.doomLoopMonitor.consumeEscalation();
1485
+ const overrides = {};
1486
+ if (config.model !== undefined) {
1487
+ overrides['model'] = config.model;
1488
+ // A single-model override must not be shadowed by a fallback list.
1489
+ overrides['models'] = undefined;
1490
+ }
1491
+ if (config.advisor !== undefined && config.advisor !== false) {
1492
+ const advisorParameters = {
1493
+ forwardTranscript: true,
1494
+ instructions: 'You are an escalation advisor. The executing model appears stuck in a loop: ' +
1495
+ `${verdict.message} Diagnose why its approach is failing and give concrete, ` +
1496
+ 'specific instructions for a DIFFERENT approach. Do not restate the problem.',
1497
+ ...(typeof config.advisor === 'object' ? config.advisor : {}),
1498
+ };
1499
+ const existingTools = Array.isArray(this.resolvedRequest?.['tools'])
1500
+ ? this.resolvedRequest['tools']
1501
+ : [];
1502
+ overrides['tools'] = [
1503
+ ...existingTools,
1504
+ {
1505
+ type: 'openrouter:advisor',
1506
+ parameters: advisorParameters,
1507
+ },
1508
+ ];
1509
+ // Force the consult: constrain this turn's tool surface to the
1510
+ // advisor and require a call.
1511
+ overrides['toolChoice'] = {
1512
+ type: 'allowed_tools',
1513
+ mode: 'required',
1514
+ tools: [
1515
+ {
1516
+ type: 'openrouter:advisor',
1517
+ },
1518
+ ],
1519
+ };
1520
+ }
1521
+ return overrides;
1522
+ }
1523
+ /**
1524
+ * Step-level doom-loop checkpoint on a fresh model response:
1525
+ *
1526
+ * 1. Text detectors — within-response token repetition + cross-step
1527
+ * identical-text streak.
1528
+ * 2. Server-tool fingerprints — server tools (web_search_call etc.) never
1529
+ * pass through runToolWithHooks; their repetition is detected here,
1530
+ * post-execution, from the echoed call fields on the output item.
1531
+ * Post-execution ⇒ block is meaningless (allowBlock: false); the
1532
+ * actions are observe/steer/stop.
1533
+ *
1534
+ * Runs where the loop already inspects each fresh response, so scripted
1535
+ * transcripts exercise it deterministically.
1536
+ */
1537
+ async checkDoomLoopForResponse(response) {
1538
+ if (!this.doomLoopMonitor) {
1539
+ return;
1540
+ }
1541
+ let verdict;
1542
+ try {
1543
+ verdict = await this.doomLoopMonitor.recordAssistantText(extractTextFromResponse(response));
1544
+ }
1545
+ catch (error) {
1546
+ console.warn('[DoomLoop] text checkpoint failed; skipping for this response:', error);
1547
+ verdict = undefined;
1548
+ }
1549
+ if (verdict) {
1550
+ const action = await this.applyDoomLoopVerdict(verdict);
1551
+ this.applyDoomLoopSideEffects(action, verdict);
1552
+ }
1553
+ // Server-tool repetition. The response's server-tool output items echo
1554
+ // their call identity (action/query fields, item type as the tool name).
1555
+ for (const item of response.output) {
1556
+ if (!hasTypeProperty(item) || !isServerToolResultItem(item)) {
1557
+ continue;
1558
+ }
1559
+ const identity = extractServerToolIdentity(item);
1560
+ if (identity === null) {
1561
+ continue;
1562
+ }
1563
+ await this.enqueueDoomLoopEvaluation({
1564
+ toolName: `server:${item.type}`,
1565
+ keyMaterial: identity,
1566
+ fallbackKeyMaterial: null,
1567
+ allowBlock: false,
1568
+ detector: 'server-tool-fingerprint',
1569
+ });
1570
+ }
1571
+ }
1572
+ /**
1573
+ * Queue steer guidance, deduplicating identical messages (one verdict can
1574
+ * repeat across calls in a round; the model needs the guidance once).
1575
+ */
1576
+ queueDoomLoopSteer(message) {
1577
+ if (!this.pendingDoomLoopSteer.includes(message)) {
1578
+ this.pendingDoomLoopSteer.push(message);
1579
+ }
1580
+ }
1581
+ /**
1582
+ * Inject any queued doom-loop steer messages as a user message before the
1583
+ * next model turn. Reuses the Stop-hook appendPrompt injection path, so
1584
+ * state/messages advance observably. Called in-loop before each follow-up
1585
+ * request AND before every pause-persist, so guidance queued right before
1586
+ * a pause lands in the persisted conversation instead of being dropped.
1587
+ */
1588
+ async flushDoomLoopSteer() {
1589
+ if (this.pendingDoomLoopSteer.length === 0) {
1590
+ return;
1591
+ }
1592
+ const prompt = this.pendingDoomLoopSteer.join('\n');
1593
+ this.pendingDoomLoopSteer = [];
1594
+ await this.injectAppendPromptMessage(prompt);
1595
+ }
1596
+ /**
1597
+ * Halt the run for an armed doom-loop stop verdict, keeping the persisted
1598
+ * history well-formed: every executable `function_call` in the current
1599
+ * response that has no output yet gets a synthesized halt-error output
1600
+ * (persisted via the normal tool-result path), so a stateful resume never
1601
+ * sends a dangling `function_call` (providers reject those with a 400).
1602
+ *
1603
+ * Used by every stop site: the pre-round break, the post-round break, the
1604
+ * no-tools early return, the allow-final-response gate, and the approval-
1605
+ * resume gate.
1606
+ *
1607
+ * @param resolvedCallIds - call ids that already have outputs this round.
1608
+ */
1609
+ async sealDoomLoopStop(currentResponse, resolvedCallIds) {
1610
+ const verdict = this.doomLoopStop;
1611
+ const toolCalls = extractToolCallsFromResponse(currentResponse);
1612
+ const unresolved = toolCalls.filter((tc) => !resolvedCallIds?.has(tc.id));
1613
+ if (unresolved.length > 0) {
1614
+ const haltMessage = verdict
1615
+ ? `Run halted by doom-loop detection: ${verdict.message}`
1616
+ : 'Run halted by doom-loop detection.';
1617
+ const haltOutputs = unresolved.map((tc) => ({
1618
+ type: 'function_call_output',
1619
+ id: `output_${tc.id}`,
1620
+ callId: tc.id,
1621
+ output: JSON.stringify({
1622
+ error: haltMessage,
1623
+ }),
1624
+ }));
1625
+ await this.saveToolResultsToState(haltOutputs);
1626
+ }
1627
+ }
796
1628
  /**
797
1629
  * Build the per-emit context for lifecycle hook emits. Threads this run's
798
1630
  * session identity into `context.sessionId` on every emit, so a
@@ -809,6 +1641,40 @@ export class ModelResult {
809
1641
  sessionId: this.currentState?.id ?? '',
810
1642
  };
811
1643
  }
1644
+ /**
1645
+ * Emit PostToolUse / PostToolUseFailure for an async task at settlement —
1646
+ * the point its final output actually exists. Round-synchronous tools
1647
+ * emit these in runToolWithHooks; async invocations skip that emission
1648
+ * (no output yet) and land here instead. `durationMs` is the task's
1649
+ * start-to-settle wall clock (includes background-pool queue wait, which
1650
+ * counts against the task's own timeout budget). Observation-only — a
1651
+ * throwing hook must not break delivery.
1652
+ */
1653
+ async emitAsyncSettledHooks(task) {
1654
+ if (!this.hooksManager) {
1655
+ return;
1656
+ }
1657
+ try {
1658
+ if (task.status === 'completed') {
1659
+ await this.hooksManager.emit('PostToolUse', {
1660
+ toolName: task.name,
1661
+ toolInput: task.input ?? {},
1662
+ toolOutput: task.result,
1663
+ durationMs: task.durationMs,
1664
+ }, this.hookEmitContext(task.name));
1665
+ }
1666
+ else {
1667
+ await this.hooksManager.emit('PostToolUseFailure', {
1668
+ toolName: task.name,
1669
+ toolInput: task.input ?? {},
1670
+ error: new Error(task.error ?? `Task ${task.taskId} ${task.status}`),
1671
+ }, this.hookEmitContext(task.name));
1672
+ }
1673
+ }
1674
+ catch (error) {
1675
+ console.warn('[AsyncTools] PostToolUse hook threw during async settlement:', error);
1676
+ }
1677
+ }
812
1678
  /**
813
1679
  * Emit SessionEnd exactly once, and only when a matching SessionStart
814
1680
  * actually succeeded. Safe to call from multiple teardown paths.
@@ -821,28 +1687,41 @@ export class ModelResult {
821
1687
  await this.hooksManager.emit('SessionEnd', {
822
1688
  reason,
823
1689
  ...(this.sessionUsage.modelCalls > 0 && {
824
- totalUsage: {
825
- modelCalls: this.sessionUsage.modelCalls,
826
- inputTokens: this.sessionUsage.inputTokens,
827
- outputTokens: this.sessionUsage.outputTokens,
828
- totalTokens: this.sessionUsage.totalTokens,
829
- cachedTokens: this.sessionUsage.cachedTokens,
830
- reasoningTokens: this.sessionUsage.reasoningTokens,
831
- ...(this.sessionUsage.hasCost && {
832
- cost: this.sessionUsage.cost,
833
- }),
834
- },
1690
+ totalUsage: this.snapshotSessionUsage(),
835
1691
  }),
836
1692
  }, this.hookEmitContext());
837
1693
  }
1694
+ /**
1695
+ * Materialize the running session aggregate as an immutable
1696
+ * `SessionUsageTotals`. `cost` is present only when at least one response
1697
+ * reported it, matching the hook payload's optionality (a summed cost of
1698
+ * `0` from cost-less responses would read as "free" rather than "unknown").
1699
+ *
1700
+ * Single source of truth for both `SessionEnd.totalUsage` and the public
1701
+ * `getUsage()` accessor, so the two can never drift.
1702
+ */
1703
+ snapshotSessionUsage() {
1704
+ return {
1705
+ modelCalls: this.sessionUsage.modelCalls,
1706
+ inputTokens: this.sessionUsage.inputTokens,
1707
+ outputTokens: this.sessionUsage.outputTokens,
1708
+ totalTokens: this.sessionUsage.totalTokens,
1709
+ cachedTokens: this.sessionUsage.cachedTokens,
1710
+ reasoningTokens: this.sessionUsage.reasoningTokens,
1711
+ ...(this.sessionUsage.hasCost && {
1712
+ cost: this.sessionUsage.cost,
1713
+ }),
1714
+ };
1715
+ }
838
1716
  /**
839
1717
  * Emit PostModelCall for a completed model response and fold its usage
840
1718
  * into the session aggregate. One emit per materialized response.
1719
+ *
1720
+ * Accumulation runs unconditionally — before the hooks short-circuit —
1721
+ * because `getUsage()` surfaces these totals to callers who configured no
1722
+ * hooks at all. Only the hook emit is gated on a `HooksManager`.
841
1723
  */
842
1724
  async emitPostModelCall(response, startedAt, turnType, turnNumber) {
843
- if (!this.hooksManager) {
844
- return;
845
- }
846
1725
  const usage = extractModelCallUsage(response.usage);
847
1726
  this.sessionUsage.modelCalls++;
848
1727
  if (usage) {
@@ -856,6 +1735,9 @@ export class ModelResult {
856
1735
  this.sessionUsage.hasCost = true;
857
1736
  }
858
1737
  }
1738
+ if (!this.hooksManager) {
1739
+ return;
1740
+ }
859
1741
  await this.hooksManager.emit('PostModelCall', {
860
1742
  sessionId: this.currentState?.id ?? '',
861
1743
  responseId: response.id,
@@ -927,36 +1809,56 @@ export class ModelResult {
927
1809
  }
928
1810
  /**
929
1811
  * Session teardown for the no-tools stream paths, which bypass
930
- * executeToolsIfNeeded (the normal SessionEnd site). Emits SessionEnd once
931
- * and drains pending hook work. Never throws: teardown must not mask the
932
- * stream's own outcome.
1812
+ * executeToolsIfNeeded (the normal SessionEnd site). Materializes the
1813
+ * parked model-call telemetry, emits SessionEnd once, and drains pending
1814
+ * hook work. Never throws: teardown must not mask the stream's own outcome.
933
1815
  */
934
1816
  async finishHooksSessionForStream(reason = 'complete') {
935
- if (!this.hooksManager) {
936
- return;
937
- }
1817
+ // Materialize the parked initial-call telemetry when the stream fully
1818
+ // completed (the retained buffer replays without touching the source).
1819
+ // A failed/errored stream skips it: no materialized response exists.
1820
+ // (`response.incomplete` responses DO emit — they are materialized, have
1821
+ // a generation id, and consumed tokens.)
1822
+ // Runs even without a HooksManager: this is the only site that folds the
1823
+ // no-tools streaming response into `sessionUsage`, which `getUsage()`
1824
+ // reports to hook-less callers.
1825
+ // Isolated try: a telemetry failure (e.g. a buffer without a completion
1826
+ // event, or a throwing strict-mode handler) must not skip SessionEnd or
1827
+ // the drain below — those are contractual on every exit path.
938
1828
  try {
939
- // Materialize the parked initial-call telemetry when the stream fully
940
- // completed (the retained buffer replays without touching the source).
941
- // A failed/errored stream skips it: no materialized response exists.
942
- // (`response.incomplete` responses DO emit — they are materialized, have
943
- // a generation id, and consumed tokens.)
944
- // Isolated try: a telemetry failure (e.g. a buffer without a completion
945
- // event, or a throwing strict-mode handler) must not skip SessionEnd or
946
- // the drain below — those are contractual on every exit path.
947
- try {
948
- if (this.pendingModelCall) {
949
- if (this.finalResponse) {
950
- await this.emitPendingModelCallOnce(this.finalResponse);
951
- }
952
- else if (this.reusableStream?.isComplete) {
953
- await this.emitPendingModelCallOnce(await consumeStreamForCompletion(this.reusableStream));
1829
+ if (this.pendingModelCall) {
1830
+ if (this.finalResponse) {
1831
+ await this.emitPendingModelCallOnce(this.finalResponse);
1832
+ }
1833
+ else if (this.reusableStream?.isComplete) {
1834
+ // Sync backward scan of the retained buffer — not a consumer
1835
+ // replay, which would cost one microtask hop per buffered event
1836
+ // on every hook-less streaming teardown.
1837
+ await this.emitPendingModelCallOnce(extractCompletionFromBuffer(this.reusableStream));
1838
+ }
1839
+ else if (this.reusableStream) {
1840
+ // Consumers stop at the terminal event (streamTerminationEvents),
1841
+ // usually before the pump reads the source close that flips
1842
+ // `isComplete` — with a real network stream the close frame
1843
+ // arrives after `response.completed`. The terminal event is
1844
+ // already buffered in that window, so recover it rather than
1845
+ // dropping the parked telemetry. Stays silent (no emit, no
1846
+ // throw) when nothing terminal was buffered — e.g. an errored
1847
+ // mid-flight stream, where no materialized response exists.
1848
+ const buffered = tryExtractCompletionFromBuffer(this.reusableStream);
1849
+ if (buffered) {
1850
+ await this.emitPendingModelCallOnce(buffered);
954
1851
  }
955
1852
  }
956
1853
  }
957
- catch (telemetryError) {
958
- console.warn('[PostModelCall] error during stream teardown:', telemetryError);
959
- }
1854
+ }
1855
+ catch (telemetryError) {
1856
+ console.warn('[PostModelCall] error during stream teardown:', telemetryError);
1857
+ }
1858
+ if (!this.hooksManager) {
1859
+ return;
1860
+ }
1861
+ try {
960
1862
  await this.emitSessionEndOnce(reason);
961
1863
  await this.hooksManager.drain();
962
1864
  }
@@ -1090,13 +1992,75 @@ export class ModelResult {
1090
1992
  };
1091
1993
  }
1092
1994
  /**
1093
- * Execute tools that can auto-execute (don't require approval) in parallel.
1995
+ * Settle an async invocation on the approval/resume path, where results
1996
+ * are accumulated as `UnsentToolResult`s rather than round outputs.
1094
1997
  *
1095
- * @param toolCalls - The tool calls to execute
1096
- * @param turnContext - The current turn context
1097
- * @returns Array of unsent tool results for later submission
1998
+ * - background: degrade to synchronous execution — the approval flow is
1999
+ * already a discrete resume step, so there is no round to unblock; the
2000
+ * work is awaited and its (validated) value becomes the unsent result.
2001
+ * - defer: the external task HAS been started by `start()`; record the
2002
+ * pending placeholder as the unsent result and track the task on
2003
+ * `pendingAsyncTools` so `resumeToolResults` can deliver the real
2004
+ * result later as an envelope.
1098
2005
  */
1099
- async executeAutoApproveTools(toolCalls, turnContext) {
2006
+ async settleAsyncInvocationAsUnsent(tc, invocation) {
2007
+ if (invocation.asyncMode === 'background') {
2008
+ try {
2009
+ const result = await invocation.run();
2010
+ return createUnsentResult(tc.id, String(tc.name), result);
2011
+ }
2012
+ catch (error) {
2013
+ return createRejectedResult(tc.id, String(tc.name), error instanceof Error ? error.message : String(error));
2014
+ }
2015
+ }
2016
+ const registry = this.ensureAsyncToolRegistry();
2017
+ const liveTask = registry.trackDeferred({
2018
+ callId: tc.id,
2019
+ taskId: invocation.taskId,
2020
+ name: String(tc.name),
2021
+ ...(invocation.pollAfterMs !== undefined && {
2022
+ pollAfterMs: invocation.pollAfterMs,
2023
+ }),
2024
+ ...(invocation.expiresAt !== undefined && {
2025
+ expiresAt: invocation.expiresAt,
2026
+ }),
2027
+ });
2028
+ const task = {
2029
+ callId: tc.id,
2030
+ taskId: invocation.taskId,
2031
+ name: String(tc.name),
2032
+ mode: 'defer',
2033
+ status: 'working',
2034
+ startedAt: liveTask.startedAt,
2035
+ ...(invocation.pollAfterMs !== undefined && {
2036
+ pollAfterMs: invocation.pollAfterMs,
2037
+ }),
2038
+ ...(invocation.expiresAt !== undefined && {
2039
+ expiresAt: invocation.expiresAt,
2040
+ }),
2041
+ };
2042
+ if (this.currentState) {
2043
+ this.currentState = updateState(this.currentState, {
2044
+ pendingAsyncTools: [
2045
+ ...(this.currentState.pendingAsyncTools ?? []).filter((t) => t.callId !== tc.id),
2046
+ task,
2047
+ ],
2048
+ });
2049
+ }
2050
+ const placeholder = this.buildPendingPlaceholder(tc, invocation.taskId, invocation.ack, invocation.pollAfterMs);
2051
+ return createUnsentResult(tc.id, String(tc.name), typeof placeholder.output === 'string' ? JSON.parse(placeholder.output) : placeholder.output);
2052
+ }
2053
+ /**
2054
+ * Execute tools that can auto-execute (don't require approval) in parallel.
2055
+ *
2056
+ * @param toolCalls - The tool calls to execute
2057
+ * @param turnContext - The current turn context
2058
+ * @returns Array of unsent tool results for later submission
2059
+ */
2060
+ async executeAutoApproveTools(toolCalls, turnContext) {
2061
+ // Auto-approved batch = one doom-loop round: identical parallel calls
2062
+ // count once (see beginDoomLoopRound).
2063
+ await this.beginDoomLoopRound(toolCalls);
1100
2064
  const toolCallPromises = toolCalls.map(async (tc) => {
1101
2065
  const tool = this.options.tools?.find((t) => isClientTool(t) && t.function.name === tc.name);
1102
2066
  if (!tool || !isAutoResolvableTool(tool)) {
@@ -1107,7 +2071,7 @@ export class ModelResult {
1107
2071
  // JSON-parse failures so hooks never see a malformed payload.
1108
2072
  const hookOutcome = await this.runToolWithHooks(tool, tc, turnContext);
1109
2073
  if (hookOutcome.type === 'parse_error') {
1110
- this.broadcastToolResult(tc.id, isMcpTool(tool) ? 'mcp' : 'client', {
2074
+ this.broadcastToolResult(tc.id, String(tc.name), isMcpTool(tool) ? 'mcp' : 'client', {
1111
2075
  error: hookOutcome.errorMessage,
1112
2076
  });
1113
2077
  return createRejectedResult(tc.id, String(tc.name), hookOutcome.errorMessage);
@@ -1120,6 +2084,9 @@ export class ModelResult {
1120
2084
  // HITL tool paused — no unsent result for this call in this round
1121
2085
  return null;
1122
2086
  }
2087
+ if (isAsyncToolInvocation(result)) {
2088
+ return this.settleAsyncInvocationAsUnsent(tc, result);
2089
+ }
1123
2090
  if (result.error) {
1124
2091
  return createRejectedResult(tc.id, String(tc.name), result.error.message);
1125
2092
  }
@@ -1250,6 +2217,11 @@ export class ModelResult {
1250
2217
  */
1251
2218
  async persistHitlPause(currentResponse, pausedCalls) {
1252
2219
  this.finalResponse = currentResponse;
2220
+ // Queued steer guidance is NOT injected here: the paused calls are
2221
+ // dangling function_calls, and a user message between a function_call
2222
+ // and its eventual output is invalid history. saveStateSafely persists
2223
+ // the queue (doomLoop.pendingSteer); the resumed run delivers it at its
2224
+ // next safe flush point.
1253
2225
  if (!this.stateAccessor) {
1254
2226
  return;
1255
2227
  }
@@ -1285,6 +2257,7 @@ export class ModelResult {
1285
2257
  */
1286
2258
  async persistClientToolsPause(currentResponse, unresolvedCalls) {
1287
2259
  this.finalResponse = currentResponse;
2260
+ // Steer queue persists via saveStateSafely (see persistHitlPause).
1288
2261
  if (!this.stateAccessor || unresolvedCalls.length === 0) {
1289
2262
  return;
1290
2263
  }
@@ -1294,6 +2267,111 @@ export class ModelResult {
1294
2267
  };
1295
2268
  await this.saveStateSafely(stateUpdates);
1296
2269
  }
2270
+ /**
2271
+ * Persist state when one or more deferred tools (`tool.deferred`) started
2272
+ * durable external tasks this round. The placeholder outputs are already
2273
+ * persisted (the round is fully paired), so — unlike HITL/manual pauses —
2274
+ * nothing dangles; the pause exists purely because the model's next turn
2275
+ * should wait for the real results.
2276
+ *
2277
+ * Mirrors `persistHitlPause` in shape. Status `awaiting_async_tools` is
2278
+ * distinct from `awaiting_client_tools`: the tool DID execute; only its
2279
+ * result is pending. Resumption happens via the deferred tool's
2280
+ * `.resolve()` / `.fail()` / `.cancel()` or `resumeToolResults()`.
2281
+ */
2282
+ async persistAsyncToolPause(currentResponse, deferredTasks) {
2283
+ this.finalResponse = currentResponse;
2284
+ if (!this.stateAccessor) {
2285
+ return;
2286
+ }
2287
+ const existing = (this.currentState?.pendingAsyncTools ?? []).filter((t) => !deferredTasks.some((d) => d.callId === t.callId));
2288
+ await this.saveStateSafely({
2289
+ pendingAsyncTools: [
2290
+ ...existing,
2291
+ ...deferredTasks,
2292
+ ],
2293
+ status: 'awaiting_async_tools',
2294
+ });
2295
+ }
2296
+ /**
2297
+ * Harvest background tasks that settled since the last flush and inject
2298
+ * their outcomes into the conversation as `tool_task_result` envelope
2299
+ * messages (user role). Runs at the same safe point as
2300
+ * `flushDoomLoopSteer()` — after a fully-paired round, before the next
2301
+ * request — because injecting between a dangling `function_call` and its
2302
+ * future output would be invalid history.
2303
+ *
2304
+ * A delivery is forward progress: the text-repetition streak is reset so a
2305
+ * model saying "still waiting…" between deliveries is not condemned.
2306
+ */
2307
+ async flushAsyncToolDeliveries() {
2308
+ const registry = this.asyncToolRegistry;
2309
+ if (!registry) {
2310
+ return false;
2311
+ }
2312
+ const settled = registry.takeSettled();
2313
+ if (settled.length === 0) {
2314
+ return false;
2315
+ }
2316
+ const envelopes = [];
2317
+ for (const task of settled) {
2318
+ this.broadcastAsyncSettled(task, 'injected');
2319
+ // Emit the canonical tool.result event with the FINAL value so
2320
+ // consumers watching only tool.result see real results exactly once.
2321
+ if (task.status === 'completed') {
2322
+ this.broadcastToolResult(task.callId, String(task.name), this.toolSourceByName(String(task.name)), task.result);
2323
+ }
2324
+ // PostToolUse/PostToolUseFailure fire at SETTLEMENT for async tools —
2325
+ // the observation-only audit surface (secret scanning, output review)
2326
+ // must cover long-running results, which are exactly the ones that
2327
+ // arrive from external processes.
2328
+ await this.emitAsyncSettledHooks(task);
2329
+ envelopes.push(JSON.stringify({
2330
+ type: 'tool_task_result',
2331
+ tool: task.name,
2332
+ taskId: task.taskId,
2333
+ callId: task.callId,
2334
+ status: task.status,
2335
+ ...(task.result !== undefined && {
2336
+ result: task.result,
2337
+ }),
2338
+ ...(task.error !== undefined && {
2339
+ error: task.error,
2340
+ }),
2341
+ }));
2342
+ // Record settlement for the at-most-once guard. The pending entry is
2343
+ // KEPT with its terminal status (not removed) — same policy as
2344
+ // resumeToolResults — so a late external resolution by taskId OR
2345
+ // callId resolves to ToolTaskAlreadySettledError, never "not found".
2346
+ if (this.currentState) {
2347
+ const terminalStatus = task.status === 'completed' || task.status === 'cancelled' ? task.status : 'failed';
2348
+ this.currentState = updateState(this.currentState, {
2349
+ settledAsyncCallIds: [
2350
+ ...(this.currentState.settledAsyncCallIds ?? []),
2351
+ task.callId,
2352
+ ],
2353
+ ...(this.currentState.pendingAsyncTools !== undefined && {
2354
+ pendingAsyncTools: this.currentState.pendingAsyncTools.map((t) => t.callId === task.callId
2355
+ ? {
2356
+ ...t,
2357
+ status: terminalStatus,
2358
+ }
2359
+ : t),
2360
+ }),
2361
+ });
2362
+ }
2363
+ }
2364
+ // Instruction boundary: the injected message rides the user role (the
2365
+ // only channel that can deliver a late result without a duplicate
2366
+ // function_call_output), but its content is tool output — mark it so
2367
+ // attacker-influenced result text does not read as user instructions.
2368
+ await this.injectAppendPromptMessage(`${TASK_RESULT_BOUNDARY}\n${envelopes.join('\n')}`);
2369
+ // Delivery is observable forward progress — clear the text streak so
2370
+ // legitimate "waiting" phrasing between deliveries can't accumulate
2371
+ // into a doom-loop stop.
2372
+ this.doomLoopMonitor?.resetTextStreak();
2373
+ return true;
2374
+ }
1297
2375
  /**
1298
2376
  * Compute the `output` payload sent to the model for a successfully
1299
2377
  * settled tool execution. Routes through `toModelOutput` when the tool
@@ -1346,6 +2424,19 @@ export class ModelResult {
1346
2424
  * outcome consumed by `executeToolRound`'s aggregation loop.
1347
2425
  */
1348
2426
  async executeSingleToolCall(toolCall, turnContext) {
2427
+ // Universal task-tool dispatch: ONE static tool ("task") handles every
2428
+ // check/steer/result/cancel interaction with running tasks, addressed
2429
+ // by taskId — the wire surface stays constant regardless of how many
2430
+ // async tools are registered, while the handling stays tool-resident
2431
+ // (the owning tool's `check` config). Engine-intercepted: bypasses
2432
+ // gates, deadlines, and Pre/PostToolUse hooks (engine bookkeeping, not
2433
+ // user work) and never reaches the doom-loop checkpoint. A
2434
+ // PermissionRequest denial recorded for the call IS honored — cancel
2435
+ // is destructive and steer reshapes a running task, so a policy layer
2436
+ // that vetoed the call wins over the interception.
2437
+ if (toolCall.name === TASK_TOOL_NAME && this.taskToolActive()) {
2438
+ return this.consumeHookDenial(toolCall.id) ?? this.answerTaskToolCall(toolCall);
2439
+ }
1349
2440
  const tool = this.options.tools?.find((t) => isClientTool(t) && t.function.name === toolCall.name);
1350
2441
  if (!tool || !isAutoResolvableTool(tool)) {
1351
2442
  return null;
@@ -1353,20 +2444,9 @@ export class ModelResult {
1353
2444
  // PermissionRequest hook denied this call without pausing: synthesize a
1354
2445
  // rejection instead of executing. Consume the entry so a later round
1355
2446
  // with a reused id is not affected.
1356
- const denialReason = this.hookDeniedCalls.get(toolCall.id);
1357
- if (denialReason !== undefined) {
1358
- this.hookDeniedCalls.delete(toolCall.id);
1359
- return {
1360
- type: 'hook_blocked',
1361
- output: {
1362
- type: 'function_call_output',
1363
- id: `output_${toolCall.id}`,
1364
- callId: toolCall.id,
1365
- output: JSON.stringify({
1366
- error: denialReason,
1367
- }),
1368
- },
1369
- };
2447
+ const hookDenied = this.consumeHookDenial(toolCall.id);
2448
+ if (hookDenied !== null) {
2449
+ return hookDenied;
1370
2450
  }
1371
2451
  const preliminaryResultsForCall = [];
1372
2452
  const hasBroadcaster = this.toolEventBroadcaster || this.turnBroadcaster;
@@ -1374,44 +2454,201 @@ export class ModelResult {
1374
2454
  ? (callId, resultValue) => {
1375
2455
  const typedResult = resultValue;
1376
2456
  preliminaryResultsForCall.push(typedResult);
1377
- this.broadcastPreliminaryResult(callId, typedResult);
2457
+ this.broadcastPreliminaryResult(callId, String(toolCall.name), typedResult);
1378
2458
  }
1379
2459
  : undefined;
1380
- // Run the tool through the full Pre/Post lifecycle hooks. The helper
1381
- // fails closed on a JSON-parse failure in toolCall.arguments so hooks
1382
- // never see a malformed payload; the caller handles that case via the
1383
- // shared `parse_error` / `hook_blocked` branch.
1384
- const executed = await this.runToolWithHooks(tool, toolCall, turnContext, onPreliminaryResult);
1385
- if (executed.type === 'parse_error') {
1386
- this.broadcastToolResult(toolCall.id, isMcpTool(tool) ? 'mcp' : 'client', {
1387
- error: executed.errorMessage,
2460
+ // Compose the per-call abort signal (run signal + tool/run timeout +
2461
+ // per-call controller) and acquire the concurrency gates in fixed order
2462
+ // (round gate first, then per-tool gate — fixed ordering is what makes
2463
+ // multi-gate acquisition deadlock-free). Gate WAITING happens before the
2464
+ // timeout race below, so queue wait counts against the deadline only
2465
+ // for background tasks (which carry their own timeout in the registry).
2466
+ //
2467
+ // Background lifecycles skip the per-tool gate HERE: their body only
2468
+ // runs later via runBackgroundWork, which acquires that gate for the
2469
+ // body's true duration. Acquiring it on the round path too would let
2470
+ // detached bodies holding all maxConcurrency slots block a later
2471
+ // round's dispatch (thunk creation, no body work) for the full task
2472
+ // duration — while it holds a round-gate slot, head-of-line-blocking
2473
+ // every other call in the round.
2474
+ const { controller, signal, timeoutMs } = this.composeToolSignal(String(toolCall.name), toolCall.id);
2475
+ const isBackgroundLifecycle = isUnifiedTool(tool) && tool.function.lifecycle === 'background';
2476
+ const releaseGates = await acquireAll([
2477
+ this.roundGate ?? undefined,
2478
+ isBackgroundLifecycle ? undefined : this.perToolGate(String(toolCall.name)),
2479
+ ]);
2480
+ try {
2481
+ // Run the tool through the full Pre/Post lifecycle hooks. The helper
2482
+ // fails closed on a JSON-parse failure in toolCall.arguments so hooks
2483
+ // never see a malformed payload; the caller handles that case via the
2484
+ // shared `parse_error` / `hook_blocked` branch.
2485
+ //
2486
+ // The whole hooked execution is raced against the tool deadline: on
2487
+ // timeout the round stops waiting (the model gets a `tool_timeout`
2488
+ // error output) and the body's signal is aborted. The abandoned body
2489
+ // keeps running detached; awaiting it would reintroduce the hang
2490
+ // run-cancellation (DEV-658) fixed.
2491
+ // Unified-run affordances. The ToolTask is created later (when the
2492
+ // call escapes the round in handleAsyncInvocation), so the log sink
2493
+ // and inbox registration go through a mutable slot the task binds to
2494
+ // on creation; entries logged before that are buffered.
2495
+ const runBinding = this.createRunBinding(toolCall.id);
2496
+ const executionPromise = this.runToolWithHooks(tool, toolCall, turnContext, onPreliminaryResult, {
2497
+ signal,
2498
+ callId: toolCall.id,
2499
+ ...(this.currentState?.id !== undefined && {
2500
+ conversationId: this.currentState.id,
2501
+ }),
2502
+ client: this.options.client,
2503
+ runExtras: buildRunExtras(tool, runBinding),
1388
2504
  });
1389
- return executed;
1390
- }
1391
- if (executed.type === 'hook_blocked') {
1392
- return executed;
1393
- }
1394
- const result = executed.result;
1395
- if (result === null) {
1396
- // HITL tool paused — surface as manual (no output this round)
2505
+ const executed = await this.raceToolDeadline(executionPromise, String(toolCall.name), timeoutMs, controller);
2506
+ if (executed === 'timeout') {
2507
+ return this.buildToolTimeoutOutcome(toolCall, tool, timeoutMs, preliminaryResultsForCall);
2508
+ }
2509
+ if (executed.type === 'parse_error') {
2510
+ this.broadcastToolResult(toolCall.id, String(toolCall.name), isMcpTool(tool) ? 'mcp' : 'client', {
2511
+ error: executed.errorMessage,
2512
+ });
2513
+ return executed;
2514
+ }
2515
+ if (executed.type === 'hook_blocked') {
2516
+ return executed;
2517
+ }
2518
+ const result = executed.result;
2519
+ if (result === null) {
2520
+ // HITL tool paused — surface as manual (no output this round)
2521
+ return {
2522
+ type: 'paused',
2523
+ toolCall,
2524
+ };
2525
+ }
2526
+ if (isAsyncToolInvocation(result)) {
2527
+ // Background/deferred: the round's aggregation loop turns this into
2528
+ // a placeholder output (and, for background, tracks the work in the
2529
+ // registry). The per-call controller stays alive with the task.
2530
+ return {
2531
+ type: 'async',
2532
+ toolCall: executed.effectiveToolCall,
2533
+ tool,
2534
+ invocation: result,
2535
+ controller,
2536
+ timeoutMs,
2537
+ runBinding,
2538
+ preliminaryResultsForCall,
2539
+ };
2540
+ }
1397
2541
  return {
1398
- type: 'paused',
1399
- toolCall,
2542
+ type: 'execution',
2543
+ toolCall: executed.effectiveToolCall,
2544
+ tool,
2545
+ result,
2546
+ preliminaryResultsForCall,
1400
2547
  };
1401
2548
  }
2549
+ finally {
2550
+ releaseGates();
2551
+ // The inflight map only tracks round-synchronous executions; async
2552
+ // background tasks hand their controller to the registry, which owns
2553
+ // teardown from here (cancelTask / abortAll).
2554
+ this.inflightToolControllers.delete(toolCall.id);
2555
+ }
2556
+ }
2557
+ /**
2558
+ * The `tool_timeout` execution outcome for a call whose deadline fired:
2559
+ * the model receives an error output immediately (the abandoned body
2560
+ * keeps running detached).
2561
+ */
2562
+ buildToolTimeoutOutcome(toolCall, tool, timeoutMs, preliminaryResultsForCall) {
2563
+ const message = `Tool "${toolCall.name}" timed out after ${timeoutMs}ms`;
2564
+ this.broadcastToolResult(toolCall.id, String(toolCall.name), isMcpTool(tool) ? 'mcp' : 'client', {
2565
+ error: message,
2566
+ });
1402
2567
  return {
1403
2568
  type: 'execution',
1404
- toolCall: executed.effectiveToolCall,
2569
+ toolCall,
1405
2570
  tool,
1406
- result,
2571
+ result: {
2572
+ result: null,
2573
+ error: Object.assign(new Error(message), {
2574
+ code: 'tool_timeout',
2575
+ }),
2576
+ },
1407
2577
  preliminaryResultsForCall,
1408
2578
  };
1409
2579
  }
2580
+ /**
2581
+ * PermissionRequest-hook denial for a call, as a `hook_blocked` outcome —
2582
+ * or null when the call was not denied. Consumes the entry so a later
2583
+ * round with a reused id is not affected.
2584
+ */
2585
+ consumeHookDenial(callId) {
2586
+ const denialReason = this.hookDeniedCalls.get(callId);
2587
+ if (denialReason === undefined) {
2588
+ return null;
2589
+ }
2590
+ this.hookDeniedCalls.delete(callId);
2591
+ return {
2592
+ type: 'hook_blocked',
2593
+ output: {
2594
+ type: 'function_call_output',
2595
+ id: `output_${callId}`,
2596
+ callId,
2597
+ output: JSON.stringify({
2598
+ error: denialReason,
2599
+ }),
2600
+ },
2601
+ };
2602
+ }
2603
+ /**
2604
+ * Race a tool execution against its deadline. Returns `'timeout'` when the
2605
+ * deadline fires first — the caller synthesizes the `tool_timeout` output —
2606
+ * and aborts the body's controller so cooperative tools stop working. The
2607
+ * timer is cleared on settle either way.
2608
+ */
2609
+ raceToolDeadline(execution, toolName, timeoutMs, controller) {
2610
+ if (timeoutMs === undefined || timeoutMs <= 0) {
2611
+ return execution;
2612
+ }
2613
+ return new Promise((resolve, reject) => {
2614
+ const timer = setTimeout(() => {
2615
+ controller.abort(new Error(`Tool "${toolName}" timed out after ${timeoutMs}ms`));
2616
+ resolve('timeout');
2617
+ }, timeoutMs);
2618
+ if (typeof timer === 'object' && 'unref' in timer && typeof timer.unref === 'function') {
2619
+ timer.unref();
2620
+ }
2621
+ execution.then((value) => {
2622
+ clearTimeout(timer);
2623
+ resolve(value);
2624
+ }, (error) => {
2625
+ clearTimeout(timer);
2626
+ reject(error instanceof Error ? error : new Error(String(error)));
2627
+ });
2628
+ });
2629
+ }
1410
2630
  async executeToolRound(toolCalls, turnContext) {
2631
+ // One executed batch = one doom-loop round: identical parallel calls in
2632
+ // this batch count as ONE piece of loop evidence and share a decision.
2633
+ await this.beginDoomLoopRound(toolCalls);
1411
2634
  const toolCallPromises = toolCalls.map((toolCall) => this.executeSingleToolCall(toolCall, turnContext));
1412
2635
  const settledResults = await Promise.allSettled(toolCallPromises);
1413
2636
  const toolResults = [];
1414
2637
  const pausedCalls = [];
2638
+ const deferredTasks = [];
2639
+ // Start ALL async invocations before consuming any outcome: the work
2640
+ // (and its grace window) begins in handleAsyncInvocation, so awaiting
2641
+ // it inside the ordered loop below would serialize N background calls
2642
+ // into (N-1)×graceMs of stagger. Kicked off here in parallel; the
2643
+ // ordered loop awaits the per-call promise, so OUTPUT order stays call
2644
+ // order (prompt-cache stability).
2645
+ const asyncOutcomes = new Map();
2646
+ for (let i = 0; i < settledResults.length; i++) {
2647
+ const settled = settledResults[i];
2648
+ if (settled?.status === 'fulfilled' && settled.value?.type === 'async') {
2649
+ asyncOutcomes.set(i, this.handleAsyncInvocation(settled.value));
2650
+ }
2651
+ }
1415
2652
  for (let i = 0; i < settledResults.length; i++) {
1416
2653
  const settled = settledResults[i];
1417
2654
  const originalToolCall = toolCalls[i];
@@ -1421,7 +2658,7 @@ export class ModelResult {
1421
2658
  if (settled.status === 'rejected') {
1422
2659
  const errorMessage = settled.reason instanceof Error ? settled.reason.message : String(settled.reason);
1423
2660
  // `runToolWithHooks` is the single point of emission for PostToolUseFailure.
1424
- this.broadcastToolResult(originalToolCall.id, this.toolSourceByName(originalToolCall.name), {
2661
+ this.broadcastToolResult(originalToolCall.id, String(originalToolCall.name), this.toolSourceByName(String(originalToolCall.name)), {
1425
2662
  error: errorMessage,
1426
2663
  });
1427
2664
  const rejectedOutput = {
@@ -1461,29 +2698,601 @@ export class ModelResult {
1461
2698
  pausedCalls.push(value.toolCall);
1462
2699
  continue;
1463
2700
  }
1464
- const toolResult = (value.result.error
1465
- ? {
1466
- error: value.result.error.message,
1467
- }
1468
- : value.result.result);
1469
- this.broadcastToolResult(value.toolCall.id, isMcpTool(value.tool) ? 'mcp' : 'client', toolResult, value.preliminaryResultsForCall.length > 0 ? value.preliminaryResultsForCall : undefined);
1470
- const outputForModel = await this.computeToolOutputForModel(value);
1471
- const executedOutput = {
1472
- type: 'function_call_output',
1473
- id: `output_${value.toolCall.id}`,
1474
- callId: value.toolCall.id,
1475
- output: outputForModel,
2701
+ if (value.type === 'async') {
2702
+ // Background / deferred: the call escapes the round. Its handling
2703
+ // (grace-window race, registry tracking, placeholder synthesis)
2704
+ // was started above in parallel with the round's other async
2705
+ // calls; awaiting here keeps outputs in call order.
2706
+ const asyncOutcome = await (asyncOutcomes.get(i) ?? this.handleAsyncInvocation(value));
2707
+ toolResults.push(asyncOutcome.output);
2708
+ this.turnBroadcaster?.push({
2709
+ type: 'tool.call_output',
2710
+ output: asyncOutcome.output,
2711
+ timestamp: Date.now(),
2712
+ });
2713
+ if (asyncOutcome.deferredTask) {
2714
+ deferredTasks.push(asyncOutcome.deferredTask);
2715
+ }
2716
+ continue;
2717
+ }
2718
+ const toolResult = (value.result.error
2719
+ ? {
2720
+ error: value.result.error.message,
2721
+ }
2722
+ : value.result.result);
2723
+ this.broadcastToolResult(value.toolCall.id, String(value.toolCall.name), isMcpTool(value.tool) ? 'mcp' : 'client', toolResult, value.preliminaryResultsForCall.length > 0 ? value.preliminaryResultsForCall : undefined);
2724
+ const outputForModel = await this.computeToolOutputForModel(value);
2725
+ const executedOutput = {
2726
+ type: 'function_call_output',
2727
+ id: `output_${value.toolCall.id}`,
2728
+ callId: value.toolCall.id,
2729
+ output: outputForModel,
2730
+ };
2731
+ toolResults.push(executedOutput);
2732
+ this.turnBroadcaster?.push({
2733
+ type: 'tool.call_output',
2734
+ output: executedOutput,
2735
+ timestamp: Date.now(),
2736
+ });
2737
+ }
2738
+ return {
2739
+ toolResults,
2740
+ pausedCalls,
2741
+ deferredTasks,
2742
+ };
2743
+ }
2744
+ /**
2745
+ * Handle one async invocation from a round.
2746
+ *
2747
+ * Background: start the work under the background pool, race it against
2748
+ * the grace window — settle in time and the call yields a plain
2749
+ * synchronous output (no async machinery visible); otherwise emit the
2750
+ * pending placeholder and let the registry track the task.
2751
+ *
2752
+ * Deferred: emit the placeholder and report the task for the pause path.
2753
+ */
2754
+ async handleAsyncInvocation(value) {
2755
+ const { toolCall, tool, invocation, controller, runBinding } = value;
2756
+ const registry = this.ensureAsyncToolRegistry();
2757
+ const source = isMcpTool(tool) ? 'mcp' : 'client';
2758
+ const logLimits = isUnifiedTool(tool) ? tool.function.logLimits : undefined;
2759
+ if (invocation.asyncMode === 'defer') {
2760
+ const collision = this.rejectDuplicateTaskId(toolCall, invocation.taskId, source);
2761
+ if (collision) {
2762
+ return collision;
2763
+ }
2764
+ const liveTask = registry.trackDeferred({
2765
+ callId: toolCall.id,
2766
+ taskId: invocation.taskId,
2767
+ name: String(toolCall.name),
2768
+ ...(invocation.expiresAt !== undefined && {
2769
+ expiresAt: invocation.expiresAt,
2770
+ }),
2771
+ ...(invocation.pollAfterMs !== undefined && {
2772
+ pollAfterMs: invocation.pollAfterMs,
2773
+ }),
2774
+ });
2775
+ runBinding.bind(liveTask);
2776
+ const task = {
2777
+ callId: toolCall.id,
2778
+ taskId: invocation.taskId,
2779
+ name: String(toolCall.name),
2780
+ mode: 'defer',
2781
+ status: 'working',
2782
+ startedAt: liveTask.startedAt,
2783
+ ...(invocation.pollAfterMs !== undefined && {
2784
+ pollAfterMs: invocation.pollAfterMs,
2785
+ }),
2786
+ ...(invocation.expiresAt !== undefined && {
2787
+ expiresAt: invocation.expiresAt,
2788
+ }),
2789
+ };
2790
+ this.broadcastAsyncStarted(toolCall, 'defer', invocation.taskId, invocation.ack);
2791
+ return {
2792
+ output: this.buildPendingPlaceholder(toolCall, invocation.taskId, invocation.ack, invocation.pollAfterMs),
2793
+ deferredTask: task,
2794
+ };
2795
+ }
2796
+ // Background: start the work under the background pool. The pool slot is
2797
+ // held for the duration of the work (queue wait counts against the
2798
+ // task's own timeout tracked by the registry).
2799
+ const taskId = registry.generateTaskId();
2800
+ const liveTask = new ToolTask({
2801
+ taskId,
2802
+ callId: toolCall.id,
2803
+ toolName: String(toolCall.name),
2804
+ mode: isAgentTool(tool) ? 'agent' : 'background',
2805
+ controller,
2806
+ ...(logLimits !== undefined && {
2807
+ limits: logLimits,
2808
+ }),
2809
+ // Carried so PostToolUse/PostToolUseFailure can fire at settlement
2810
+ // with the same payload shape as round-synchronous executions.
2811
+ input: (toolCall.arguments ?? {}),
2812
+ });
2813
+ runBinding.bind(liveTask);
2814
+ // Visible to steering / cancel / snapshots from the start — a large
2815
+ // graceMs must not create a window where the running task cannot be
2816
+ // reached (sendToTask queues into the inbox; cancelTask aborts the
2817
+ // controller, which the grace race observes as an error settlement).
2818
+ registry.register(liveTask);
2819
+ const work = this.runBackgroundWork(String(toolCall.name), invocation.run, controller);
2820
+ // Surface unhandled rejections nowhere — the registry's .then() below
2821
+ // (or the grace race) is the sole consumer.
2822
+ work.catch(() => undefined);
2823
+ // Grace window: work settling in time yields a plain synchronous output.
2824
+ const graceMs = invocation.graceMs;
2825
+ if (graceMs > 0) {
2826
+ // The timer is cleared when the work settles first (same discipline
2827
+ // as raceToolDeadline) — it's unref'd, but a leaked timer is still a
2828
+ // leaked timer.
2829
+ let graceTimer;
2830
+ const settled = await Promise.race([
2831
+ work.then((result) => ({
2832
+ outcome: 'ok',
2833
+ result,
2834
+ }), (error) => ({
2835
+ outcome: 'error',
2836
+ error,
2837
+ })),
2838
+ new Promise((resolve) => {
2839
+ graceTimer = setTimeout(() => resolve('pending'), graceMs);
2840
+ if (typeof graceTimer === 'object' &&
2841
+ 'unref' in graceTimer &&
2842
+ typeof graceTimer.unref === 'function') {
2843
+ graceTimer.unref();
2844
+ }
2845
+ }),
2846
+ ]);
2847
+ if (graceTimer !== undefined) {
2848
+ clearTimeout(graceTimer);
2849
+ }
2850
+ if (settled !== 'pending') {
2851
+ // Settled in-window: behave exactly like a regular tool. The call
2852
+ // yields a synchronous output, so the task leaves the registry —
2853
+ // including any settlement a racing cancelTask queued for it (the
2854
+ // sync output already reports the outcome; an envelope on top
2855
+ // would be a double delivery).
2856
+ registry.untrack(toolCall.id);
2857
+ if (settled.outcome === 'ok') {
2858
+ this.broadcastToolResult(toolCall.id, String(toolCall.name), source, settled.result);
2859
+ const outputForModel = await this.computeToolOutputForModel({
2860
+ toolCall,
2861
+ tool,
2862
+ result: {
2863
+ result: settled.result,
2864
+ },
2865
+ });
2866
+ return {
2867
+ output: {
2868
+ type: 'function_call_output',
2869
+ id: `output_${toolCall.id}`,
2870
+ callId: toolCall.id,
2871
+ output: outputForModel,
2872
+ },
2873
+ };
2874
+ }
2875
+ const message = settled.error instanceof Error ? settled.error.message : String(settled.error);
2876
+ this.broadcastToolResult(toolCall.id, String(toolCall.name), source, {
2877
+ error: message,
2878
+ });
2879
+ return {
2880
+ output: {
2881
+ type: 'function_call_output',
2882
+ id: `output_${toolCall.id}`,
2883
+ callId: toolCall.id,
2884
+ output: JSON.stringify({
2885
+ error: message,
2886
+ }),
2887
+ },
2888
+ };
2889
+ }
2890
+ }
2891
+ // Outlived the grace window: placeholder now, deliver later. The
2892
+ // registry owns the timeout from here (the per-call deadline raced in
2893
+ // executeSingleToolCall only bounds the ROUND's wait; a background task
2894
+ // gets its full budget).
2895
+ registry.trackBackground(liveTask, work, value.timeoutMs !== undefined
2896
+ ? {
2897
+ timeoutMs: value.timeoutMs,
2898
+ }
2899
+ : undefined);
2900
+ this.broadcastAsyncStarted(toolCall, liveTask.mode, taskId, invocation.ack);
2901
+ return {
2902
+ output: this.buildPendingPlaceholder(toolCall, taskId, invocation.ack),
2903
+ };
2904
+ }
2905
+ /**
2906
+ * Reject a `ctx.defer()` taskId already claimed by ANOTHER pending task.
2907
+ * Task ids are the resolution key (resumeToolResults matches on them), so
2908
+ * a duplicate would make one of the two calls unresolvable — the
2909
+ * conversation would wait forever. Caller-supplied ids
2910
+ * (`ctx.defer(ticket.id)`) carry no uniqueness guarantee; reject the
2911
+ * collision at start time, when the tool can still see it.
2912
+ * @returns the error output for the colliding call, or null when unique.
2913
+ */
2914
+ rejectDuplicateTaskId(toolCall, taskId, source) {
2915
+ const duplicate = this.asyncToolRegistry?.getTask(taskId) ??
2916
+ this.currentState?.pendingAsyncTools?.find((t) => t.taskId === taskId);
2917
+ if (!duplicate || duplicate.callId === toolCall.id) {
2918
+ return null;
2919
+ }
2920
+ const message = `Tool "${toolCall.name}": ctx.defer() taskId "${taskId}" is already in use by another pending task in this conversation (call ${duplicate.callId}). Task ids must be unique per conversation — include a per-call component (e.g. the ticket id plus your callId).`;
2921
+ this.broadcastToolResult(toolCall.id, String(toolCall.name), source, {
2922
+ error: message,
2923
+ });
2924
+ return {
2925
+ output: {
2926
+ type: 'function_call_output',
2927
+ id: `output_${toolCall.id}`,
2928
+ callId: toolCall.id,
2929
+ output: JSON.stringify({
2930
+ error: message,
2931
+ }),
2932
+ },
2933
+ };
2934
+ }
2935
+ /**
2936
+ * A mutable binding between a tool call's run context and its (created
2937
+ * later) ToolTask. `ctx.log()` entries before the task exists are
2938
+ * buffered; `ctx.onMessage()` registrations are forwarded on bind.
2939
+ */
2940
+ createRunBinding(_callId) {
2941
+ let task = null;
2942
+ const bufferedLogs = [];
2943
+ let pendingHandler = null;
2944
+ let pendingTranscriptSource = null;
2945
+ return {
2946
+ log: (entry) => {
2947
+ if (task) {
2948
+ task.appendLog(entry, typeof entry === 'string' ? 'text' : 'event');
2949
+ }
2950
+ else {
2951
+ bufferedLogs.push(entry);
2952
+ }
2953
+ },
2954
+ onMessage: (handler) => {
2955
+ if (task) {
2956
+ task.onMessage(handler);
2957
+ }
2958
+ else {
2959
+ pendingHandler = handler;
2960
+ }
2961
+ },
2962
+ setTranscriptSource: (source) => {
2963
+ if (task) {
2964
+ task.transcriptSource = source;
2965
+ }
2966
+ else {
2967
+ pendingTranscriptSource = source;
2968
+ }
2969
+ },
2970
+ bind: (bound) => {
2971
+ task = bound;
2972
+ for (const entry of bufferedLogs) {
2973
+ bound.appendLog(entry, typeof entry === 'string' ? 'text' : 'event');
2974
+ }
2975
+ bufferedLogs.length = 0;
2976
+ if (pendingHandler) {
2977
+ bound.onMessage(pendingHandler);
2978
+ pendingHandler = null;
2979
+ }
2980
+ if (pendingTranscriptSource) {
2981
+ bound.transcriptSource = pendingTranscriptSource;
2982
+ pendingTranscriptSource = null;
2983
+ }
2984
+ },
2985
+ task: () => task,
2986
+ };
2987
+ }
2988
+ /**
2989
+ * Run a background tool's work under the tool's own concurrency gate AND
2990
+ * the background pool. The round path does not touch the per-tool gate
2991
+ * for background lifecycles (the thunk had not run yet), so the gate is
2992
+ * acquired here for the body's lifetime — `maxConcurrency` bounds
2993
+ * executions of the BODY, wherever they run.
2994
+ *
2995
+ * Acquisition order: per-tool gate FIRST, then the pool. The narrow gate
2996
+ * is where a saturated tool's excess calls queue — queuing them on the
2997
+ * shared pool instead would let 16 waiters on one maxConcurrency:2 tool
2998
+ * hold every global slot while only two bodies run, starving every other
2999
+ * background tool. Order is deadlock-safe: only background bodies take
3000
+ * the per-tool gate (the round path skips it for them), and every body
3001
+ * acquires in this same fixed order.
3002
+ *
3003
+ * Queue wait counts against the task's own timeout (tracked by the
3004
+ * registry); an abort observed before the slots free up short-circuits
3005
+ * without running the body.
3006
+ */
3007
+ async runBackgroundWork(toolName, run, controller) {
3008
+ const gate = this.perToolGate(toolName);
3009
+ const releaseGate = gate ? await gate.acquire() : undefined;
3010
+ const pool = this.backgroundPool;
3011
+ const releasePool = pool ? await pool.acquire() : undefined;
3012
+ try {
3013
+ if (controller.signal.aborted) {
3014
+ throw controller.signal.reason instanceof Error
3015
+ ? controller.signal.reason
3016
+ : new Error(String(controller.signal.reason ?? 'aborted'));
3017
+ }
3018
+ return await run();
3019
+ }
3020
+ finally {
3021
+ releasePool?.();
3022
+ releaseGate?.();
3023
+ }
3024
+ }
3025
+ /** Broadcast a `tool.async_started` event on the turn broadcaster. */
3026
+ broadcastAsyncStarted(toolCall, mode, taskId, ack) {
3027
+ this.turnBroadcaster?.push({
3028
+ type: 'tool.async_started',
3029
+ toolCallId: toolCall.id,
3030
+ toolName: String(toolCall.name),
3031
+ taskId,
3032
+ mode,
3033
+ ...(ack !== undefined && {
3034
+ ack,
3035
+ }),
3036
+ timestamp: Date.now(),
3037
+ });
3038
+ }
3039
+ /** Broadcast a `tool.async_settled` event on the turn broadcaster. */
3040
+ broadcastAsyncSettled(settled, delivery) {
3041
+ this.turnBroadcaster?.push({
3042
+ type: 'tool.async_settled',
3043
+ toolCallId: settled.callId,
3044
+ taskId: settled.taskId,
3045
+ status: settled.status,
3046
+ ...(settled.result !== undefined && {
3047
+ result: settled.result,
3048
+ }),
3049
+ ...(settled.error !== undefined && {
3050
+ error: settled.error,
3051
+ }),
3052
+ delivery,
3053
+ timestamp: Date.now(),
3054
+ });
3055
+ }
3056
+ /**
3057
+ * Build the pending placeholder `function_call_output` for an async tool
3058
+ * call. Pairs the `function_call` immediately (providers 400 on unpaired
3059
+ * calls in follow-up history); the real result arrives later as a
3060
+ * `tool_task_result` envelope. The note steers the model: with check-ins
3061
+ * enabled it points at the universal `task` tool; with check-ins
3062
+ * disabled it forbids re-calling entirely.
3063
+ */
3064
+ buildPendingPlaceholder(toolCall, taskId, ack, pollAfterMs) {
3065
+ const checkinsEnabled = this.options.asyncTools?.checkins !== false;
3066
+ const payload = {
3067
+ status: 'pending',
3068
+ taskId,
3069
+ ...(pollAfterMs !== undefined && {
3070
+ pollAfterMs,
3071
+ }),
3072
+ ...(checkinsEnabled && {
3073
+ check: true,
3074
+ }),
3075
+ };
3076
+ const directive = checkinsEnabled
3077
+ ? `The result will be delivered to you automatically when ready. To check progress, steer, or cancel, call the task tool with { "taskId": "${taskId}" }. Keep working on other steps meanwhile.`
3078
+ : 'The result will be delivered to you automatically when ready — do not call this tool again to check on it.';
3079
+ if (typeof ack === 'string') {
3080
+ payload['note'] = `${ack} ${directive}`;
3081
+ }
3082
+ else {
3083
+ if (ack !== undefined && typeof ack === 'object') {
3084
+ Object.assign(payload, ack);
3085
+ }
3086
+ payload['note'] = `Started. ${directive}`;
3087
+ }
3088
+ return {
3089
+ type: 'function_call_output',
3090
+ id: `output_${toolCall.id}`,
3091
+ callId: toolCall.id,
3092
+ output: JSON.stringify(payload),
3093
+ };
3094
+ }
3095
+ /**
3096
+ * True when the built-in task tool is active for this run. Mirrors
3097
+ * `needsTaskTool` exactly: when a user tool claims the reserved name the
3098
+ * built-in is not registered, so calls named "task" must NOT be
3099
+ * intercepted — they belong to the user's tool.
3100
+ */
3101
+ taskToolActive() {
3102
+ if (this.options.asyncTools?.checkins === false) {
3103
+ return false;
3104
+ }
3105
+ const tools = this.options.tools ?? [];
3106
+ if (hasTaskToolNameCollision(tools)) {
3107
+ return false;
3108
+ }
3109
+ return tools.some((t) => isLongRunningTool(t));
3110
+ }
3111
+ /**
3112
+ * Answer a call to the universal `task` tool: resolve the taskId to its
3113
+ * live registry task (or persisted pendingAsyncTools entry, post-restart)
3114
+ * and its OWNING tool, then dispatch by action:
3115
+ *
3116
+ * - `check` (default): the owning tool's `check.execute` when declared
3117
+ * (custom `params` validated against `check.schema`), else the SDK
3118
+ * default status / logs / transcript views.
3119
+ * - `steer`: deliver `message` to the run body's inbox.
3120
+ * - `result`: the final result if settled, else the status view.
3121
+ * - `cancel`: cancel the task.
3122
+ */
3123
+ async answerTaskToolCall(toolCall) {
3124
+ const taskTool = buildTaskToolStub();
3125
+ const answer = (result, error) => {
3126
+ if (error === undefined) {
3127
+ this.broadcastToolResult(toolCall.id, String(toolCall.name), 'client', result);
3128
+ }
3129
+ return {
3130
+ type: 'execution',
3131
+ toolCall,
3132
+ tool: taskTool,
3133
+ result: error
3134
+ ? {
3135
+ result: null,
3136
+ error,
3137
+ }
3138
+ : {
3139
+ result,
3140
+ },
3141
+ preliminaryResultsForCall: [],
3142
+ };
3143
+ };
3144
+ let input;
3145
+ try {
3146
+ input = validateToolInput(TaskToolInputSchema, toolCall.arguments ?? {});
3147
+ }
3148
+ catch (error) {
3149
+ return answer(null, error instanceof Error ? error : new Error(String(error)));
3150
+ }
3151
+ const liveTask = this.asyncToolRegistry?.getTask(input.taskId);
3152
+ const persisted = this.currentState?.pendingAsyncTools?.find((t) => t.taskId === input.taskId);
3153
+ if (!liveTask && !persisted) {
3154
+ return answer({
3155
+ error: 'unknown_task',
3156
+ taskId: input.taskId,
3157
+ hint: 'No task with this id exists in this conversation. It may belong to another conversation, or its record was dropped.',
3158
+ });
3159
+ }
3160
+ try {
3161
+ switch (input.action ?? 'check') {
3162
+ case 'cancel':
3163
+ return answer(this.taskToolCancel(input, liveTask));
3164
+ case 'steer':
3165
+ return answer(...this.taskToolSteer(input, liveTask));
3166
+ case 'result': {
3167
+ const settled = taskToolResultIfSettled(input, liveTask);
3168
+ if (settled !== null) {
3169
+ return answer(settled);
3170
+ }
3171
+ // Not settled: fall through to the status view.
3172
+ return answer(await this.taskToolCheck(input, liveTask, persisted));
3173
+ }
3174
+ default:
3175
+ return answer(await this.taskToolCheck(input, liveTask, persisted));
3176
+ }
3177
+ }
3178
+ catch (error) {
3179
+ return answer(null, error instanceof Error ? error : new Error(String(error)));
3180
+ }
3181
+ }
3182
+ /** `task` tool, action=cancel. */
3183
+ taskToolCancel(input, liveTask) {
3184
+ const cancelled = liveTask
3185
+ ? (this.asyncToolRegistry?.cancelTask(input.taskId, input.reason) ?? false)
3186
+ : false;
3187
+ return cancelled
3188
+ ? {
3189
+ taskId: input.taskId,
3190
+ status: 'cancelled',
3191
+ }
3192
+ : {
3193
+ taskId: input.taskId,
3194
+ error: 'not_cancellable',
3195
+ hint: liveTask
3196
+ ? 'The task has already settled.'
3197
+ : 'This task is owned by an external system — cancel it there (or via the tool’s .cancel() method).',
1476
3198
  };
1477
- toolResults.push(executedOutput);
1478
- this.turnBroadcaster?.push({
1479
- type: 'tool.call_output',
1480
- output: executedOutput,
1481
- timestamp: Date.now(),
1482
- });
3199
+ }
3200
+ /** `task` tool, action=steer. Returns [result, error?] for `answer`. */
3201
+ taskToolSteer(input, liveTask) {
3202
+ if (typeof input.message !== 'string' || input.message.length === 0) {
3203
+ return [
3204
+ null,
3205
+ new Error("action 'steer' requires a non-empty `message`"),
3206
+ ];
3207
+ }
3208
+ if (!liveTask || liveTask.mode === 'defer') {
3209
+ return [
3210
+ {
3211
+ taskId: input.taskId,
3212
+ error: 'not_steerable',
3213
+ hint: 'This task runs in an external system — steer it there.',
3214
+ },
3215
+ ];
3216
+ }
3217
+ liveTask.send(input.message);
3218
+ return [
3219
+ {
3220
+ taskId: input.taskId,
3221
+ steered: true,
3222
+ },
3223
+ ];
3224
+ }
3225
+ /**
3226
+ * `task` tool, action=check (and the status-view fallback for `result`
3227
+ * on an unsettled task): the owning tool's `check.execute` when
3228
+ * declared, else the SDK default views — live-task-backed when the task
3229
+ * is in this process, persisted-state-backed post-restart.
3230
+ */
3231
+ async taskToolCheck(input, liveTask, persisted) {
3232
+ const owningToolName = liveTask?.toolName ?? persisted?.name ?? '';
3233
+ const owningTool = this.options.tools?.find((t) => isClientTool(t) && t.function.name === owningToolName);
3234
+ const config = owningTool && isUnifiedTool(owningTool) ? owningTool.function.check : undefined;
3235
+ const { schema, execute } = resolveCheckConfig(config);
3236
+ const maxTranscriptChars = this.options.asyncTools?.maxTranscriptChars ?? 20000;
3237
+ // Custom params for the owning tool's handler are validated against
3238
+ // its check.schema when both are present.
3239
+ let customParams = input.params ?? {};
3240
+ if (schema && input.params !== undefined) {
3241
+ customParams = validateToolInput(schema, input.params);
1483
3242
  }
3243
+ if (liveTask) {
3244
+ const handle = this.buildTaskHandle(liveTask, maxTranscriptChars);
3245
+ const checkTurnContext = {
3246
+ numberOfTurns: this.allToolExecutionRounds.length + 1,
3247
+ toolCallStatus: liveTask.status,
3248
+ accumulatedYieldedEvents: liveTask.accumulatedYieldedEvents,
3249
+ task: handle,
3250
+ };
3251
+ return execute
3252
+ ? await Promise.resolve(execute(customParams, checkTurnContext))
3253
+ : defaultCheckResult({
3254
+ view: input.view,
3255
+ tail: input.tail,
3256
+ }, checkTurnContext, {
3257
+ maxTranscriptChars,
3258
+ });
3259
+ }
3260
+ // Cross-process / post-restart: only persisted status survives. A
3261
+ // custom check.execute still runs, with a state-backed context (no
3262
+ // live task handle — `turnContext.task` is absent here). Handlers
3263
+ // written against `task.statusView()` with optional chaining return
3264
+ // undefined in this situation; fall back to the persisted status view
3265
+ // rather than answering the model with nothing.
3266
+ const checkTurnContext = {
3267
+ numberOfTurns: this.allToolExecutionRounds.length + 1,
3268
+ toolCallStatus: persisted.status,
3269
+ accumulatedYieldedEvents: persisted.lastLog
3270
+ ? [
3271
+ persisted.lastLog.text,
3272
+ ]
3273
+ : [],
3274
+ };
3275
+ const customResult = execute
3276
+ ? await Promise.resolve(execute(customParams, checkTurnContext))
3277
+ : undefined;
3278
+ return (customResult ??
3279
+ persistedTaskCheckResult({
3280
+ view: input.view,
3281
+ }, persisted));
3282
+ }
3283
+ /** Build the narrow ToolTaskHandle façade for check.execute handlers. */
3284
+ buildTaskHandle(task, maxTranscriptChars) {
3285
+ const registry = this.asyncToolRegistry;
1484
3286
  return {
1485
- toolResults,
1486
- pausedCalls,
3287
+ taskId: task.taskId,
3288
+ toolName: task.toolName,
3289
+ mode: task.mode,
3290
+ status: () => task.status,
3291
+ statusView: () => task.toStatusView(),
3292
+ tailLogs: (n) => task.tailLogs(n),
3293
+ transcript: (maxChars) => task.renderTranscript(maxChars ?? maxTranscriptChars),
3294
+ send: (message) => task.send(message),
3295
+ cancel: (reason) => registry?.cancelTask(task.taskId, reason) ?? false,
1487
3296
  };
1488
3297
  }
1489
3298
  /**
@@ -1494,7 +3303,7 @@ export class ModelResult {
1494
3303
  */
1495
3304
  async resolveAsyncFunctionsForTurn(turnContext) {
1496
3305
  if (hasAsyncFunctions(this.options.request)) {
1497
- const resolved = await resolveAsyncFunctions(this.options.request, turnContext);
3306
+ const resolved = await this.resolveRequestForContext(turnContext);
1498
3307
  // Preserve accumulated input from previous turns
1499
3308
  const preservedInput = this.resolvedRequest?.input;
1500
3309
  const preservedStream = this.resolvedRequest?.stream;
@@ -1518,9 +3327,23 @@ export class ModelResult {
1518
3327
  return;
1519
3328
  }
1520
3329
  const computedParams = await executeNextTurnParamsFunctions(toolCalls, this.options.tools, this.resolvedRequest);
1521
- if (Object.keys(computedParams).length > 0) {
1522
- this.resolvedRequest = applyNextTurnParamsToRequest(this.resolvedRequest, computedParams);
3330
+ if (Object.keys(computedParams).length === 0) {
3331
+ return;
1523
3332
  }
3333
+ const nextRequest = applyNextTurnParamsToRequest(this.resolvedRequest, computedParams);
3334
+ /*
3335
+ * A tool-computed `toolChoice` becomes the new caller-level policy, not a
3336
+ * one-turn override. Merging it onto the request alone is not enough:
3337
+ * `makeFollowupRequest` re-derives the wire choice from
3338
+ * `configuredToolChoice` via `applyForcedToolChoicePolicy`, which would
3339
+ * discard the tool's value before dispatch. Re-running the resolved-policy
3340
+ * bookkeeping re-stamps the configured choice and its forced-choice
3341
+ * consumption key together, so relaxation stays consistent for later turns.
3342
+ */
3343
+ this.resolvedRequest =
3344
+ 'toolChoice' in computedParams
3345
+ ? this.applyResolvedForcedToolChoicePolicy(nextRequest)
3346
+ : nextRequest;
1524
3347
  }
1525
3348
  /**
1526
3349
  * Make a follow-up API request with tool results.
@@ -1550,26 +3373,231 @@ export class ModelResult {
1550
3373
  if (!this.resolvedRequest) {
1551
3374
  throw new Error('Request not initialized');
1552
3375
  }
1553
- // Update resolvedRequest.input with accumulated conversation for next turn
3376
+ // Update resolvedRequest.input with accumulated conversation for next turn.
1554
3377
  this.resolvedRequest = {
1555
3378
  ...this.resolvedRequest,
1556
3379
  input: newInput,
1557
3380
  };
3381
+ // A forced tool choice (`required` or a specific tool) has served its
3382
+ // purpose once a tool round has executed; keeping it on follow-up turns
3383
+ // would forbid the model from ever answering in text, looping it through
3384
+ // tool calls until the step budget runs out (DEV-785). Relax it to `auto`
3385
+ // so the model can either call another tool or synthesize its answer.
3386
+ this.resolvedRequest = this.applyForcedToolChoicePolicy(this.resolvedRequest);
3387
+ // Escalation recovery: one-turn overrides (model swap / forced advisor
3388
+ // consult) applied to THIS dispatch only — `resolvedRequest` above keeps
3389
+ // the configured model/tools, so the next turn reverts automatically.
3390
+ const escalationOverrides = this.takeDoomLoopEscalationOverrides();
1558
3391
  const newRequest = {
1559
3392
  ...this.resolvedRequest,
3393
+ ...(escalationOverrides ?? {}),
1560
3394
  stream: true,
1561
3395
  };
3396
+ this.beginToolChoiceDispatch(newRequest.toolChoice);
1562
3397
  const startedAt = performance.now();
1563
3398
  const newResult = await betaResponsesSend(this.options.client, {
1564
3399
  responsesRequest: newRequest,
1565
- }, this.options.options);
3400
+ }, this.dispatchRequestOptions());
1566
3401
  if (!newResult.ok) {
1567
3402
  throw newResult.error;
1568
3403
  }
1569
3404
  const response = await this.materializeResponse(newResult.value, turnNumber);
3405
+ this.commitToolChoiceDispatch(responseHasToolCalls(response));
1570
3406
  await this.emitPostModelCall(response, startedAt, 'tool_round', turnNumber);
1571
3407
  return response;
1572
3408
  }
3409
+ /**
3410
+ * Append a response's output items to the accumulated request input,
3411
+ * exactly as `makeFollowupRequest` does before adding tool results. Used
3412
+ * by the end-of-run drain, where envelope messages must land AFTER the
3413
+ * final assistant output in the request.
3414
+ */
3415
+ accumulateResponseIntoInput(response) {
3416
+ if (!this.resolvedRequest) {
3417
+ return;
3418
+ }
3419
+ const originalInput = this.resolvedRequest.input;
3420
+ const normalizedOriginalInput = Array.isArray(originalInput)
3421
+ ? originalInput
3422
+ : originalInput
3423
+ ? [
3424
+ {
3425
+ role: 'user',
3426
+ content: originalInput,
3427
+ },
3428
+ ]
3429
+ : [];
3430
+ this.resolvedRequest = {
3431
+ ...this.resolvedRequest,
3432
+ input: [
3433
+ ...normalizedOriginalInput,
3434
+ ...(Array.isArray(response.output)
3435
+ ? response.output
3436
+ : [
3437
+ response.output,
3438
+ ]),
3439
+ ],
3440
+ };
3441
+ }
3442
+ /**
3443
+ * End-of-run handling for async background tasks still in flight (or
3444
+ * settled but unharvested) when the loop is about to finalize.
3445
+ *
3446
+ * - `'drain'` (default): wait for tasks (bounded by `drainTimeoutMs`),
3447
+ * inject their results, and give the model up to `maxDrainTurns` extra
3448
+ * `toolChoice: 'none'` turns so the final answer incorporates them.
3449
+ * - `'detach'`: return immediately; still-working tasks are marked
3450
+ * `orphaned` on persisted state and their eventual results are dropped.
3451
+ * - `'cancel'`: abort in-flight tasks and finish.
3452
+ *
3453
+ * @returns the (possibly replaced) final response.
3454
+ */
3455
+ async handleRunEndAsyncTasks(currentResponse) {
3456
+ const registry = this.asyncToolRegistry;
3457
+ if (!registry || (!registry.hasInFlight() && !registry.hasUnharvestedSettled())) {
3458
+ return currentResponse;
3459
+ }
3460
+ const config = this.options.asyncTools;
3461
+ const mode = config?.onRunEnd ?? 'drain';
3462
+ if (mode === 'cancel') {
3463
+ registry.abortAll('Run ended (onRunEnd: cancel)');
3464
+ await this.dropSettledTasks();
3465
+ return currentResponse;
3466
+ }
3467
+ if (mode === 'detach') {
3468
+ await this.detachWorkingTasks();
3469
+ return currentResponse;
3470
+ }
3471
+ // 'drain': deliver late results and let the model incorporate them.
3472
+ // Drain turns use the retry path (toolChoice forced to 'none' when tools
3473
+ // are present) so a drain turn cannot open a fresh tool round — it
3474
+ // exists purely to fold the delivered results into the final answer.
3475
+ // ONE deadline spans the whole drain phase: drainTimeoutMs bounds total
3476
+ // WAITING (documented contract), not per-turn waiting — maxDrainTurns
3477
+ // bounds the extra model turns, not the wait.
3478
+ const drainTimeoutMs = config?.drainTimeoutMs ?? 30000;
3479
+ const maxDrainTurns = config?.maxDrainTurns ?? 2;
3480
+ const drainDeadline = Date.now() + drainTimeoutMs;
3481
+ let response = currentResponse;
3482
+ for (let drainTurn = 0; drainTurn < maxDrainTurns; drainTurn++) {
3483
+ if (!registry.hasUnharvestedSettled() && registry.hasInFlight()) {
3484
+ const remaining = drainDeadline - Date.now();
3485
+ if (remaining <= 0) {
3486
+ break;
3487
+ }
3488
+ await registry.drain(remaining);
3489
+ }
3490
+ // Accumulate the current final output BEFORE injecting so the
3491
+ // envelope lands after the assistant's last message in the request.
3492
+ this.accumulateResponseIntoInput(response);
3493
+ // Queued steering (queueUserMessage / sendToTask) flushes here too —
3494
+ // a drain turn is a model dispatch, and guidance queued while the
3495
+ // last tool round was already finishing would otherwise be dropped.
3496
+ await this.flushDoomLoopSteer();
3497
+ const delivered = await this.flushAsyncToolDeliveries();
3498
+ if (!delivered) {
3499
+ // Drain timed out with nothing new settled — stop waiting.
3500
+ break;
3501
+ }
3502
+ const turnNumber = this.allToolExecutionRounds.length + 1 + drainTurn;
3503
+ response = await this.retryCurrentRequest(turnNumber);
3504
+ await this.saveResponseToState(response);
3505
+ if (!registry.hasInFlight() && !registry.hasUnharvestedSettled()) {
3506
+ break;
3507
+ }
3508
+ }
3509
+ // Drain budget exhausted with work still in flight: cut it loose.
3510
+ if (registry.hasInFlight()) {
3511
+ registry.abortAll('Async tool drain budget exhausted at run end');
3512
+ await this.dropSettledTasks();
3513
+ }
3514
+ return response;
3515
+ }
3516
+ /**
3517
+ * Broadcast every unharvested settlement as dropped (no in-run delivery),
3518
+ * mirror the terminal status onto persisted `pendingAsyncTools`, and
3519
+ * append a terminal `tool_task_result` envelope to persisted history.
3520
+ *
3521
+ * The status mirror prevents a stuck resume: an entry persisted as
3522
+ * `working` for a task that only ever settles in-memory (abortAll at
3523
+ * drain exhaustion / run cancel) would pin a later cross-process resume
3524
+ * to `awaiting_async_tools` forever. The envelope keeps the pending
3525
+ * placeholder's "the result will be delivered" promise honest for
3526
+ * PERSISTED conversations: the run is over (no more model dispatch), but
3527
+ * the terminal outcome rides along on the next `callModel({ state })`
3528
+ * instead of vanishing.
3529
+ */
3530
+ async dropSettledTasks() {
3531
+ const registry = this.asyncToolRegistry;
3532
+ if (!registry) {
3533
+ return;
3534
+ }
3535
+ const settled = registry.takeSettled();
3536
+ for (const task of settled) {
3537
+ this.broadcastAsyncSettled(task, 'dropped');
3538
+ }
3539
+ if (settled.length === 0 || !this.stateAccessor || !this.currentState) {
3540
+ return;
3541
+ }
3542
+ const terminalByCallId = new Map(settled.map((t) => [
3543
+ t.callId,
3544
+ t.status === 'completed' || t.status === 'cancelled' ? t.status : 'failed',
3545
+ ]));
3546
+ const envelopes = settled.map((t) => JSON.stringify({
3547
+ type: 'tool_task_result',
3548
+ tool: t.name,
3549
+ taskId: t.taskId,
3550
+ callId: t.callId,
3551
+ status: t.status,
3552
+ ...(t.result !== undefined && {
3553
+ result: t.result,
3554
+ }),
3555
+ ...(t.error !== undefined && {
3556
+ error: t.error,
3557
+ }),
3558
+ }));
3559
+ await this.saveStateSafely({
3560
+ messages: appendToMessages(this.currentState.messages, [
3561
+ {
3562
+ role: 'user',
3563
+ content: `${TASK_RESULT_BOUNDARY}\n${envelopes.join('\n')}`,
3564
+ },
3565
+ ]),
3566
+ ...(this.currentState.pendingAsyncTools?.length && {
3567
+ pendingAsyncTools: this.currentState.pendingAsyncTools.map((entry) => {
3568
+ const terminal = terminalByCallId.get(entry.callId);
3569
+ return terminal !== undefined
3570
+ ? {
3571
+ ...entry,
3572
+ status: terminal,
3573
+ }
3574
+ : entry;
3575
+ }),
3576
+ }),
3577
+ });
3578
+ }
3579
+ /**
3580
+ * `onRunEnd: 'detach'`: mark still-working tasks orphaned on persisted
3581
+ * state (their eventual results are dropped) and drop unharvested
3582
+ * settlements.
3583
+ */
3584
+ async detachWorkingTasks() {
3585
+ const registry = this.asyncToolRegistry;
3586
+ if (!registry) {
3587
+ return;
3588
+ }
3589
+ const orphaned = registry.markWorkingAsOrphaned();
3590
+ await this.dropSettledTasks();
3591
+ if (orphaned.length > 0 && this.stateAccessor && this.currentState) {
3592
+ const existing = (this.currentState.pendingAsyncTools ?? []).filter((t) => !orphaned.some((o) => o.callId === t.callId));
3593
+ await this.saveStateSafely({
3594
+ pendingAsyncTools: [
3595
+ ...existing,
3596
+ ...orphaned,
3597
+ ],
3598
+ });
3599
+ }
3600
+ }
1573
3601
  /**
1574
3602
  * Make a final no-tool-calls request to coerce a text response after the
1575
3603
  * loop was halted by `stopWhen` mid-tool-call. Reuses the resolved request
@@ -1632,7 +3660,7 @@ export class ModelResult {
1632
3660
  const startedAt = performance.now();
1633
3661
  const result = await betaResponsesSend(this.options.client, {
1634
3662
  responsesRequest: finalRequest,
1635
- }, this.options.options);
3663
+ }, this.dispatchRequestOptions());
1636
3664
  if (!result.ok) {
1637
3665
  throw result.error;
1638
3666
  }
@@ -1643,19 +3671,40 @@ export class ModelResult {
1643
3671
  /**
1644
3672
  * Validate the final response has required fields.
1645
3673
  *
3674
+ * Message prefixes (`Invalid final response: missing required fields`,
3675
+ * `Invalid final response: empty or invalid output`) are load-bearing —
3676
+ * callers and tests match on them. Detail is appended after an em dash;
3677
+ * never reword the prefixes.
3678
+ *
3679
+ * The detail exists because the bare "empty or invalid output" message was
3680
+ * routinely misread as "the model returned a tool call and validation
3681
+ * rejected it" (issue #45). It does not: this is a pure array-length check,
3682
+ * so a `function_call`-only output passes. Naming the actual defect —
3683
+ * empty array vs. wrong type — stops that misdiagnosis at the error string.
3684
+ *
1646
3685
  * @param response - The response to validate
1647
3686
  * @param allowEmptyOutput - When true, tolerate an empty (but present) output array
1648
3687
  * @throws Error if response is missing required fields or has invalid output
1649
3688
  */
1650
3689
  validateFinalResponse(response, allowEmptyOutput = false) {
1651
3690
  if (!response?.id || !response?.output) {
1652
- throw new Error('Invalid final response: missing required fields');
3691
+ const missing = [];
3692
+ if (!response?.id) {
3693
+ missing.push('id');
3694
+ }
3695
+ if (!response?.output) {
3696
+ missing.push('output');
3697
+ }
3698
+ throw new Error(`Invalid final response: missing required fields: ${missing.join(', ')}`);
1653
3699
  }
1654
3700
  if (!Array.isArray(response.output) || response.output.length === 0) {
1655
3701
  if (allowEmptyOutput) {
1656
3702
  return;
1657
3703
  }
1658
- throw new Error('Invalid final response: empty or invalid output');
3704
+ const detail = Array.isArray(response.output)
3705
+ ? `output array is empty (length 0) for response "${response.id}". The model returned no output items. This can happen when the provider returns an empty final turn; see the strictFinalResponse/allowFinalResponse options`
3706
+ : `output is not an array (got ${describeNonRecord(response.output)}) for response "${response.id}"`;
3707
+ throw new Error(`Invalid final response: empty or invalid output — ${detail}`);
1659
3708
  }
1660
3709
  }
1661
3710
  /**
@@ -1684,7 +3733,7 @@ export class ModelResult {
1684
3733
  const startedAt = performance.now();
1685
3734
  const newResult = await betaResponsesSend(this.options.client, {
1686
3735
  responsesRequest: newRequest,
1687
- }, this.options.options);
3736
+ }, this.dispatchRequestOptions());
1688
3737
  if (!newResult.ok) {
1689
3738
  throw newResult.error;
1690
3739
  }
@@ -1692,6 +3741,47 @@ export class ModelResult {
1692
3741
  await this.emitPostModelCall(response, startedAt, 'retry', turnNumber);
1693
3742
  return response;
1694
3743
  }
3744
+ /**
3745
+ * Build the RequestOptions for one API dispatch, composing the run-level
3746
+ * cancellation signal (`options.signal`) with the caller's RequestOptions.
3747
+ *
3748
+ * The SDK only auto-wires `timeoutMs` into an abort signal when NO signal
3749
+ * is present on the options — a supplied signal silently disables the
3750
+ * timeout. Composing here keeps both: each request is bounded by
3751
+ * whichever of {run signal, caller signal, per-request timeout} fires
3752
+ * first. MUST be called once per dispatch — `AbortSignal.timeout()`
3753
+ * starts counting at creation, so a cached composite would burn its
3754
+ * budget across turns instead of per request.
3755
+ *
3756
+ * Also the per-request fail-fast checkpoint: an already-aborted run
3757
+ * signal throws its abort reason here, before any network I/O.
3758
+ */
3759
+ dispatchRequestOptions() {
3760
+ const base = this.options.options;
3761
+ const runSignal = this.options.signal;
3762
+ if (!runSignal) {
3763
+ return base;
3764
+ }
3765
+ runSignal.throwIfAborted();
3766
+ const signals = [
3767
+ runSignal,
3768
+ ];
3769
+ const callerSignal = base?.signal ?? base?.fetchOptions?.signal;
3770
+ if (callerSignal) {
3771
+ signals.push(callerSignal);
3772
+ }
3773
+ // Re-create the timeout the SDK would have wired had no signal been
3774
+ // present. Per-request option wins over the client-level default,
3775
+ // mirroring betaResponsesSend's own precedence.
3776
+ const timeoutMs = base?.timeoutMs ?? this.options.client._options.timeoutMs;
3777
+ if (typeof timeoutMs === 'number' && timeoutMs > 0) {
3778
+ signals.push(AbortSignal.timeout(timeoutMs));
3779
+ }
3780
+ return {
3781
+ ...base,
3782
+ signal: signals.length === 1 ? runSignal : AbortSignal.any(signals),
3783
+ };
3784
+ }
1695
3785
  /**
1696
3786
  * Resolve async functions in the request for a given turn context.
1697
3787
  * Extracts non-function fields and resolves any async parameter functions.
@@ -1701,15 +3791,110 @@ export class ModelResult {
1701
3791
  */
1702
3792
  async resolveRequestForContext(context) {
1703
3793
  if (hasAsyncFunctions(this.options.request)) {
1704
- return resolveAsyncFunctions(this.options.request, context);
3794
+ const resolved = await resolveAsyncFunctions(this.options.request, context);
3795
+ return this.applyResolvedForcedToolChoicePolicy(resolved);
1705
3796
  }
1706
3797
  // Already resolved, extract non-function fields.
1707
3798
  // Strip ALL client-only fields — keep this list in sync with
1708
3799
  // `clientOnlyFields` in async-params.ts, which handles the async path.
1709
3800
  // (`sharedContextSchema` is absent here: call-model.ts destructures it
1710
3801
  // before the request reaches ModelResult.)
1711
- const { stopWhen: _, state: _s, requireApproval: _r, approveToolCalls: _a, rejectToolCalls: _rj, context: _c, onTurnStart: _ots, onTurnEnd: _ote, allowFinalResponse: _afr, strictFinalResponse: _sfr, hooks: _h, ...rest } = this.options.request;
1712
- return rest;
3802
+ const { stopWhen: _, state: _s, requireApproval: _r, approveToolCalls: _a, rejectToolCalls: _rj, context: _c, onTurnStart: _ots, onTurnEnd: _ote, allowFinalResponse: _afr, strictFinalResponse: _sfr, hooks: _h, doomLoop: _dl, signal: _sig, toolTimeoutMs: _ttm, toolConcurrency: _tc, asyncTools: _at, activeTools: _activeTools, ...rest } = this.options.request;
3803
+ const resolved = {
3804
+ ...rest,
3805
+ };
3806
+ stripToolSetSnapshotMetadata(resolved);
3807
+ return this.applyResolvedForcedToolChoicePolicy(resolved);
3808
+ }
3809
+ /**
3810
+ * Record the freshly-resolved caller choice before deriving its wire value.
3811
+ * An unforced turn clears the consumed key, re-arming the same forced value
3812
+ * if it appears again later. A different forced key re-arms immediately.
3813
+ */
3814
+ applyResolvedForcedToolChoicePolicy(request) {
3815
+ this.configuredToolChoice = request.toolChoice;
3816
+ this.configuredForcedToolChoiceKey = forcedToolChoiceKey(request.toolChoice);
3817
+ if (this.configuredForcedToolChoiceKey === null) {
3818
+ this.preparedToolChoiceCommit =
3819
+ this.activeConsumedForcedToolChoiceKey === null
3820
+ ? null
3821
+ : {
3822
+ type: 'clear',
3823
+ };
3824
+ }
3825
+ else {
3826
+ this.preparedToolChoiceCommit =
3827
+ this.configuredForcedToolChoiceKey === this.activeConsumedForcedToolChoiceKey
3828
+ ? null
3829
+ : {
3830
+ type: 'consume',
3831
+ key: this.configuredForcedToolChoiceKey,
3832
+ };
3833
+ }
3834
+ return this.applyForcedToolChoicePolicy(request);
3835
+ }
3836
+ /**
3837
+ * Derive the caller-configured tool choice for the active logical run.
3838
+ * Engine-owned one-turn overrides (`none` for finalization, or a pinned
3839
+ * doom-loop advisor) are applied after this helper and therefore still win.
3840
+ */
3841
+ applyForcedToolChoicePolicy(request) {
3842
+ const toolChoice = this.configuredForcedToolChoiceKey !== null &&
3843
+ this.configuredForcedToolChoiceKey === this.activeConsumedForcedToolChoiceKey
3844
+ ? relaxForcedToolChoice(this.configuredToolChoice)
3845
+ : this.configuredToolChoice;
3846
+ this.preparedEffectiveToolChoice = toolChoice;
3847
+ return toolChoice === request.toolChoice
3848
+ ? request
3849
+ : {
3850
+ ...request,
3851
+ toolChoice,
3852
+ };
3853
+ }
3854
+ /**
3855
+ * Bind the prepared caller-policy transition to the actual wire request.
3856
+ * Engine-owned overrides (for example a forced advisor consult) deliberately
3857
+ * produce a different semantic key and therefore cannot commit caller state.
3858
+ */
3859
+ beginToolChoiceDispatch(actualToolChoice) {
3860
+ this.dispatchedCallerToolChoice =
3861
+ canonicalizeKeyMaterial(actualToolChoice ?? null) ===
3862
+ canonicalizeKeyMaterial(this.preparedEffectiveToolChoice ?? null);
3863
+ this.dispatchedToolChoiceCommit = this.dispatchedCallerToolChoice
3864
+ ? this.preparedToolChoiceCommit
3865
+ : null;
3866
+ }
3867
+ /**
3868
+ * Commit only after the prepared caller choice reached the wire and a
3869
+ * response was materialized. A pause before dispatch leaves persisted state
3870
+ * untouched; a forced choice is consumed only when that response called a
3871
+ * tool.
3872
+ */
3873
+ commitToolChoiceDispatch(hasToolCalls) {
3874
+ const callerChoiceDispatched = this.dispatchedCallerToolChoice;
3875
+ const commit = this.dispatchedToolChoiceCommit;
3876
+ this.dispatchedCallerToolChoice = false;
3877
+ this.dispatchedToolChoiceCommit = null;
3878
+ if (!callerChoiceDispatched) {
3879
+ return;
3880
+ }
3881
+ this.preparedToolChoiceCommit = null;
3882
+ if (commit?.type === 'clear') {
3883
+ this.activeConsumedForcedToolChoiceKey = null;
3884
+ }
3885
+ else if (commit?.type === 'consume' && hasToolCalls) {
3886
+ this.activeConsumedForcedToolChoiceKey = commit.key;
3887
+ }
3888
+ }
3889
+ /** Clear all runtime and persisted policy state for a terminal run exit. */
3890
+ resetForcedToolChoicePolicy() {
3891
+ this.activeConsumedForcedToolChoiceKey = null;
3892
+ this.configuredForcedToolChoiceKey = null;
3893
+ this.configuredToolChoice = undefined;
3894
+ this.preparedEffectiveToolChoice = undefined;
3895
+ this.preparedToolChoiceCommit = null;
3896
+ this.dispatchedToolChoiceCommit = null;
3897
+ this.dispatchedCallerToolChoice = false;
1713
3898
  }
1714
3899
  /**
1715
3900
  * Apply `onResponseReceived` hooks to the freshly-supplied input items
@@ -1791,6 +3976,72 @@ export class ModelResult {
1791
3976
  if (updates) {
1792
3977
  this.currentState = updateState(this.currentState, updates);
1793
3978
  }
3979
+ // Persist only the consumed semantic key, never the callback or a copied
3980
+ // effective toolChoice. Dynamic values are re-resolved on every turn.
3981
+ if (this.activeConsumedForcedToolChoiceKey !== null) {
3982
+ this.currentState = {
3983
+ ...this.currentState,
3984
+ consumedForcedToolChoiceKey: this.activeConsumedForcedToolChoiceKey,
3985
+ };
3986
+ }
3987
+ else if (this.currentState.consumedForcedToolChoiceKey !== undefined) {
3988
+ const nextState = {
3989
+ ...this.currentState,
3990
+ };
3991
+ delete nextState.consumedForcedToolChoiceKey;
3992
+ this.currentState = nextState;
3993
+ }
3994
+ // Piggyback the doom-loop detector snapshot on every persist so loop
3995
+ // memory survives serialize → resume (a resumed doom loop is still a
3996
+ // doom loop — provided the resuming call passes `doomLoop` again).
3997
+ // Plain bounded JSON; absent when detection is off. The engine-owned
3998
+ // fields ride along: `stopVerdict` keeps a condemned run condemned
3999
+ // across decision-only resumes; `pendingSteer` carries queued guidance
4000
+ // that had no flush opportunity before a pause.
4001
+ if (this.doomLoopMonitor) {
4002
+ const doomLoopState = {
4003
+ ...this.doomLoopMonitor.getState(),
4004
+ ...(this.doomLoopStop !== null && {
4005
+ stopVerdict: this.doomLoopStop,
4006
+ }),
4007
+ ...(this.pendingDoomLoopSteer.length > 0 && {
4008
+ pendingSteer: [
4009
+ ...this.pendingDoomLoopSteer,
4010
+ ],
4011
+ }),
4012
+ };
4013
+ this.currentState = {
4014
+ ...this.currentState,
4015
+ doomLoop: doomLoopState,
4016
+ };
4017
+ }
4018
+ // Mirror each live task's lastLog onto its persisted pendingAsyncTools
4019
+ // entry (bounded ~200 chars) — the one piece of progress that survives
4020
+ // a restart, surfaced by post-restart check calls. Debounced naturally:
4021
+ // this runs only when state is being saved anyway, never per log entry.
4022
+ if (this.asyncToolRegistry && this.currentState.pendingAsyncTools?.length) {
4023
+ const registry = this.asyncToolRegistry;
4024
+ this.currentState = {
4025
+ ...this.currentState,
4026
+ pendingAsyncTools: this.currentState.pendingAsyncTools.map((entry) => {
4027
+ const live = registry.findByCallId(entry.callId);
4028
+ const lastLog = live?.lastLog;
4029
+ if (!lastLog) {
4030
+ return entry;
4031
+ }
4032
+ const text = typeof lastLog.data === 'string'
4033
+ ? lastLog.data
4034
+ : (JSON.stringify(lastLog.data) ?? String(lastLog.data));
4035
+ return {
4036
+ ...entry,
4037
+ lastLog: {
4038
+ at: lastLog.at,
4039
+ text: text.length > 200 ? `${text.slice(0, 200)}…` : text,
4040
+ },
4041
+ };
4042
+ }),
4043
+ };
4044
+ }
1794
4045
  try {
1795
4046
  await this.stateAccessor.save(this.currentState);
1796
4047
  }
@@ -1831,6 +4082,34 @@ export class ModelResult {
1831
4082
  const loadedState = await this.stateAccessor.load();
1832
4083
  if (loadedState) {
1833
4084
  this.currentState = loadedState;
4085
+ this.activeConsumedForcedToolChoiceKey = loadedState.consumedForcedToolChoiceKey ?? null;
4086
+ // Rehydrate doom-loop state so a resumed run continues where it
4087
+ // left off: streak counters always; the queued steer guidance;
4088
+ // and the stop verdict under the condemnation rule below.
4089
+ if (this.doomLoopMonitor && loadedState.doomLoop !== undefined) {
4090
+ this.doomLoopMonitor.restore(loadedState.doomLoop);
4091
+ const persisted = loadedState.doomLoop;
4092
+ if (Array.isArray(persisted.pendingSteer)) {
4093
+ for (const message of persisted.pendingSteer) {
4094
+ if (typeof message === 'string') {
4095
+ this.queueDoomLoopSteer(message);
4096
+ }
4097
+ }
4098
+ }
4099
+ // Condemnation rule: a doom-stopped conversation STAYS stopped
4100
+ // across decision-only resumes (approveToolCalls /
4101
+ // rejectToolCalls — approving a call is not new conversation).
4102
+ // A fresh conversational turn (a plain callModel on the same
4103
+ // state, no decisions) clears the verdict: operator input is
4104
+ // new information. Streaks are kept either way, so renewed
4105
+ // repetition re-condemns quickly.
4106
+ if (persisted.stopVerdict) {
4107
+ const isDecisionResume = this.approvedToolCalls.length > 0 || this.rejectedToolCalls.length > 0;
4108
+ if (isDecisionResume) {
4109
+ this.doomLoopStop = persisted.stopVerdict;
4110
+ }
4111
+ }
4112
+ }
1834
4113
  // Check if we're resuming from awaiting_approval or awaiting_hitl
1835
4114
  // with decisions. `awaiting_hitl` reuses `processApprovalDecisions`
1836
4115
  // because the resume mechanism is identical — the caller supplies
@@ -1871,13 +4150,18 @@ export class ModelResult {
1871
4150
  }
1872
4151
  // Keep manual calls durable until the resumed request produces a
1873
4152
  // response. Clearing before the API call would lose the only copy if
1874
- // that request failed.
4153
+ // that request failed. `awaiting_async_tools` resumes the same way:
4154
+ // the pending-task bookkeeping was already updated by
4155
+ // resumeToolResults (or remains pending for tasks not yet resolved),
4156
+ // and the placeholder outputs are in history, so a fresh request is
4157
+ // valid.
1875
4158
  this.resumingFromClientTools = loadedState.status === 'awaiting_client_tools';
4159
+ this.resumingFromAsyncTools = loadedState.status === 'awaiting_async_tools';
1876
4160
  }
1877
4161
  else {
1878
4162
  this.currentState = createInitialState();
1879
4163
  }
1880
- if (!this.resumingFromClientTools) {
4164
+ if (!this.resumingFromClientTools && !this.resumingFromAsyncTools) {
1881
4165
  await this.saveStateSafely({
1882
4166
  status: 'in_progress',
1883
4167
  });
@@ -2003,9 +4287,10 @@ export class ModelResult {
2003
4287
  turnNumber: 0,
2004
4288
  };
2005
4289
  // Make the API request
4290
+ this.beginToolChoiceDispatch(request.toolChoice);
2006
4291
  const apiResult = await betaResponsesSend(this.options.client, {
2007
4292
  responsesRequest: request,
2008
- }, this.options.options);
4293
+ }, this.dispatchRequestOptions());
2009
4294
  if (!apiResult.ok) {
2010
4295
  throw apiResult.error;
2011
4296
  }
@@ -2051,6 +4336,16 @@ export class ModelResult {
2051
4336
  // Track approved HITL calls that paused (onToolCalled returned null) —
2052
4337
  // these stay in pendingToolCalls so the caller can resume them later.
2053
4338
  const hitlPausedIds = new Set();
4339
+ // The approved batch is one doom-loop round: N approved duplicates of
4340
+ // the same call count once (the sequential loop below still evaluates
4341
+ // in order; restored streaks from the persisted state carry forward).
4342
+ // Declared from the approved calls only — a pending call the user did not
4343
+ // approve is not part of this round.
4344
+ await this.beginDoomLoopRound([
4345
+ ...this.approvedToolCalls,
4346
+ ]
4347
+ .map((callId) => pendingCalls.find((tc) => tc.id === callId))
4348
+ .filter((tc) => tc !== undefined));
2054
4349
  // Process approvals - execute the approved tools. Route through
2055
4350
  // runToolWithHooks so PreToolUse/PostToolUse fire even on this path.
2056
4351
  for (const callId of this.approvedToolCalls) {
@@ -2066,7 +4361,7 @@ export class ModelResult {
2066
4361
  }
2067
4362
  const hookOutcome = await this.runToolWithHooks(tool, toolCall, turnContext);
2068
4363
  if (hookOutcome.type === 'parse_error') {
2069
- this.broadcastToolResult(callId, this.toolSourceByName(String(toolCall.name)), {
4364
+ this.broadcastToolResult(callId, String(toolCall.name), this.toolSourceByName(String(toolCall.name)), {
2070
4365
  error: hookOutcome.errorMessage,
2071
4366
  });
2072
4367
  unsentResults.push(createRejectedResult(callId, String(toolCall.name), hookOutcome.errorMessage));
@@ -2083,6 +4378,10 @@ export class ModelResult {
2083
4378
  hitlPausedIds.add(callId);
2084
4379
  continue;
2085
4380
  }
4381
+ if (isAsyncToolInvocation(result)) {
4382
+ unsentResults.push(await this.settleAsyncInvocationAsUnsent(toolCall, result));
4383
+ continue;
4384
+ }
2086
4385
  if (result.error) {
2087
4386
  unsentResults.push(createRejectedResult(callId, String(toolCall.name), result.error.message));
2088
4387
  }
@@ -2131,6 +4430,11 @@ export class ModelResult {
2131
4430
  if (unsentResults.length > 0) {
2132
4431
  stateUpdates.unsentToolResults = unsentResults;
2133
4432
  }
4433
+ if (nextStatus === 'in_progress' && this.doomLoopStop) {
4434
+ // This approval continuation is terminal: no model request follows, so
4435
+ // a later fresh run must not inherit its consumed caller choice.
4436
+ this.resetForcedToolChoicePolicy();
4437
+ }
2134
4438
  await this.saveStateSafely(stateUpdates);
2135
4439
  // Clear optional properties if they should be empty
2136
4440
  const propsToClear = [];
@@ -2144,10 +4448,22 @@ export class ModelResult {
2144
4448
  this.clearOptionalStateProperties(propsToClear);
2145
4449
  await this.saveStateSafely();
2146
4450
  }
2147
- // If we are paused (for approval or for HITL), stop here
4451
+ // If we are paused (for approval or for HITL), stop here. Queued steer
4452
+ // guidance rides doomLoop.pendingSteer in the state persisted above;
4453
+ // the resumed run delivers it at its next safe flush point (injecting a
4454
+ // user message between dangling function_calls and their future
4455
+ // outputs would be invalid history).
2148
4456
  if (nextStatus !== 'in_progress') {
2149
4457
  return;
2150
4458
  }
4459
+ // A doom-stop armed while executing the approved calls (the restored
4460
+ // streak crossed the stop rung): halt WITHOUT the unsent-results model
4461
+ // request. The executed outputs are already persisted as unsent; the
4462
+ // condemned call got a blocked output. Session teardown reports the
4463
+ // doom reason via the armed verdict (see executeToolsIfNeeded).
4464
+ if (this.doomLoopStop) {
4465
+ return;
4466
+ }
2151
4467
  // Otherwise, continue with tool execution using unsent results
2152
4468
  await this.continueWithUnsentResults();
2153
4469
  }
@@ -2203,9 +4519,10 @@ export class ModelResult {
2203
4519
  turnNumber: turnContext.numberOfTurns,
2204
4520
  };
2205
4521
  // Make the API request
4522
+ this.beginToolChoiceDispatch(request.toolChoice);
2206
4523
  const apiResult = await betaResponsesSend(this.options.client, {
2207
4524
  responsesRequest: request,
2208
- }, this.options.options);
4525
+ }, this.dispatchRequestOptions());
2209
4526
  if (!apiResult.ok) {
2210
4527
  throw apiResult.error;
2211
4528
  }
@@ -2247,13 +4564,35 @@ export class ModelResult {
2247
4564
  this.currentState?.status === 'awaiting_hitl')) {
2248
4565
  return;
2249
4566
  }
4567
+ // A doom-stop armed while executing approved calls on resume:
4568
+ // processApprovalDecisions already skipped the unsent-results model
4569
+ // request; the executed outputs are persisted as unsent for a later
4570
+ // (fresh-input) resume. Halt here — like a pause, no stream exists.
4571
+ if (this.isResumingFromApproval && this.doomLoopStop) {
4572
+ sessionEndReason = 'doom_loop';
4573
+ this.resetForcedToolChoicePolicy();
4574
+ await this.saveStateSafely();
4575
+ return;
4576
+ }
2250
4577
  // Get initial response
2251
4578
  let currentResponse = await this.getInitialResponse();
4579
+ // toolChoice constrains model output, so a forced requirement is
4580
+ // satisfied as soon as this turn emits a tool call. Record it before
4581
+ // any approval/HITL/client/async pause can persist the run.
4582
+ const hasToolCalls = responseHasToolCalls(currentResponse);
4583
+ this.commitToolChoiceDispatch(hasToolCalls);
2252
4584
  // Save initial response to state
2253
4585
  await this.saveResponseToState(currentResponse);
4586
+ // Doom-loop text checkpoint for the initial response (within-response
4587
+ // token repetition + cross-step streak seed).
4588
+ await this.checkDoomLoopForResponse(currentResponse);
2254
4589
  // Check if tools should be executed
2255
- const hasToolCalls = currentResponse.output.some((item) => hasTypeProperty(item) && item.type === 'function_call');
2256
4590
  if (!this.options.tools?.length || !hasToolCalls) {
4591
+ // A text verdict on the initial response may have armed a stop —
4592
+ // report the run as doom-stopped, not as a normal completion.
4593
+ if (this.doomLoopStop) {
4594
+ sessionEndReason = 'doom_loop';
4595
+ }
2257
4596
  // No tool work: keep hard throw on empty/invalid final output.
2258
4597
  this.validateFinalResponse(currentResponse);
2259
4598
  this.finalResponse = currentResponse;
@@ -2282,11 +4621,27 @@ export class ModelResult {
2282
4621
  // runStopHook for the cap.
2283
4622
  let forceResumeCount = 0;
2284
4623
  while (true) {
4624
+ // Run-level cancellation: fail the loop at the turn boundary with
4625
+ // the abort reason. In-flight requests are aborted independently
4626
+ // by the composed dispatch signal (dispatchRequestOptions); this
4627
+ // check covers cancellation between requests (during tool
4628
+ // execution or hook work).
4629
+ this.options.signal?.throwIfAborted();
2285
4630
  // Check for external interruption
2286
4631
  if (await this.checkForInterruption(currentResponse)) {
2287
4632
  sessionEndReason = 'user';
2288
4633
  return;
2289
4634
  }
4635
+ // A doom-loop 'stop' verdict from the previous response's text
4636
+ // halts the run before any further spend. Seal first: this break
4637
+ // fires BEFORE this response's tool calls execute, so they need
4638
+ // synthesized halt outputs or the persisted history would carry
4639
+ // dangling function_calls (providers 400 on resume).
4640
+ if (this.doomLoopStop) {
4641
+ await this.sealDoomLoopStop(currentResponse);
4642
+ sessionEndReason = 'doom_loop';
4643
+ break;
4644
+ }
2290
4645
  // Check stop conditions
2291
4646
  if (await this.shouldStopExecution()) {
2292
4647
  // Emit the Stop hook -- handlers can force resume or inject a
@@ -2331,7 +4686,7 @@ export class ModelResult {
2331
4686
  // Resolve async functions for this turn
2332
4687
  await this.resolveAsyncFunctionsForTurn(turnContext);
2333
4688
  // Execute tools
2334
- const { toolResults, pausedCalls } = await this.executeToolRound(currentToolCalls, turnContext);
4689
+ const { toolResults, pausedCalls, deferredTasks = [], } = await this.executeToolRound(currentToolCalls, turnContext);
2335
4690
  // A tool round with observable progress resets the consecutive
2336
4691
  // forceResume counter so a legitimate override earlier in the run
2337
4692
  // does not count against a later, independent one.
@@ -2384,6 +4739,15 @@ export class ModelResult {
2384
4739
  await this.persistHitlPause(currentResponse, pausedCalls);
2385
4740
  return;
2386
4741
  }
4742
+ // Deferred tools started durable external tasks this round: pause.
4743
+ // Unlike HITL, the round is fully paired (placeholder outputs were
4744
+ // persisted above), so the resumed conversation history is valid;
4745
+ // the pause exists so the model's next turn can incorporate the
4746
+ // real results supplied via .resolve()/resumeToolResults().
4747
+ if (deferredTasks.length > 0) {
4748
+ await this.persistAsyncToolPause(currentResponse, deferredTasks);
4749
+ return;
4750
+ }
2387
4751
  // Manual (client-executed) tools produce no output this round —
2388
4752
  // `executeToolRound` returns nothing for them — so a mixed round of
2389
4753
  // auto-executed and manual calls would otherwise send a follow-up
@@ -2403,6 +4767,27 @@ export class ModelResult {
2403
4767
  await this.persistClientToolsPause(currentResponse, unresolvedToolCalls);
2404
4768
  return;
2405
4769
  }
4770
+ // A doom-loop 'stop' verdict armed during this round halts the run
4771
+ // before the next model request. Seal defensively: the condemned
4772
+ // call already got a blocked error output, but a TEXT stop armed
4773
+ // by a concurrent checkpoint could leave other calls unresolved.
4774
+ // No final text-coercion request is made: the point of 'stop' is
4775
+ // to cut spend on a run that is demonstrably not progressing.
4776
+ if (this.doomLoopStop) {
4777
+ await this.sealDoomLoopStop(currentResponse, new Set(toolResults.map((r) => r.callId)));
4778
+ sessionEndReason = 'doom_loop';
4779
+ break;
4780
+ }
4781
+ // Inject any queued doom-loop steer guidance so the very next
4782
+ // request carries it. It lands in the input before this round's
4783
+ // call/output items (the injection point predates the follow-up
4784
+ // accumulation) — semantically fine, the guidance names the
4785
+ // repeated pattern explicitly.
4786
+ await this.flushDoomLoopSteer();
4787
+ // Inject any background-tool results that settled since the last
4788
+ // flush. Same safe point as the steer flush: the round is fully
4789
+ // paired, so a user-role envelope here is valid history.
4790
+ await this.flushAsyncToolDeliveries();
2406
4791
  // Apply nextTurnParams
2407
4792
  await this.applyNextTurnParams(currentToolCalls);
2408
4793
  currentResponse = await this.makeFollowupRequest(currentResponse, toolResults, turnNumber);
@@ -2412,6 +4797,8 @@ export class ModelResult {
2412
4797
  await this.options.onTurnEnd?.(turnContext, currentResponse);
2413
4798
  // Save new response to state
2414
4799
  await this.saveResponseToState(currentResponse);
4800
+ // Doom-loop text checkpoint for the fresh response.
4801
+ await this.checkDoomLoopForResponse(currentResponse);
2415
4802
  currentRound++;
2416
4803
  }
2417
4804
  // If stopWhen broke the loop while the model was still emitting tool
@@ -2434,7 +4821,7 @@ export class ModelResult {
2434
4821
  };
2435
4822
  await this.options.onTurnStart?.(turnContext);
2436
4823
  await this.resolveAsyncFunctionsForTurn(turnContext);
2437
- const { toolResults, pausedCalls } = await this.executeToolRound(pendingToolCalls, turnContext);
4824
+ const { toolResults, pausedCalls, deferredTasks: finalDeferredTasks = [], } = await this.executeToolRound(pendingToolCalls, turnContext);
2438
4825
  // Track the executed round and persist real outputs BEFORE the HITL
2439
4826
  // pause check — mirrors the in-loop ordering at executeToolsIfNeeded
2440
4827
  // so a partial batch (HITL + regular tools) doesn't drop the regular
@@ -2455,30 +4842,49 @@ export class ModelResult {
2455
4842
  await this.persistHitlPause(currentResponse, pausedCalls);
2456
4843
  return;
2457
4844
  }
2458
- // Apply any nextTurnParams from the executed tools so they affect the
2459
- // final text-coercion request (mirrors the in-loop behavior).
2460
- await this.applyNextTurnParams(pendingToolCalls);
2461
- // Pair any manual tool calls (no execute fn) with stub outputs so
2462
- // every function_call in the *request* has a matching output. Stubs
2463
- // are NOT persisted to state — only real tool outputs are — so a
2464
- // resumed conversation doesn't see "Tool execution skipped" as if it
2465
- // were a real result.
2466
- const executedCallIds = new Set(toolResults.map((r) => r.callId));
2467
- const stubOutputs = pendingToolCalls
2468
- .filter((tc) => !executedCallIds.has(tc.id))
2469
- .map((tc) => ({
2470
- type: 'function_call_output',
2471
- callId: tc.id,
2472
- output: 'Tool execution skipped: step limit reached.',
2473
- }));
2474
- const requestOutputs = [
2475
- ...toolResults,
2476
- ...stubOutputs,
2477
- ];
2478
- currentResponse = await this.makeFinalResponseRequest(currentResponse, requestOutputs, allowFinalResponse, turnNumber);
2479
- await this.options.onTurnEnd?.(turnContext, currentResponse);
2480
- await this.saveResponseToState(currentResponse);
4845
+ if (finalDeferredTasks.length > 0) {
4846
+ // Deferred tasks started on the halted turn — persist the pause
4847
+ // and skip the final text-coercion request; the resumed run
4848
+ // produces the final answer with the real results in hand.
4849
+ await this.persistAsyncToolPause(currentResponse, finalDeferredTasks);
4850
+ return;
4851
+ }
4852
+ // A doom-stop armed while executing the halted turn's pending
4853
+ // calls: seal and skip the final text-coercion request — the
4854
+ // design contract is "no further model requests after stop".
4855
+ if (this.doomLoopStop) {
4856
+ await this.sealDoomLoopStop(currentResponse, new Set(toolResults.map((r) => r.callId)));
4857
+ sessionEndReason = 'doom_loop';
4858
+ }
4859
+ else {
4860
+ // Apply any nextTurnParams from the executed tools so they affect
4861
+ // the final text-coercion request (mirrors the in-loop behavior).
4862
+ await this.applyNextTurnParams(pendingToolCalls);
4863
+ // Pair any manual tool calls (no execute fn) with stub outputs so
4864
+ // every function_call in the *request* has a matching output.
4865
+ // Stubs are NOT persisted to state — only real tool outputs are —
4866
+ // so a resumed conversation doesn't see "Tool execution skipped"
4867
+ // as if it were a real result.
4868
+ const executedCallIds = new Set(toolResults.map((r) => r.callId));
4869
+ const stubOutputs = pendingToolCalls
4870
+ .filter((tc) => !executedCallIds.has(tc.id))
4871
+ .map((tc) => ({
4872
+ type: 'function_call_output',
4873
+ callId: tc.id,
4874
+ output: 'Tool execution skipped: step limit reached.',
4875
+ }));
4876
+ const requestOutputs = [
4877
+ ...toolResults,
4878
+ ...stubOutputs,
4879
+ ];
4880
+ currentResponse = await this.makeFinalResponseRequest(currentResponse, requestOutputs, allowFinalResponse, turnNumber);
4881
+ await this.options.onTurnEnd?.(turnContext, currentResponse);
4882
+ await this.saveResponseToState(currentResponse);
4883
+ }
2481
4884
  }
4885
+ // Background tasks still in flight (or settled but undelivered) as
4886
+ // the loop finalizes: drain / detach / cancel per asyncTools config.
4887
+ currentResponse = await this.handleRunEndAsyncTasks(currentResponse);
2482
4888
  // Validate and finalize. Mini-class models intermittently return an
2483
4889
  // empty final turn after a successful tool round (the tool call was
2484
4890
  // the answer). Retry once, then tolerate empty output so a completed
@@ -2502,6 +4908,9 @@ export class ModelResult {
2502
4908
  }
2503
4909
  catch (error) {
2504
4910
  sessionEndReason = 'error';
4911
+ // A failing (or aborted) run must not leave tool bodies or
4912
+ // background tasks running against a dead conversation.
4913
+ this.abortAllToolWork(error instanceof Error ? error.message : String(error));
2505
4914
  throw error;
2506
4915
  }
2507
4916
  finally {
@@ -2552,6 +4961,10 @@ export class ModelResult {
2552
4961
  * Get the complete response object including usage information.
2553
4962
  * This will consume the stream until completion and execute any tools.
2554
4963
  * Returns the full OpenResponsesResult with usage data (inputTokens, outputTokens, cachedTokens, etc.)
4964
+ *
4965
+ * Note: in a multi-round tool loop this is the **final** round's response
4966
+ * only — its `usage` block covers that one generation, not the run. For
4967
+ * aggregate totals across every model call, use `getUsage()`.
2555
4968
  */
2556
4969
  async getResponse() {
2557
4970
  await this.executeToolsIfNeeded();
@@ -2640,6 +5053,17 @@ export class ModelResult {
2640
5053
  * - file_search_call: File search operations
2641
5054
  * - image_generation_call: Image generation operations
2642
5055
  * - function_call_output: Results from executed tools
5056
+ *
5057
+ * This stream carries **output items only** — no usage or response-level
5058
+ * metadata. The `response.completed` events (which hold each round's usage
5059
+ * block) are not surfaced here, and `getResponse()` afterwards returns only
5060
+ * the *final* round's response, so a multi-round tool loop's `tool_calls`
5061
+ * generations are not accounted for by either. For usage, reach for:
5062
+ * - `getUsage()` — aggregate token/cost totals across every round
5063
+ * - the `PostModelCall` hook — one emit per model call, with
5064
+ * `turnType`/`turnNumber` and that call's own `usage`
5065
+ * - `getFullResponsesStream()` — raw events including each round's
5066
+ * `response.completed`, and therefore its per-round usage block
2643
5067
  */
2644
5068
  getItemsStream() {
2645
5069
  // Build the allowed-item-type scope from the tools actually passed to
@@ -2950,10 +5374,12 @@ export class ModelResult {
2950
5374
  continue;
2951
5375
  }
2952
5376
  if (event.type === 'tool.preliminary_result') {
5377
+ const prelim = event;
2953
5378
  yield {
2954
5379
  type: 'preliminary_result',
2955
- toolCallId: event.toolCallId,
2956
- result: event.result,
5380
+ toolCallId: prelim.toolCallId,
5381
+ toolName: prelim.toolName,
5382
+ result: prelim.result,
2957
5383
  };
2958
5384
  }
2959
5385
  }
@@ -3062,23 +5488,82 @@ export class ModelResult {
3062
5488
  }
3063
5489
  }
3064
5490
  /**
3065
- * Cancel the underlying stream and all consumers
5491
+ * Cancel the underlying stream and all consumers, and abort in-flight
5492
+ * tool executions and background tasks (their `ctx.signal` fires).
5493
+ *
5494
+ * Note: prior releases only cancelled the stream; aborting tool work is
5495
+ * new with async tool support.
3066
5496
  */
3067
5497
  async cancel() {
5498
+ this.abortAllToolWork('Cancelled via ModelResult.cancel()');
3068
5499
  if (this.reusableStream) {
3069
5500
  await this.reusableStream.cancel();
3070
5501
  }
3071
5502
  }
5503
+ /**
5504
+ * Snapshot of every async tool task tracked by this run (background and
5505
+ * deferred), including settled ones.
5506
+ */
5507
+ getAsyncTasks() {
5508
+ return this.asyncToolRegistry?.snapshot() ?? [];
5509
+ }
5510
+ /**
5511
+ * Cancel one in-process async task by its task id. Background tasks abort
5512
+ * their `ctx.signal`; the model receives a `status: 'cancelled'` envelope
5513
+ * at the next delivery point so it is not left waiting forever.
5514
+ *
5515
+ * For deferred tasks in a paused (possibly other-process) conversation,
5516
+ * use the deferred tool's `.cancel()` method instead.
5517
+ *
5518
+ * @returns true when a working task was found and cancelled.
5519
+ */
5520
+ cancelTask(taskId, reason) {
5521
+ return this.asyncToolRegistry?.cancelTask(taskId, reason) ?? false;
5522
+ }
5523
+ /**
5524
+ * Send a steering message to a running in-process task. Delivered to the
5525
+ * run body's `ctx.onMessage` handler (queued until one registers). Agent
5526
+ * tools (`tool.agent()`) auto-forward messages into the child
5527
+ * conversation as user messages at the child's next turn boundary.
5528
+ *
5529
+ * Throws for deferred tasks — their work runs in an external system;
5530
+ * steer it there.
5531
+ *
5532
+ * @returns true when a working task received (or queued) the message.
5533
+ */
5534
+ sendToTask(taskId, message) {
5535
+ return this.asyncToolRegistry?.sendToTask(taskId, message) ?? false;
5536
+ }
5537
+ /**
5538
+ * Queue a user-role message for injection into this run's conversation at
5539
+ * the next safe turn boundary (the same mechanism doom-loop steer
5540
+ * guidance uses — never between a dangling `function_call` and its
5541
+ * output). The primary consumer is `tool.agent()`, which forwards
5542
+ * steering messages into child conversations through this.
5543
+ *
5544
+ * Delivery requires a next dispatch: boundaries are post-tool-round
5545
+ * follow-ups, pause-persists, and end-of-run drain turns. A message
5546
+ * queued while the run is composing its FINAL text response (no further
5547
+ * dispatch of any kind) has no boundary left and is not delivered —
5548
+ * steer while the run still has work in flight.
5549
+ */
5550
+ queueUserMessage(text) {
5551
+ this.queueDoomLoopSteer(text);
5552
+ }
3072
5553
  // =========================================================================
3073
5554
  // Multi-Turn Conversation State Methods
3074
5555
  // =========================================================================
3075
5556
  /**
3076
5557
  * Check if the conversation requires human/client input to continue.
3077
- * Returns true when the conversation is paused waiting for caller-supplied
3078
- * decisions — approval/rejection (`awaiting_approval`), HITL tool resume
3079
- * (`awaiting_hitl`), or client-executed manual tools (`awaiting_client_tools`).
3080
- * Also returns true whenever `pendingToolCalls` is populated regardless of
3081
- * status.
5558
+ * Returns true when the conversation is paused waiting on the CALLER —
5559
+ * approval/rejection (`awaiting_approval`), HITL tool resume
5560
+ * (`awaiting_hitl`), client-executed manual tools
5561
+ * (`awaiting_client_tools`), or an external async task resolution
5562
+ * (`awaiting_async_tools`, cleared via the deferred tool's `.resolve()` /
5563
+ * `.fail()` / `.cancel()` or `resumeToolResults()` — NOT via
5564
+ * `approveToolCalls`). Also returns true whenever `pendingToolCalls` is
5565
+ * populated regardless of status. To branch on the pause KIND, read
5566
+ * `(await getState()).status` instead.
3082
5567
  */
3083
5568
  async requiresApproval() {
3084
5569
  await this.initStreamGuarded({
@@ -3087,7 +5572,8 @@ export class ModelResult {
3087
5572
  const status = this.currentState?.status;
3088
5573
  if (status === 'awaiting_approval' ||
3089
5574
  status === 'awaiting_hitl' ||
3090
- status === 'awaiting_client_tools') {
5575
+ status === 'awaiting_client_tools' ||
5576
+ status === 'awaiting_async_tools') {
3091
5577
  return true;
3092
5578
  }
3093
5579
  // Also check if pendingToolCalls is populated
@@ -3126,5 +5612,139 @@ export class ModelResult {
3126
5612
  }
3127
5613
  return this.currentState;
3128
5614
  }
5615
+ /**
5616
+ * The doom-loop verdict that stopped this run, or null when the run was
5617
+ * not stopped by doom-loop detection (including when detection is off).
5618
+ * Resolves after the tool loop settles. For per-event observation use the
5619
+ * `DoomLoopDetected` hook.
5620
+ */
5621
+ async getDoomLoopVerdict() {
5622
+ await this.initStreamGuarded({
5623
+ requireStream: false,
5624
+ });
5625
+ if (!this.isResumingFromApproval) {
5626
+ await this.executeToolsIfNeeded();
5627
+ }
5628
+ return this.doomLoopStop;
5629
+ }
5630
+ /**
5631
+ * Aggregate token/cost usage across **every** model call this run made —
5632
+ * the initial request, each tool-round follow-up, the empty-final retry,
5633
+ * the `allowFinalResponse` final turn, and approval-resume requests.
5634
+ *
5635
+ * This is the pull-based counterpart to the `SessionEnd.totalUsage` hook
5636
+ * payload (same `SessionUsageTotals` shape, same numbers) and exists
5637
+ * because `getResponse()` returns only the **final** round's response: in a
5638
+ * multi-round tool loop the `tool_calls` generations' tokens are otherwise
5639
+ * unreachable, and `getItemsStream()` carries output items only — no
5640
+ * `response.completed` usage block.
5641
+ *
5642
+ * Gates on run completion the same way `getResponse()` does, so totals are
5643
+ * final whether you await it directly, after `getResponse()`, or after
5644
+ * consuming any of the streaming getters — except on an approval-resumed
5645
+ * run, where reading usage never advances the tool loop: the resume
5646
+ * request's own generation is awaited and counted, but no further rounds
5647
+ * are driven, so totals can be mid-run. Await `getResponse()`/`getText()`
5648
+ * first for final totals there. On a run that paused (approval / HITL) or
5649
+ * failed, it returns the totals accrued so far — `modelCalls: 0` with
5650
+ * zeroed tokens when no model call ever completed.
5651
+ *
5652
+ * Unlike `getResponse()`, this never rejects: a failed run still consumed
5653
+ * tokens, and cost accounting typically runs in a `finally`/`catch` where a
5654
+ * second throw would mask the run's original error. Await the run itself
5655
+ * (e.g. `getResponse()`) if you need to observe the failure.
5656
+ *
5657
+ * `cost` is present only when the server reported cost accounting for at
5658
+ * least one call; a `0` would be indistinguishable from "free".
5659
+ *
5660
+ * For per-call granularity use the `PostModelCall` hook (one emit per model
5661
+ * call, with `turnType`/`turnNumber`) or `getFullResponsesStream()`, whose
5662
+ * `response.completed` events carry each round's own usage block.
5663
+ */
5664
+ async getUsage() {
5665
+ try {
5666
+ await this.initStreamGuarded({
5667
+ requireStream: false,
5668
+ });
5669
+ if (!this.isResumingFromApproval) {
5670
+ await this.executeToolsIfNeeded();
5671
+ }
5672
+ else if (this.pendingModelCall && this.reusableStream) {
5673
+ // The resume dispatch returned a live event stream whose telemetry
5674
+ // is still parked (the non-streaming branch folds usage in
5675
+ // immediately — see continueWithUnsentResults). Consuming the
5676
+ // reusable stream is a passive observation: it buffers events
5677
+ // without executing tools or mutating conversation state, so the
5678
+ // resume generation is counted without advancing the loop.
5679
+ await this.emitPendingModelCallOnce(await consumeStreamForCompletion(this.reusableStream));
5680
+ }
5681
+ }
5682
+ catch (error) {
5683
+ // Intentionally swallowed — see the "never rejects" note above. The
5684
+ // aggregate below still reports whatever calls completed before the
5685
+ // failure; the warn leaves a diagnostic thread for the swallowed cause.
5686
+ console.warn('[getUsage] run failed; reporting totals accrued so far:', error);
5687
+ }
5688
+ return this.snapshotSessionUsage();
5689
+ }
5690
+ }
5691
+ /**
5692
+ * The unified-run affordances (`ctx.defer` for deferred tools, `ctx.log`,
5693
+ * `ctx.onMessage`, the transcript-source slot) threaded into a tool's run
5694
+ * context via `runExtras`.
5695
+ */
5696
+ function buildRunExtras(tool, runBinding) {
5697
+ return {
5698
+ ...(isUnifiedTool(tool) &&
5699
+ tool.function.lifecycle === 'deferred' && {
5700
+ defer: (taskId, options) => ({
5701
+ __deferred: true,
5702
+ taskId,
5703
+ ...(options ?? {}),
5704
+ }),
5705
+ }),
5706
+ log: runBinding.log,
5707
+ onMessage: runBinding.onMessage,
5708
+ // Live getter through the binding — the ToolTask (and its id) is only
5709
+ // created when the call escapes the round, after this object is built.
5710
+ get taskId() {
5711
+ return runBinding.task()?.taskId;
5712
+ },
5713
+ // Transcript slot only — deliberately NOT `task`: TurnContext.task is
5714
+ // the ToolTaskHandle facade (check calls only) and a run body must not
5715
+ // see a transcript-only object under that name.
5716
+ taskTranscript: {
5717
+ set transcriptSource(source) {
5718
+ runBinding.setTranscriptSource(source);
5719
+ },
5720
+ get transcriptSource() {
5721
+ return runBinding.task()?.transcriptSource;
5722
+ },
5723
+ },
5724
+ };
5725
+ }
5726
+ /**
5727
+ * `task` tool, action=result on a SETTLED live task: the final result
5728
+ * (completed) or terminal status + error. Null when the task is not
5729
+ * settled in-process — the caller falls back to the status view.
5730
+ */
5731
+ function taskToolResultIfSettled(input, liveTask) {
5732
+ if (liveTask?.status === 'completed') {
5733
+ return {
5734
+ taskId: input.taskId,
5735
+ status: 'completed',
5736
+ result: liveTask.result,
5737
+ };
5738
+ }
5739
+ if (liveTask && liveTask.status !== 'working') {
5740
+ return {
5741
+ taskId: input.taskId,
5742
+ status: liveTask.status,
5743
+ ...(liveTask.error !== undefined && {
5744
+ error: liveTask.error,
5745
+ }),
5746
+ };
5747
+ }
5748
+ return null;
3129
5749
  }
3130
5750
  //# sourceMappingURL=model-result.js.map