@openrouter/agent 0.8.0 → 0.9.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 (87) hide show
  1. package/README.md +483 -5
  2. package/esm/index.d.ts +19 -4
  3. package/esm/index.d.ts.map +1 -1
  4. package/esm/index.js +9 -2
  5. package/esm/index.js.map +1 -1
  6. package/esm/inner-loop/call-model.d.ts.map +1 -1
  7. package/esm/inner-loop/call-model.js +40 -41
  8. package/esm/inner-loop/call-model.js.map +1 -1
  9. package/esm/inner-loop/resume-tool-results.d.ts +128 -0
  10. package/esm/inner-loop/resume-tool-results.d.ts.map +1 -0
  11. package/esm/inner-loop/resume-tool-results.js +244 -0
  12. package/esm/inner-loop/resume-tool-results.js.map +1 -0
  13. package/esm/lib/agent-tool.d.ts +92 -0
  14. package/esm/lib/agent-tool.d.ts.map +1 -0
  15. package/esm/lib/agent-tool.js +211 -0
  16. package/esm/lib/agent-tool.js.map +1 -0
  17. package/esm/lib/async-params.d.ts +82 -0
  18. package/esm/lib/async-params.d.ts.map +1 -1
  19. package/esm/lib/async-params.js +5 -0
  20. package/esm/lib/async-params.js.map +1 -1
  21. package/esm/lib/async-tool-registry.d.ts +134 -0
  22. package/esm/lib/async-tool-registry.d.ts.map +1 -0
  23. package/esm/lib/async-tool-registry.js +352 -0
  24. package/esm/lib/async-tool-registry.js.map +1 -0
  25. package/esm/lib/async-tools.d.ts +23 -0
  26. package/esm/lib/async-tools.d.ts.map +1 -0
  27. package/esm/lib/async-tools.js +19 -0
  28. package/esm/lib/async-tools.js.map +1 -0
  29. package/esm/lib/conversation-state.d.ts +1 -0
  30. package/esm/lib/conversation-state.d.ts.map +1 -1
  31. package/esm/lib/conversation-state.js +8 -0
  32. package/esm/lib/conversation-state.js.map +1 -1
  33. package/esm/lib/doom-loop.d.ts +533 -0
  34. package/esm/lib/doom-loop.d.ts.map +1 -0
  35. package/esm/lib/doom-loop.js +1221 -0
  36. package/esm/lib/doom-loop.js.map +1 -0
  37. package/esm/lib/hooks-schemas.d.ts +33 -0
  38. package/esm/lib/hooks-schemas.d.ts.map +1 -1
  39. package/esm/lib/hooks-schemas.js +54 -0
  40. package/esm/lib/hooks-schemas.js.map +1 -1
  41. package/esm/lib/hooks-types.d.ts +6 -2
  42. package/esm/lib/hooks-types.d.ts.map +1 -1
  43. package/esm/lib/hooks-types.js.map +1 -1
  44. package/esm/lib/model-result.d.ts +646 -10
  45. package/esm/lib/model-result.d.ts.map +1 -1
  46. package/esm/lib/model-result.js +2749 -153
  47. package/esm/lib/model-result.js.map +1 -1
  48. package/esm/lib/reusable-stream.d.ts +9 -0
  49. package/esm/lib/reusable-stream.d.ts.map +1 -1
  50. package/esm/lib/reusable-stream.js +17 -0
  51. package/esm/lib/reusable-stream.js.map +1 -1
  52. package/esm/lib/stream-transformers.d.ts +20 -0
  53. package/esm/lib/stream-transformers.d.ts.map +1 -1
  54. package/esm/lib/stream-transformers.js +45 -0
  55. package/esm/lib/stream-transformers.js.map +1 -1
  56. package/esm/lib/tool-check.d.ts +76 -0
  57. package/esm/lib/tool-check.d.ts.map +1 -0
  58. package/esm/lib/tool-check.js +241 -0
  59. package/esm/lib/tool-check.js.map +1 -0
  60. package/esm/lib/tool-concurrency.d.ts +42 -0
  61. package/esm/lib/tool-concurrency.d.ts.map +1 -0
  62. package/esm/lib/tool-concurrency.js +80 -0
  63. package/esm/lib/tool-concurrency.js.map +1 -0
  64. package/esm/lib/tool-context.d.ts +45 -1
  65. package/esm/lib/tool-context.d.ts.map +1 -1
  66. package/esm/lib/tool-context.js +60 -1
  67. package/esm/lib/tool-context.js.map +1 -1
  68. package/esm/lib/tool-executor.d.ts +52 -9
  69. package/esm/lib/tool-executor.d.ts.map +1 -1
  70. package/esm/lib/tool-executor.js +253 -21
  71. package/esm/lib/tool-executor.js.map +1 -1
  72. package/esm/lib/tool-orchestrator.d.ts.map +1 -1
  73. package/esm/lib/tool-orchestrator.js +87 -50
  74. package/esm/lib/tool-orchestrator.js.map +1 -1
  75. package/esm/lib/tool-task.d.ts +157 -0
  76. package/esm/lib/tool-task.d.ts.map +1 -0
  77. package/esm/lib/tool-task.js +206 -0
  78. package/esm/lib/tool-task.js.map +1 -0
  79. package/esm/lib/tool-types.d.ts +473 -14
  80. package/esm/lib/tool-types.d.ts.map +1 -1
  81. package/esm/lib/tool-types.js +59 -10
  82. package/esm/lib/tool-types.js.map +1 -1
  83. package/esm/lib/tool.d.ts +184 -2
  84. package/esm/lib/tool.d.ts.map +1 -1
  85. package/esm/lib/tool.js +193 -3
  86. package/esm/lib/tool.js.map +1 -1
  87. package/package.json +30 -2
@@ -1,16 +1,17 @@
1
1
  import { betaResponsesSend } from '@openrouter/sdk/funcs/betaResponsesSend';
2
2
  import { hasAsyncFunctions, resolveAsyncFunctions } from './async-params.js';
3
- import { appendToMessages, createInitialState, createRejectedResult, createUnsentResult, extractTextFromResponse as extractTextFromResponseState, partitionToolCalls, unsentResultsToAPIFormat, updateState, } from './conversation-state.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).
@@ -490,6 +707,13 @@ export class ModelResult {
490
707
  this.resumingFromClientTools = false;
491
708
  stateUpdates.status = 'in_progress';
492
709
  }
710
+ if (this.resumingFromAsyncTools) {
711
+ // The resumed request succeeded — the conversation is moving again.
712
+ // Pending tasks that are still unresolved stay on pendingAsyncTools
713
+ // (resumeToolResults maintains that list); only the status advances.
714
+ this.resumingFromAsyncTools = false;
715
+ stateUpdates.status = 'in_progress';
716
+ }
493
717
  await this.saveStateSafely(stateUpdates);
494
718
  }
495
719
  /**
@@ -497,6 +721,9 @@ export class ModelResult {
497
721
  * Sets the conversation status to 'complete' indicating no further tool execution is needed.
498
722
  */
499
723
  async markStateComplete() {
724
+ // A later conversational turn is a new logical run and may force its
725
+ // first tool call again.
726
+ this.resetForcedToolChoicePolicy();
500
727
  await this.saveStateSafely({
501
728
  status: 'complete',
502
729
  });
@@ -533,6 +760,7 @@ export class ModelResult {
533
760
  // Save partial state
534
761
  if (this.currentState) {
535
762
  const currentToolCalls = extractToolCallsFromResponse(currentResponse);
763
+ this.resetForcedToolChoicePolicy();
536
764
  await this.saveStateSafely({
537
765
  status: 'interrupted',
538
766
  partialResponse: {
@@ -659,6 +887,10 @@ export class ModelResult {
659
887
  */
660
888
  hasExecutableToolCalls(toolCalls) {
661
889
  return toolCalls.some((toolCall) => {
890
+ // The universal task tool is engine-intercepted — always executable.
891
+ if (toolCall.name === TASK_TOOL_NAME && this.taskToolActive()) {
892
+ return true;
893
+ }
662
894
  const tool = this.options.tools?.find((t) => isClientTool(t) && t.function.name === toolCall.name);
663
895
  return tool && isAutoResolvableTool(tool);
664
896
  });
@@ -692,7 +924,7 @@ export class ModelResult {
692
924
  * - `execution`: The tool ran. `result` is the ToolExecutionResult.
693
925
  * `effectiveToolCall` reflects any `mutatedInput` piped by PreToolUse.
694
926
  */
695
- async runToolWithHooks(tool, toolCall, turnContext, onPreliminaryResult) {
927
+ async runToolWithHooks(tool, toolCall, turnContext, onPreliminaryResult, extras) {
696
928
  // Reject raw-string arguments before any hook fires. When the model
697
929
  // produces invalid JSON, the parser leaves `toolCall.arguments` as the
698
930
  // raw string; handing that to PreToolUse would either fail payload
@@ -702,6 +934,23 @@ export class ModelResult {
702
934
  // synthetic error without running the tool or firing hooks.
703
935
  const rawArgs = toolCall.arguments;
704
936
  if (typeof rawArgs === 'string') {
937
+ // Malformed calls are classic doom-loop fuel: a model stuck emitting
938
+ // the same invalid JSON re-triggers this parse error forever. Record
939
+ // the raw string as the call's identity so the streak trips the
940
+ // detector instead of bouncing off the parse error unbounded. The
941
+ // parse-error output below already prevents execution, so 'block'
942
+ // needs no extra handling; 'steer'/'stop' side effects are applied
943
+ // inside the ordered evaluation.
944
+ if (this.doomLoopMonitor) {
945
+ await this.enqueueDoomLoopEvaluation({
946
+ toolName: String(toolCall.name),
947
+ keyMaterial: rawArgs,
948
+ fallbackKeyMaterial: null,
949
+ allowBlock: true,
950
+ detector: 'tool-fingerprint',
951
+ toolCall,
952
+ });
953
+ }
705
954
  const errorMessage = `Failed to parse tool call arguments for "${toolCall.name}": The model provided invalid JSON. ` +
706
955
  `Raw arguments received: "${rawArgs}". ` +
707
956
  'Please provide valid JSON arguments for this tool call.';
@@ -719,6 +968,29 @@ export class ModelResult {
719
968
  },
720
969
  };
721
970
  }
971
+ // Doom-loop checkpoint — BEFORE PreToolUse, on the arguments the MODEL
972
+ // issued (pre-mutation): the loop evidence is the model repeating
973
+ // itself, and a PreToolUse hook that rewrites input each call (e.g.
974
+ // injecting a nonce) must not mask that repetition. A 'block' verdict
975
+ // returns the same `hook_blocked` shape as a PreToolUse block, so every
976
+ // caller handles it identically; PreToolUse deliberately does not fire
977
+ // for doom-blocked calls (nothing will execute — mirrors parse_error).
978
+ const doomOutcome = await this.checkDoomLoopBeforeExecution(tool, toolCall);
979
+ if (doomOutcome.blocked) {
980
+ return {
981
+ type: 'hook_blocked',
982
+ toolCall,
983
+ reason: doomOutcome.reason,
984
+ output: {
985
+ type: 'function_call_output',
986
+ id: `output_${toolCall.id}`,
987
+ callId: toolCall.id,
988
+ output: JSON.stringify({
989
+ error: doomOutcome.reason,
990
+ }),
991
+ },
992
+ };
993
+ }
722
994
  let effectiveToolCall = toolCall;
723
995
  // Emit PreToolUse hook -- can block or mutate input.
724
996
  if (this.hooksManager) {
@@ -764,13 +1036,18 @@ export class ModelResult {
764
1036
  // performance.now() gives monotonic, sub-ms precision and is immune to
765
1037
  // system clock jumps, unlike Date.now().
766
1038
  const startTime = performance.now();
767
- const result = await executeTool(tool, effectiveToolCall, turnContext, onPreliminaryResult, this.contextStore ?? undefined, this.options.sharedContextSchema);
1039
+ const result = await executeTool(tool, effectiveToolCall, turnContext, onPreliminaryResult, this.contextStore ?? undefined, this.options.sharedContextSchema, extras);
768
1040
  const durationMs = performance.now() - startTime;
769
1041
  // HITL tools may pause (executeTool returns null). No output was produced
770
1042
  // yet, so neither PostToolUse nor PostToolUseFailure fires; they will fire
771
1043
  // if/when the tool is resumed and actually executes.
1044
+ // Async invocations (background/deferred) haven't produced their final
1045
+ // output either — Post hooks fire at settlement via
1046
+ // emitAsyncSettledHooks (in-process delivery). Results delivered by
1047
+ // resumeToolResults in ANOTHER process have no hooksManager there;
1048
+ // that path stays uncovered and is documented on resumeToolResults.
772
1049
  // Emit PostToolUse or PostToolUseFailure.
773
- if (this.hooksManager && result !== null) {
1050
+ if (this.hooksManager && result !== null && !isAsyncToolInvocation(result)) {
774
1051
  if (result.error) {
775
1052
  await this.hooksManager.emit('PostToolUseFailure', {
776
1053
  toolName: effectiveToolCall.name,
@@ -793,6 +1070,557 @@ export class ModelResult {
793
1070
  result,
794
1071
  };
795
1072
  }
1073
+ /**
1074
+ * Advance the doom-loop round counter and declare the round's calls. Called
1075
+ * at every execution-batch boundary (main tool round, auto-approve batch
1076
+ * while pausing, approved-on-resume batch). Identical calls WITHIN one round
1077
+ * are duplicates — one piece of loop evidence, one shared decision.
1078
+ *
1079
+ * `batch` is every call the round will make. A round's identity for one tool
1080
+ * is the *set* of fingerprints it was called with, and that set has to be
1081
+ * complete before any of the round's calls is scored: accumulating it call
1082
+ * by call made a round that is a superset of the previous one transiently
1083
+ * match it, blocking calls that represented real progress, with the outcome
1084
+ * depending on emission order. Declaration is best-effort — a call whose key
1085
+ * material is exempt or unhashable is simply left out, and the per-call
1086
+ * fallback chain in `enqueueDoomLoopEvaluation` still governs identity at
1087
+ * record time.
1088
+ *
1089
+ * Only calls that the round will actually CHECK are declared. A `loopKey`
1090
+ * function is user code that may count or log, so it must not run for a call
1091
+ * the detector never evaluates: manual tool calls (no `execute`, no
1092
+ * `onToolCalled`) are handed to the caller and never recorded, and every
1093
+ * execution path skips them with the same `isAutoResolvableTool` predicate
1094
+ * used here. Their absence from the declared set is also correct on its own
1095
+ * terms — they are not evidence, so they are not part of the round.
1096
+ *
1097
+ * INVARIANT — every declared member must eventually be recorded. A call
1098
+ * that is declared but never reaches the doom-loop checkpoint is a phantom
1099
+ * member of the round's identity: the tool's streak silently resets the
1100
+ * moment the phantom stops being emitted (pinned by the "declared-but-
1101
+ * never-recorded member" test in doom-loop-fanout.test.ts). The filters
1102
+ * below therefore MIRROR every path that skips recording — unknown/manual
1103
+ * tools (`isAutoResolvableTool`, also gating executeAutoApproveTools and
1104
+ * the approval-resume loop), `hookDeniedCalls` (consumed before the
1105
+ * checkpoint in executeSingleToolCall), and `loopKey`-exempt calls (early
1106
+ * return in checkDoomLoopBeforeExecution; the same resolution is cached
1107
+ * here so both sides agree). If you add a new short-circuit between
1108
+ * declaration and the checkpoint — a pre-execution gate, a batch filter —
1109
+ * it MUST be reflected here, or fan-out detection degrades silently for
1110
+ * that tool rather than failing loudly.
1111
+ *
1112
+ * KNOWN RESIDUAL (documented, not mirrored): a per-request timeout or
1113
+ * abort can cancel a call AFTER declaration but BEFORE its checkpoint,
1114
+ * leaving a declared phantom for that round. Bounded and fail-safe: the
1115
+ * round-set streak for that tool resets when the phantom stops recurring,
1116
+ * while per-call streaks are unaffected — detection degrades to per-call
1117
+ * for the affected tool rather than being lost. Un-declaring mid-round
1118
+ * would mutate the round's identity while it is being scored, which is the
1119
+ * incremental-set order-dependence this design exists to prevent.
1120
+ */
1121
+ async beginDoomLoopRound(batch = []) {
1122
+ const monitor = this.doomLoopMonitor;
1123
+ if (!monitor) {
1124
+ return;
1125
+ }
1126
+ this.doomLoopRound++;
1127
+ this.doomLoopRoundDecisions.clear();
1128
+ this.doomLoopRoundKeyMaterial.clear();
1129
+ const declared = [];
1130
+ for (const toolCall of batch) {
1131
+ const tool = this.options.tools?.find((t) => isClientTool(t) && t.function.name === toolCall.name);
1132
+ /*
1133
+ * Never checked => never recorded => must not be declared. Declaring a
1134
+ * call that never arrives inflates the round's identity: a sibling that
1135
+ * IS recorded gets scored against a set containing a phantom member, and
1136
+ * the streak resets spuriously once the phantom stops appearing (e.g. the
1137
+ * model drops a malformed call while still repeating the valid one).
1138
+ * Keeping `loopKey` from running for such a call is the same check.
1139
+ *
1140
+ * This gate deliberately precedes the malformed-arguments branch below:
1141
+ * a raw-string call to an unknown or manual tool is still never recorded.
1142
+ */
1143
+ if (tool === undefined || !isAutoResolvableTool(tool)) {
1144
+ continue;
1145
+ }
1146
+ /*
1147
+ * Same reasoning for a call the PermissionRequest hook denied without
1148
+ * pausing: `hookDeniedCalls` is populated before the round begins, and
1149
+ * `runToolWithHooks` synthesizes the rejection before reaching the
1150
+ * doom-loop checkpoint, so the call is never recorded either.
1151
+ */
1152
+ if (this.hookDeniedCalls.has(toolCall.id)) {
1153
+ continue;
1154
+ }
1155
+ const rawArgs = toolCall.arguments;
1156
+ if (typeof rawArgs === 'string') {
1157
+ // Malformed call: its identity is the raw string (see runToolWithHooks).
1158
+ declared.push({
1159
+ toolName: String(toolCall.name),
1160
+ keyMaterial: rawArgs,
1161
+ });
1162
+ continue;
1163
+ }
1164
+ /*
1165
+ * `resolveLoopKeyMaterial` catches a throwing `loopKey`, but not every
1166
+ * throw: the field-list form reads `args[field]`, so a getter on the
1167
+ * arguments object throws out of it uncaught. Declaration runs once for
1168
+ * the whole batch, so letting that escape would fail the entire round —
1169
+ * and the run — over one odd call, breaking the invariant that detection
1170
+ * never affects a run except through its ladder actions. Skip just that
1171
+ * call; the per-call checkpoint hits the same throw inside its own
1172
+ * try/catch and applies the documented fallback chain there.
1173
+ */
1174
+ let resolution;
1175
+ try {
1176
+ resolution = resolveLoopKeyMaterial(isClientTool(tool) ? tool.function.loopKey : undefined, (toolCall.arguments ?? {}));
1177
+ }
1178
+ catch (error) {
1179
+ console.warn(`[DoomLoop] could not resolve loop identity for "${toolCall.name}" while declaring the round; ` +
1180
+ 'excluding it from the round set:', error);
1181
+ continue;
1182
+ }
1183
+ /*
1184
+ * Cache so the per-call checkpoint does not invoke `loopKey` a second
1185
+ * time; keyed by call id. Ids are MODEL-emitted strings and nothing
1186
+ * upstream enforces uniqueness, so a duplicate id must not alias one
1187
+ * call's identity onto another: overwriting here meant the first call's
1188
+ * checkpoint read the second call's key material, its true fingerprint
1189
+ * was never recorded, and a model emitting `(id=X, read a), (id=X,
1190
+ * read b_i)` each round evaded the per-call detector for `a` entirely
1191
+ * (measured: zero detections across four such rounds). On a duplicate
1192
+ * id the cache poisons that id instead: both calls fall through to
1193
+ * per-call resolution at the checkpoint, costing at most a duplicate
1194
+ * `loopKey` invocation for the colliding calls — correctness over the
1195
+ * single-invocation economy, for protocol-malformed input only.
1196
+ */
1197
+ if (this.doomLoopRoundKeyMaterial.has(toolCall.id)) {
1198
+ this.doomLoopRoundKeyMaterial.set(toolCall.id, DUPLICATE_CALL_ID);
1199
+ }
1200
+ else {
1201
+ this.doomLoopRoundKeyMaterial.set(toolCall.id, resolution);
1202
+ }
1203
+ if (resolution.kind === 'exempt') {
1204
+ continue;
1205
+ }
1206
+ declared.push({
1207
+ toolName: String(toolCall.name),
1208
+ keyMaterial: resolution.keyMaterial,
1209
+ });
1210
+ }
1211
+ await monitor.declareRound(this.doomLoopRound, declared);
1212
+ }
1213
+ /**
1214
+ * Evaluate one call against the doom-loop monitor, serialized on
1215
+ * `doomLoopChain` so parallel executions record in the deterministic
1216
+ * order their evaluations were ENQUEUED (the `.map()` over a round's
1217
+ * calls runs synchronously to its first await, i.e. model-emission
1218
+ * order) — not in hash-completion order.
1219
+ *
1220
+ * Applies the fallback invariant: detection must never take down or
1221
+ * alter a run except via its defined actions. Unhashable key material
1222
+ * (bigint, non-finite numbers, circular/too-deep structures from a
1223
+ * computed loopKey) degrades to `fallbackKeyMaterial`; if THAT is also
1224
+ * unhashable (parse-error path passes null), detection is skipped for
1225
+ * the call and it proceeds normally.
1226
+ *
1227
+ * Returns the final decision for this call (`action: 'proceed'` when no
1228
+ * rung fired or the call is exempt/skipped; `message` carries the
1229
+ * verdict's explanation for block outputs). Side effects (steer queue,
1230
+ * stop arm) are applied here so every caller shares them.
1231
+ */
1232
+ enqueueDoomLoopEvaluation(evaluation) {
1233
+ const run = async () => {
1234
+ const monitor = this.doomLoopMonitor;
1235
+ if (!monitor) {
1236
+ return {
1237
+ action: 'proceed',
1238
+ };
1239
+ }
1240
+ let record;
1241
+ try {
1242
+ record = await monitor.recordToolCall(evaluation.toolName, evaluation.keyMaterial, this.doomLoopRound, {
1243
+ allowBlock: evaluation.allowBlock,
1244
+ detector: evaluation.detector,
1245
+ });
1246
+ }
1247
+ catch (error) {
1248
+ // Unhashable key material (bigint/NaN/circular/too-deep loopKey
1249
+ // output). Fall back to the raw-arguments identity; if that also
1250
+ // fails, skip detection for this call — never fail the call itself.
1251
+ console.warn(`[DoomLoop] could not fingerprint call to "${evaluation.toolName}"; ` +
1252
+ `${evaluation.fallbackKeyMaterial !== null ? 'falling back to full arguments' : 'skipping detection for this call'}:`, error);
1253
+ if (evaluation.fallbackKeyMaterial === null) {
1254
+ return {
1255
+ action: 'proceed',
1256
+ };
1257
+ }
1258
+ try {
1259
+ record = await monitor.recordToolCall(evaluation.toolName, evaluation.fallbackKeyMaterial, this.doomLoopRound, {
1260
+ allowBlock: evaluation.allowBlock,
1261
+ detector: evaluation.detector,
1262
+ });
1263
+ }
1264
+ catch (fallbackError) {
1265
+ console.warn(`[DoomLoop] fallback identity for "${evaluation.toolName}" also unhashable; skipping detection for this call:`, fallbackError);
1266
+ return {
1267
+ action: 'proceed',
1268
+ };
1269
+ }
1270
+ }
1271
+ // Same (tool, fingerprint) already decided this round: reuse the
1272
+ // decision — no second streak increment, no second hook emission.
1273
+ const decisionKey = `${evaluation.toolName}\n${record.fingerprint}`;
1274
+ if (record.duplicateInRound) {
1275
+ return (this.doomLoopRoundDecisions.get(decisionKey) ?? {
1276
+ action: 'proceed',
1277
+ });
1278
+ }
1279
+ if (!record.verdict) {
1280
+ const decision = {
1281
+ action: 'proceed',
1282
+ };
1283
+ this.doomLoopRoundDecisions.set(decisionKey, decision);
1284
+ return decision;
1285
+ }
1286
+ const action = await this.applyDoomLoopVerdict(record.verdict, evaluation.toolCall);
1287
+ const decision = {
1288
+ action,
1289
+ message: record.verdict.message,
1290
+ };
1291
+ this.doomLoopRoundDecisions.set(decisionKey, decision);
1292
+ this.applyDoomLoopSideEffects(action, record.verdict);
1293
+ return decision;
1294
+ };
1295
+ // Serialize on the chain; keep the chain alive even if an evaluation
1296
+ // throws unexpectedly (it should not — run() catches everything).
1297
+ const evaluationPromise = this.doomLoopChain.then(run, run);
1298
+ this.doomLoopChain = evaluationPromise.catch(() => undefined);
1299
+ return evaluationPromise;
1300
+ }
1301
+ /**
1302
+ * Doom-loop checkpoint for one tool call, run before PreToolUse/execution.
1303
+ *
1304
+ * Resolves the tool's `loopKey` declaration (function |
1305
+ * false | absent — see {@link resolveLoopKeyMaterial}) and evaluates the
1306
+ * call against the monitor. Ladder actions:
1307
+ *
1308
+ * - `observe` — hook already emitted, nothing else.
1309
+ * - `steer` — queue a corrective user message for the next model turn.
1310
+ * - `block` — return `blocked` so the caller synthesizes an error output
1311
+ * without executing. The model sees the explanation in the tool result.
1312
+ * - `stop` — arm `doomLoopStop`; ALSO blocks this call (executing a
1313
+ * call the detector just condemned, then stopping, would be
1314
+ * incoherent). The loop halts before the next model request.
1315
+ */
1316
+ async checkDoomLoopBeforeExecution(tool, toolCall) {
1317
+ if (!this.doomLoopMonitor) {
1318
+ return {
1319
+ blocked: false,
1320
+ };
1321
+ }
1322
+ // The parse-error guard in runToolWithHooks already rejected raw-string
1323
+ // arguments, so what remains is the parsed record (or null/undefined for
1324
+ // no-args calls — coerced to {} the same way the PreToolUse payload is).
1325
+ const callArguments = (toolCall.arguments ?? {});
1326
+ /*
1327
+ * Reuse what `beginDoomLoopRound` resolved for this call when it declared
1328
+ * the round. `loopKey` is user code — it may count, log, or return a fresh
1329
+ * value each time — so it must run at most once per call. Resolving here
1330
+ * as well would double-invoke it and, for a non-repeatable callback, make
1331
+ * the declared identity and the recorded identity disagree.
1332
+ */
1333
+ const cached = this.doomLoopRoundKeyMaterial.get(toolCall.id);
1334
+ let resolution;
1335
+ if (cached !== undefined && cached !== DUPLICATE_CALL_ID) {
1336
+ resolution = cached;
1337
+ }
1338
+ else {
1339
+ /*
1340
+ * Not declared (undeclared round, or the declaration skipped this call).
1341
+ * `resolveLoopKeyMaterial` can throw despite catching `loopKey` itself —
1342
+ * the field-list form reads `args[field]`, so a getter on the arguments
1343
+ * throws out of it. Detection must never reject a call or fail a run
1344
+ * except through a ladder action, so skip detection for this one call.
1345
+ */
1346
+ try {
1347
+ resolution = resolveLoopKeyMaterial(isClientTool(tool) ? tool.function.loopKey : undefined, callArguments);
1348
+ }
1349
+ catch (error) {
1350
+ console.warn(`[DoomLoop] could not resolve loop identity for "${toolCall.name}"; ` +
1351
+ 'skipping detection for this call:', error);
1352
+ return {
1353
+ blocked: false,
1354
+ };
1355
+ }
1356
+ }
1357
+ if (resolution.kind === 'exempt') {
1358
+ return {
1359
+ blocked: false,
1360
+ };
1361
+ }
1362
+ if (resolution.kind === 'fallback') {
1363
+ console.warn(`[DoomLoop] tool "${toolCall.name}": ${resolution.warning}`);
1364
+ }
1365
+ const decision = await this.enqueueDoomLoopEvaluation({
1366
+ toolName: String(toolCall.name),
1367
+ keyMaterial: resolution.keyMaterial,
1368
+ fallbackKeyMaterial: resolution.keyMaterial === callArguments ? null : callArguments,
1369
+ allowBlock: true,
1370
+ detector: 'tool-fingerprint',
1371
+ toolCall,
1372
+ });
1373
+ if (decision.action === 'block' || decision.action === 'stop') {
1374
+ return {
1375
+ blocked: true,
1376
+ reason: decision.message ??
1377
+ `Doom loop suspected: tool "${toolCall.name}" is repeating identical calls. ` +
1378
+ 'Repeating the call will not change the result. Take a different approach.',
1379
+ };
1380
+ }
1381
+ return {
1382
+ blocked: false,
1383
+ };
1384
+ }
1385
+ /**
1386
+ * Emit the DoomLoopDetected hook for a verdict and resolve the final
1387
+ * action. Handlers may override per event (`overrideAction`), letting
1388
+ * userland escalate or de-escalate without reconfiguring the ladder;
1389
+ * the LAST override in the chain wins (matching PermissionRequest's
1390
+ * last-wins convention). Non-blockable verdicts (text, server tools —
1391
+ * the tokens are already emitted / the tool already ran) downgrade a
1392
+ * 'block' override to 'observe'. An 'escalate' override is honored only
1393
+ * when an escalation mechanism is configured with remaining budget;
1394
+ * otherwise it downgrades to 'observe' (never silently to a stronger
1395
+ * action).
1396
+ */
1397
+ async applyDoomLoopVerdict(verdict, toolCall) {
1398
+ let action = verdict.action;
1399
+ if (this.hooksManager) {
1400
+ const emit = await this.hooksManager.emit('DoomLoopDetected', {
1401
+ detector: verdict.detector,
1402
+ action: verdict.action,
1403
+ streak: verdict.streak,
1404
+ fingerprint: verdict.fingerprint,
1405
+ ...(verdict.toolName !== undefined && {
1406
+ toolName: verdict.toolName,
1407
+ }),
1408
+ ...(toolCall !== undefined &&
1409
+ isRecord(toolCall.arguments) && {
1410
+ toolInput: toolCall.arguments,
1411
+ }),
1412
+ message: verdict.message,
1413
+ }, this.hookEmitContext(verdict.toolName));
1414
+ for (const result of emit.results) {
1415
+ if (result?.overrideAction) {
1416
+ action = result.overrideAction;
1417
+ }
1418
+ }
1419
+ }
1420
+ if (verdict.detector !== 'tool-fingerprint' && action === 'block') {
1421
+ // Nothing to block for text/server-tool verdicts; observe instead of
1422
+ // silently acting stronger/weaker than the handler intended.
1423
+ action = 'observe';
1424
+ }
1425
+ if (action === 'escalate' && !(this.doomLoopMonitor?.canEscalate() ?? false)) {
1426
+ // Ladder verdicts are already budget-gated by the monitor; this
1427
+ // catches hook overrides requesting escalation without config/budget.
1428
+ action = 'observe';
1429
+ }
1430
+ return action;
1431
+ }
1432
+ /**
1433
+ * Apply the side effects of a resolved doom-loop action shared by every
1434
+ * checkpoint: queue steer guidance, arm an escalation for the next
1435
+ * request, or arm the stop verdict. 'block' has no shared side effect —
1436
+ * the calling checkpoint synthesizes the blocked output itself.
1437
+ */
1438
+ applyDoomLoopSideEffects(action, verdict) {
1439
+ if (action === 'steer') {
1440
+ this.queueDoomLoopSteer(verdict.message);
1441
+ }
1442
+ else if (action === 'escalate') {
1443
+ // Latch the FIRST escalation verdict for the next request; a second
1444
+ // verdict in the same window (e.g. tool + text detectors both firing)
1445
+ // must not double-spend the budget.
1446
+ if (this.pendingDoomLoopEscalation === null) {
1447
+ this.pendingDoomLoopEscalation = verdict;
1448
+ // The model should know why its next turn looks different. Rides
1449
+ // the persisted steer path so the transcript stays well-formed.
1450
+ this.queueDoomLoopSteer(`${verdict.message} An escalated turn follows: use the additional guidance to change course.`);
1451
+ }
1452
+ }
1453
+ else if (action === 'stop') {
1454
+ this.doomLoopStop = verdict;
1455
+ }
1456
+ }
1457
+ /**
1458
+ * Consume a pending escalation into ONE-TURN request overrides:
1459
+ *
1460
+ * - `model` — replace the request's model for this dispatch only (the
1461
+ * base `resolvedRequest` is never mutated, so the following turn
1462
+ * reverts automatically).
1463
+ * - `advisor` — append an `openrouter:advisor` server tool (transcript
1464
+ * forwarded, instructions describing the detected loop) and pin
1465
+ * `toolChoice` to it via `allowed_tools`/`required`, so the stuck model
1466
+ * must consult the advisor before doing anything else this turn.
1467
+ *
1468
+ * Burns one unit of the escalation budget (persisted via the monitor) at
1469
+ * APPLICATION time — verdicts the engine never got to apply (run stopped,
1470
+ * paused, or overridden) do not spend.
1471
+ */
1472
+ takeDoomLoopEscalationOverrides() {
1473
+ const verdict = this.pendingDoomLoopEscalation;
1474
+ const config = this.doomLoopEscalation;
1475
+ if (!verdict || !config || !this.doomLoopMonitor?.canEscalate()) {
1476
+ this.pendingDoomLoopEscalation = null;
1477
+ return null;
1478
+ }
1479
+ this.pendingDoomLoopEscalation = null;
1480
+ this.doomLoopMonitor.consumeEscalation();
1481
+ const overrides = {};
1482
+ if (config.model !== undefined) {
1483
+ overrides['model'] = config.model;
1484
+ // A single-model override must not be shadowed by a fallback list.
1485
+ overrides['models'] = undefined;
1486
+ }
1487
+ if (config.advisor !== undefined && config.advisor !== false) {
1488
+ const advisorParameters = {
1489
+ forwardTranscript: true,
1490
+ instructions: 'You are an escalation advisor. The executing model appears stuck in a loop: ' +
1491
+ `${verdict.message} Diagnose why its approach is failing and give concrete, ` +
1492
+ 'specific instructions for a DIFFERENT approach. Do not restate the problem.',
1493
+ ...(typeof config.advisor === 'object' ? config.advisor : {}),
1494
+ };
1495
+ const existingTools = Array.isArray(this.resolvedRequest?.['tools'])
1496
+ ? this.resolvedRequest['tools']
1497
+ : [];
1498
+ overrides['tools'] = [
1499
+ ...existingTools,
1500
+ {
1501
+ type: 'openrouter:advisor',
1502
+ parameters: advisorParameters,
1503
+ },
1504
+ ];
1505
+ // Force the consult: constrain this turn's tool surface to the
1506
+ // advisor and require a call.
1507
+ overrides['toolChoice'] = {
1508
+ type: 'allowed_tools',
1509
+ mode: 'required',
1510
+ tools: [
1511
+ {
1512
+ type: 'openrouter:advisor',
1513
+ },
1514
+ ],
1515
+ };
1516
+ }
1517
+ return overrides;
1518
+ }
1519
+ /**
1520
+ * Step-level doom-loop checkpoint on a fresh model response:
1521
+ *
1522
+ * 1. Text detectors — within-response token repetition + cross-step
1523
+ * identical-text streak.
1524
+ * 2. Server-tool fingerprints — server tools (web_search_call etc.) never
1525
+ * pass through runToolWithHooks; their repetition is detected here,
1526
+ * post-execution, from the echoed call fields on the output item.
1527
+ * Post-execution ⇒ block is meaningless (allowBlock: false); the
1528
+ * actions are observe/steer/stop.
1529
+ *
1530
+ * Runs where the loop already inspects each fresh response, so scripted
1531
+ * transcripts exercise it deterministically.
1532
+ */
1533
+ async checkDoomLoopForResponse(response) {
1534
+ if (!this.doomLoopMonitor) {
1535
+ return;
1536
+ }
1537
+ let verdict;
1538
+ try {
1539
+ verdict = await this.doomLoopMonitor.recordAssistantText(extractTextFromResponse(response));
1540
+ }
1541
+ catch (error) {
1542
+ console.warn('[DoomLoop] text checkpoint failed; skipping for this response:', error);
1543
+ verdict = undefined;
1544
+ }
1545
+ if (verdict) {
1546
+ const action = await this.applyDoomLoopVerdict(verdict);
1547
+ this.applyDoomLoopSideEffects(action, verdict);
1548
+ }
1549
+ // Server-tool repetition. The response's server-tool output items echo
1550
+ // their call identity (action/query fields, item type as the tool name).
1551
+ for (const item of response.output) {
1552
+ if (!hasTypeProperty(item) || !isServerToolResultItem(item)) {
1553
+ continue;
1554
+ }
1555
+ const identity = extractServerToolIdentity(item);
1556
+ if (identity === null) {
1557
+ continue;
1558
+ }
1559
+ await this.enqueueDoomLoopEvaluation({
1560
+ toolName: `server:${item.type}`,
1561
+ keyMaterial: identity,
1562
+ fallbackKeyMaterial: null,
1563
+ allowBlock: false,
1564
+ detector: 'server-tool-fingerprint',
1565
+ });
1566
+ }
1567
+ }
1568
+ /**
1569
+ * Queue steer guidance, deduplicating identical messages (one verdict can
1570
+ * repeat across calls in a round; the model needs the guidance once).
1571
+ */
1572
+ queueDoomLoopSteer(message) {
1573
+ if (!this.pendingDoomLoopSteer.includes(message)) {
1574
+ this.pendingDoomLoopSteer.push(message);
1575
+ }
1576
+ }
1577
+ /**
1578
+ * Inject any queued doom-loop steer messages as a user message before the
1579
+ * next model turn. Reuses the Stop-hook appendPrompt injection path, so
1580
+ * state/messages advance observably. Called in-loop before each follow-up
1581
+ * request AND before every pause-persist, so guidance queued right before
1582
+ * a pause lands in the persisted conversation instead of being dropped.
1583
+ */
1584
+ async flushDoomLoopSteer() {
1585
+ if (this.pendingDoomLoopSteer.length === 0) {
1586
+ return;
1587
+ }
1588
+ const prompt = this.pendingDoomLoopSteer.join('\n');
1589
+ this.pendingDoomLoopSteer = [];
1590
+ await this.injectAppendPromptMessage(prompt);
1591
+ }
1592
+ /**
1593
+ * Halt the run for an armed doom-loop stop verdict, keeping the persisted
1594
+ * history well-formed: every executable `function_call` in the current
1595
+ * response that has no output yet gets a synthesized halt-error output
1596
+ * (persisted via the normal tool-result path), so a stateful resume never
1597
+ * sends a dangling `function_call` (providers reject those with a 400).
1598
+ *
1599
+ * Used by every stop site: the pre-round break, the post-round break, the
1600
+ * no-tools early return, the allow-final-response gate, and the approval-
1601
+ * resume gate.
1602
+ *
1603
+ * @param resolvedCallIds - call ids that already have outputs this round.
1604
+ */
1605
+ async sealDoomLoopStop(currentResponse, resolvedCallIds) {
1606
+ const verdict = this.doomLoopStop;
1607
+ const toolCalls = extractToolCallsFromResponse(currentResponse);
1608
+ const unresolved = toolCalls.filter((tc) => !resolvedCallIds?.has(tc.id));
1609
+ if (unresolved.length > 0) {
1610
+ const haltMessage = verdict
1611
+ ? `Run halted by doom-loop detection: ${verdict.message}`
1612
+ : 'Run halted by doom-loop detection.';
1613
+ const haltOutputs = unresolved.map((tc) => ({
1614
+ type: 'function_call_output',
1615
+ id: `output_${tc.id}`,
1616
+ callId: tc.id,
1617
+ output: JSON.stringify({
1618
+ error: haltMessage,
1619
+ }),
1620
+ }));
1621
+ await this.saveToolResultsToState(haltOutputs);
1622
+ }
1623
+ }
796
1624
  /**
797
1625
  * Build the per-emit context for lifecycle hook emits. Threads this run's
798
1626
  * session identity into `context.sessionId` on every emit, so a
@@ -809,6 +1637,40 @@ export class ModelResult {
809
1637
  sessionId: this.currentState?.id ?? '',
810
1638
  };
811
1639
  }
1640
+ /**
1641
+ * Emit PostToolUse / PostToolUseFailure for an async task at settlement —
1642
+ * the point its final output actually exists. Round-synchronous tools
1643
+ * emit these in runToolWithHooks; async invocations skip that emission
1644
+ * (no output yet) and land here instead. `durationMs` is the task's
1645
+ * start-to-settle wall clock (includes background-pool queue wait, which
1646
+ * counts against the task's own timeout budget). Observation-only — a
1647
+ * throwing hook must not break delivery.
1648
+ */
1649
+ async emitAsyncSettledHooks(task) {
1650
+ if (!this.hooksManager) {
1651
+ return;
1652
+ }
1653
+ try {
1654
+ if (task.status === 'completed') {
1655
+ await this.hooksManager.emit('PostToolUse', {
1656
+ toolName: task.name,
1657
+ toolInput: task.input ?? {},
1658
+ toolOutput: task.result,
1659
+ durationMs: task.durationMs,
1660
+ }, this.hookEmitContext(task.name));
1661
+ }
1662
+ else {
1663
+ await this.hooksManager.emit('PostToolUseFailure', {
1664
+ toolName: task.name,
1665
+ toolInput: task.input ?? {},
1666
+ error: new Error(task.error ?? `Task ${task.taskId} ${task.status}`),
1667
+ }, this.hookEmitContext(task.name));
1668
+ }
1669
+ }
1670
+ catch (error) {
1671
+ console.warn('[AsyncTools] PostToolUse hook threw during async settlement:', error);
1672
+ }
1673
+ }
812
1674
  /**
813
1675
  * Emit SessionEnd exactly once, and only when a matching SessionStart
814
1676
  * actually succeeded. Safe to call from multiple teardown paths.
@@ -821,28 +1683,41 @@ export class ModelResult {
821
1683
  await this.hooksManager.emit('SessionEnd', {
822
1684
  reason,
823
1685
  ...(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
- },
1686
+ totalUsage: this.snapshotSessionUsage(),
835
1687
  }),
836
1688
  }, this.hookEmitContext());
837
1689
  }
1690
+ /**
1691
+ * Materialize the running session aggregate as an immutable
1692
+ * `SessionUsageTotals`. `cost` is present only when at least one response
1693
+ * reported it, matching the hook payload's optionality (a summed cost of
1694
+ * `0` from cost-less responses would read as "free" rather than "unknown").
1695
+ *
1696
+ * Single source of truth for both `SessionEnd.totalUsage` and the public
1697
+ * `getUsage()` accessor, so the two can never drift.
1698
+ */
1699
+ snapshotSessionUsage() {
1700
+ return {
1701
+ modelCalls: this.sessionUsage.modelCalls,
1702
+ inputTokens: this.sessionUsage.inputTokens,
1703
+ outputTokens: this.sessionUsage.outputTokens,
1704
+ totalTokens: this.sessionUsage.totalTokens,
1705
+ cachedTokens: this.sessionUsage.cachedTokens,
1706
+ reasoningTokens: this.sessionUsage.reasoningTokens,
1707
+ ...(this.sessionUsage.hasCost && {
1708
+ cost: this.sessionUsage.cost,
1709
+ }),
1710
+ };
1711
+ }
838
1712
  /**
839
1713
  * Emit PostModelCall for a completed model response and fold its usage
840
1714
  * into the session aggregate. One emit per materialized response.
1715
+ *
1716
+ * Accumulation runs unconditionally — before the hooks short-circuit —
1717
+ * because `getUsage()` surfaces these totals to callers who configured no
1718
+ * hooks at all. Only the hook emit is gated on a `HooksManager`.
841
1719
  */
842
1720
  async emitPostModelCall(response, startedAt, turnType, turnNumber) {
843
- if (!this.hooksManager) {
844
- return;
845
- }
846
1721
  const usage = extractModelCallUsage(response.usage);
847
1722
  this.sessionUsage.modelCalls++;
848
1723
  if (usage) {
@@ -856,6 +1731,9 @@ export class ModelResult {
856
1731
  this.sessionUsage.hasCost = true;
857
1732
  }
858
1733
  }
1734
+ if (!this.hooksManager) {
1735
+ return;
1736
+ }
859
1737
  await this.hooksManager.emit('PostModelCall', {
860
1738
  sessionId: this.currentState?.id ?? '',
861
1739
  responseId: response.id,
@@ -927,36 +1805,56 @@ export class ModelResult {
927
1805
  }
928
1806
  /**
929
1807
  * 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.
1808
+ * executeToolsIfNeeded (the normal SessionEnd site). Materializes the
1809
+ * parked model-call telemetry, emits SessionEnd once, and drains pending
1810
+ * hook work. Never throws: teardown must not mask the stream's own outcome.
933
1811
  */
934
1812
  async finishHooksSessionForStream(reason = 'complete') {
935
- if (!this.hooksManager) {
936
- return;
937
- }
1813
+ // Materialize the parked initial-call telemetry when the stream fully
1814
+ // completed (the retained buffer replays without touching the source).
1815
+ // A failed/errored stream skips it: no materialized response exists.
1816
+ // (`response.incomplete` responses DO emit — they are materialized, have
1817
+ // a generation id, and consumed tokens.)
1818
+ // Runs even without a HooksManager: this is the only site that folds the
1819
+ // no-tools streaming response into `sessionUsage`, which `getUsage()`
1820
+ // reports to hook-less callers.
1821
+ // Isolated try: a telemetry failure (e.g. a buffer without a completion
1822
+ // event, or a throwing strict-mode handler) must not skip SessionEnd or
1823
+ // the drain below — those are contractual on every exit path.
938
1824
  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));
1825
+ if (this.pendingModelCall) {
1826
+ if (this.finalResponse) {
1827
+ await this.emitPendingModelCallOnce(this.finalResponse);
1828
+ }
1829
+ else if (this.reusableStream?.isComplete) {
1830
+ // Sync backward scan of the retained buffer — not a consumer
1831
+ // replay, which would cost one microtask hop per buffered event
1832
+ // on every hook-less streaming teardown.
1833
+ await this.emitPendingModelCallOnce(extractCompletionFromBuffer(this.reusableStream));
1834
+ }
1835
+ else if (this.reusableStream) {
1836
+ // Consumers stop at the terminal event (streamTerminationEvents),
1837
+ // usually before the pump reads the source close that flips
1838
+ // `isComplete` — with a real network stream the close frame
1839
+ // arrives after `response.completed`. The terminal event is
1840
+ // already buffered in that window, so recover it rather than
1841
+ // dropping the parked telemetry. Stays silent (no emit, no
1842
+ // throw) when nothing terminal was buffered — e.g. an errored
1843
+ // mid-flight stream, where no materialized response exists.
1844
+ const buffered = tryExtractCompletionFromBuffer(this.reusableStream);
1845
+ if (buffered) {
1846
+ await this.emitPendingModelCallOnce(buffered);
954
1847
  }
955
1848
  }
956
1849
  }
957
- catch (telemetryError) {
958
- console.warn('[PostModelCall] error during stream teardown:', telemetryError);
959
- }
1850
+ }
1851
+ catch (telemetryError) {
1852
+ console.warn('[PostModelCall] error during stream teardown:', telemetryError);
1853
+ }
1854
+ if (!this.hooksManager) {
1855
+ return;
1856
+ }
1857
+ try {
960
1858
  await this.emitSessionEndOnce(reason);
961
1859
  await this.hooksManager.drain();
962
1860
  }
@@ -1090,13 +1988,75 @@ export class ModelResult {
1090
1988
  };
1091
1989
  }
1092
1990
  /**
1093
- * Execute tools that can auto-execute (don't require approval) in parallel.
1991
+ * Settle an async invocation on the approval/resume path, where results
1992
+ * are accumulated as `UnsentToolResult`s rather than round outputs.
1094
1993
  *
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
1994
+ * - background: degrade to synchronous execution — the approval flow is
1995
+ * already a discrete resume step, so there is no round to unblock; the
1996
+ * work is awaited and its (validated) value becomes the unsent result.
1997
+ * - defer: the external task HAS been started by `start()`; record the
1998
+ * pending placeholder as the unsent result and track the task on
1999
+ * `pendingAsyncTools` so `resumeToolResults` can deliver the real
2000
+ * result later as an envelope.
1098
2001
  */
1099
- async executeAutoApproveTools(toolCalls, turnContext) {
2002
+ async settleAsyncInvocationAsUnsent(tc, invocation) {
2003
+ if (invocation.asyncMode === 'background') {
2004
+ try {
2005
+ const result = await invocation.run();
2006
+ return createUnsentResult(tc.id, String(tc.name), result);
2007
+ }
2008
+ catch (error) {
2009
+ return createRejectedResult(tc.id, String(tc.name), error instanceof Error ? error.message : String(error));
2010
+ }
2011
+ }
2012
+ const registry = this.ensureAsyncToolRegistry();
2013
+ const liveTask = registry.trackDeferred({
2014
+ callId: tc.id,
2015
+ taskId: invocation.taskId,
2016
+ name: String(tc.name),
2017
+ ...(invocation.pollAfterMs !== undefined && {
2018
+ pollAfterMs: invocation.pollAfterMs,
2019
+ }),
2020
+ ...(invocation.expiresAt !== undefined && {
2021
+ expiresAt: invocation.expiresAt,
2022
+ }),
2023
+ });
2024
+ const task = {
2025
+ callId: tc.id,
2026
+ taskId: invocation.taskId,
2027
+ name: String(tc.name),
2028
+ mode: 'defer',
2029
+ status: 'working',
2030
+ startedAt: liveTask.startedAt,
2031
+ ...(invocation.pollAfterMs !== undefined && {
2032
+ pollAfterMs: invocation.pollAfterMs,
2033
+ }),
2034
+ ...(invocation.expiresAt !== undefined && {
2035
+ expiresAt: invocation.expiresAt,
2036
+ }),
2037
+ };
2038
+ if (this.currentState) {
2039
+ this.currentState = updateState(this.currentState, {
2040
+ pendingAsyncTools: [
2041
+ ...(this.currentState.pendingAsyncTools ?? []).filter((t) => t.callId !== tc.id),
2042
+ task,
2043
+ ],
2044
+ });
2045
+ }
2046
+ const placeholder = this.buildPendingPlaceholder(tc, invocation.taskId, invocation.ack, invocation.pollAfterMs);
2047
+ return createUnsentResult(tc.id, String(tc.name), typeof placeholder.output === 'string' ? JSON.parse(placeholder.output) : placeholder.output);
2048
+ }
2049
+ /**
2050
+ * Execute tools that can auto-execute (don't require approval) in parallel.
2051
+ *
2052
+ * @param toolCalls - The tool calls to execute
2053
+ * @param turnContext - The current turn context
2054
+ * @returns Array of unsent tool results for later submission
2055
+ */
2056
+ async executeAutoApproveTools(toolCalls, turnContext) {
2057
+ // Auto-approved batch = one doom-loop round: identical parallel calls
2058
+ // count once (see beginDoomLoopRound).
2059
+ await this.beginDoomLoopRound(toolCalls);
1100
2060
  const toolCallPromises = toolCalls.map(async (tc) => {
1101
2061
  const tool = this.options.tools?.find((t) => isClientTool(t) && t.function.name === tc.name);
1102
2062
  if (!tool || !isAutoResolvableTool(tool)) {
@@ -1120,6 +2080,9 @@ export class ModelResult {
1120
2080
  // HITL tool paused — no unsent result for this call in this round
1121
2081
  return null;
1122
2082
  }
2083
+ if (isAsyncToolInvocation(result)) {
2084
+ return this.settleAsyncInvocationAsUnsent(tc, result);
2085
+ }
1123
2086
  if (result.error) {
1124
2087
  return createRejectedResult(tc.id, String(tc.name), result.error.message);
1125
2088
  }
@@ -1250,6 +2213,11 @@ export class ModelResult {
1250
2213
  */
1251
2214
  async persistHitlPause(currentResponse, pausedCalls) {
1252
2215
  this.finalResponse = currentResponse;
2216
+ // Queued steer guidance is NOT injected here: the paused calls are
2217
+ // dangling function_calls, and a user message between a function_call
2218
+ // and its eventual output is invalid history. saveStateSafely persists
2219
+ // the queue (doomLoop.pendingSteer); the resumed run delivers it at its
2220
+ // next safe flush point.
1253
2221
  if (!this.stateAccessor) {
1254
2222
  return;
1255
2223
  }
@@ -1285,6 +2253,7 @@ export class ModelResult {
1285
2253
  */
1286
2254
  async persistClientToolsPause(currentResponse, unresolvedCalls) {
1287
2255
  this.finalResponse = currentResponse;
2256
+ // Steer queue persists via saveStateSafely (see persistHitlPause).
1288
2257
  if (!this.stateAccessor || unresolvedCalls.length === 0) {
1289
2258
  return;
1290
2259
  }
@@ -1294,6 +2263,111 @@ export class ModelResult {
1294
2263
  };
1295
2264
  await this.saveStateSafely(stateUpdates);
1296
2265
  }
2266
+ /**
2267
+ * Persist state when one or more deferred tools (`tool.deferred`) started
2268
+ * durable external tasks this round. The placeholder outputs are already
2269
+ * persisted (the round is fully paired), so — unlike HITL/manual pauses —
2270
+ * nothing dangles; the pause exists purely because the model's next turn
2271
+ * should wait for the real results.
2272
+ *
2273
+ * Mirrors `persistHitlPause` in shape. Status `awaiting_async_tools` is
2274
+ * distinct from `awaiting_client_tools`: the tool DID execute; only its
2275
+ * result is pending. Resumption happens via the deferred tool's
2276
+ * `.resolve()` / `.fail()` / `.cancel()` or `resumeToolResults()`.
2277
+ */
2278
+ async persistAsyncToolPause(currentResponse, deferredTasks) {
2279
+ this.finalResponse = currentResponse;
2280
+ if (!this.stateAccessor) {
2281
+ return;
2282
+ }
2283
+ const existing = (this.currentState?.pendingAsyncTools ?? []).filter((t) => !deferredTasks.some((d) => d.callId === t.callId));
2284
+ await this.saveStateSafely({
2285
+ pendingAsyncTools: [
2286
+ ...existing,
2287
+ ...deferredTasks,
2288
+ ],
2289
+ status: 'awaiting_async_tools',
2290
+ });
2291
+ }
2292
+ /**
2293
+ * Harvest background tasks that settled since the last flush and inject
2294
+ * their outcomes into the conversation as `tool_task_result` envelope
2295
+ * messages (user role). Runs at the same safe point as
2296
+ * `flushDoomLoopSteer()` — after a fully-paired round, before the next
2297
+ * request — because injecting between a dangling `function_call` and its
2298
+ * future output would be invalid history.
2299
+ *
2300
+ * A delivery is forward progress: the text-repetition streak is reset so a
2301
+ * model saying "still waiting…" between deliveries is not condemned.
2302
+ */
2303
+ async flushAsyncToolDeliveries() {
2304
+ const registry = this.asyncToolRegistry;
2305
+ if (!registry) {
2306
+ return false;
2307
+ }
2308
+ const settled = registry.takeSettled();
2309
+ if (settled.length === 0) {
2310
+ return false;
2311
+ }
2312
+ const envelopes = [];
2313
+ for (const task of settled) {
2314
+ this.broadcastAsyncSettled(task, 'injected');
2315
+ // Emit the canonical tool.result event with the FINAL value so
2316
+ // consumers watching only tool.result see real results exactly once.
2317
+ if (task.status === 'completed') {
2318
+ this.broadcastToolResult(task.callId, this.toolSourceByName(task.name), task.result);
2319
+ }
2320
+ // PostToolUse/PostToolUseFailure fire at SETTLEMENT for async tools —
2321
+ // the observation-only audit surface (secret scanning, output review)
2322
+ // must cover long-running results, which are exactly the ones that
2323
+ // arrive from external processes.
2324
+ await this.emitAsyncSettledHooks(task);
2325
+ envelopes.push(JSON.stringify({
2326
+ type: 'tool_task_result',
2327
+ tool: task.name,
2328
+ taskId: task.taskId,
2329
+ callId: task.callId,
2330
+ status: task.status,
2331
+ ...(task.result !== undefined && {
2332
+ result: task.result,
2333
+ }),
2334
+ ...(task.error !== undefined && {
2335
+ error: task.error,
2336
+ }),
2337
+ }));
2338
+ // Record settlement for the at-most-once guard. The pending entry is
2339
+ // KEPT with its terminal status (not removed) — same policy as
2340
+ // resumeToolResults — so a late external resolution by taskId OR
2341
+ // callId resolves to ToolTaskAlreadySettledError, never "not found".
2342
+ if (this.currentState) {
2343
+ const terminalStatus = task.status === 'completed' || task.status === 'cancelled' ? task.status : 'failed';
2344
+ this.currentState = updateState(this.currentState, {
2345
+ settledAsyncCallIds: [
2346
+ ...(this.currentState.settledAsyncCallIds ?? []),
2347
+ task.callId,
2348
+ ],
2349
+ ...(this.currentState.pendingAsyncTools !== undefined && {
2350
+ pendingAsyncTools: this.currentState.pendingAsyncTools.map((t) => t.callId === task.callId
2351
+ ? {
2352
+ ...t,
2353
+ status: terminalStatus,
2354
+ }
2355
+ : t),
2356
+ }),
2357
+ });
2358
+ }
2359
+ }
2360
+ // Instruction boundary: the injected message rides the user role (the
2361
+ // only channel that can deliver a late result without a duplicate
2362
+ // function_call_output), but its content is tool output — mark it so
2363
+ // attacker-influenced result text does not read as user instructions.
2364
+ await this.injectAppendPromptMessage(`${TASK_RESULT_BOUNDARY}\n${envelopes.join('\n')}`);
2365
+ // Delivery is observable forward progress — clear the text streak so
2366
+ // legitimate "waiting" phrasing between deliveries can't accumulate
2367
+ // into a doom-loop stop.
2368
+ this.doomLoopMonitor?.resetTextStreak();
2369
+ return true;
2370
+ }
1297
2371
  /**
1298
2372
  * Compute the `output` payload sent to the model for a successfully
1299
2373
  * settled tool execution. Routes through `toModelOutput` when the tool
@@ -1346,6 +2420,19 @@ export class ModelResult {
1346
2420
  * outcome consumed by `executeToolRound`'s aggregation loop.
1347
2421
  */
1348
2422
  async executeSingleToolCall(toolCall, turnContext) {
2423
+ // Universal task-tool dispatch: ONE static tool ("task") handles every
2424
+ // check/steer/result/cancel interaction with running tasks, addressed
2425
+ // by taskId — the wire surface stays constant regardless of how many
2426
+ // async tools are registered, while the handling stays tool-resident
2427
+ // (the owning tool's `check` config). Engine-intercepted: bypasses
2428
+ // gates, deadlines, and Pre/PostToolUse hooks (engine bookkeeping, not
2429
+ // user work) and never reaches the doom-loop checkpoint. A
2430
+ // PermissionRequest denial recorded for the call IS honored — cancel
2431
+ // is destructive and steer reshapes a running task, so a policy layer
2432
+ // that vetoed the call wins over the interception.
2433
+ if (toolCall.name === TASK_TOOL_NAME && this.taskToolActive()) {
2434
+ return this.consumeHookDenial(toolCall.id) ?? this.answerTaskToolCall(toolCall);
2435
+ }
1349
2436
  const tool = this.options.tools?.find((t) => isClientTool(t) && t.function.name === toolCall.name);
1350
2437
  if (!tool || !isAutoResolvableTool(tool)) {
1351
2438
  return null;
@@ -1353,20 +2440,9 @@ export class ModelResult {
1353
2440
  // PermissionRequest hook denied this call without pausing: synthesize a
1354
2441
  // rejection instead of executing. Consume the entry so a later round
1355
2442
  // 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
- };
2443
+ const hookDenied = this.consumeHookDenial(toolCall.id);
2444
+ if (hookDenied !== null) {
2445
+ return hookDenied;
1370
2446
  }
1371
2447
  const preliminaryResultsForCall = [];
1372
2448
  const hasBroadcaster = this.toolEventBroadcaster || this.turnBroadcaster;
@@ -1377,41 +2453,198 @@ export class ModelResult {
1377
2453
  this.broadcastPreliminaryResult(callId, typedResult);
1378
2454
  }
1379
2455
  : 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,
2456
+ // Compose the per-call abort signal (run signal + tool/run timeout +
2457
+ // per-call controller) and acquire the concurrency gates in fixed order
2458
+ // (round gate first, then per-tool gate — fixed ordering is what makes
2459
+ // multi-gate acquisition deadlock-free). Gate WAITING happens before the
2460
+ // timeout race below, so queue wait counts against the deadline only
2461
+ // for background tasks (which carry their own timeout in the registry).
2462
+ //
2463
+ // Background lifecycles skip the per-tool gate HERE: their body only
2464
+ // runs later via runBackgroundWork, which acquires that gate for the
2465
+ // body's true duration. Acquiring it on the round path too would let
2466
+ // detached bodies holding all maxConcurrency slots block a later
2467
+ // round's dispatch (thunk creation, no body work) for the full task
2468
+ // duration — while it holds a round-gate slot, head-of-line-blocking
2469
+ // every other call in the round.
2470
+ const { controller, signal, timeoutMs } = this.composeToolSignal(String(toolCall.name), toolCall.id);
2471
+ const isBackgroundLifecycle = isUnifiedTool(tool) && tool.function.lifecycle === 'background';
2472
+ const releaseGates = await acquireAll([
2473
+ this.roundGate ?? undefined,
2474
+ isBackgroundLifecycle ? undefined : this.perToolGate(String(toolCall.name)),
2475
+ ]);
2476
+ try {
2477
+ // Run the tool through the full Pre/Post lifecycle hooks. The helper
2478
+ // fails closed on a JSON-parse failure in toolCall.arguments so hooks
2479
+ // never see a malformed payload; the caller handles that case via the
2480
+ // shared `parse_error` / `hook_blocked` branch.
2481
+ //
2482
+ // The whole hooked execution is raced against the tool deadline: on
2483
+ // timeout the round stops waiting (the model gets a `tool_timeout`
2484
+ // error output) and the body's signal is aborted. The abandoned body
2485
+ // keeps running detached; awaiting it would reintroduce the hang
2486
+ // run-cancellation (DEV-658) fixed.
2487
+ // Unified-run affordances. The ToolTask is created later (when the
2488
+ // call escapes the round in handleAsyncInvocation), so the log sink
2489
+ // and inbox registration go through a mutable slot the task binds to
2490
+ // on creation; entries logged before that are buffered.
2491
+ const runBinding = this.createRunBinding(toolCall.id);
2492
+ const executionPromise = this.runToolWithHooks(tool, toolCall, turnContext, onPreliminaryResult, {
2493
+ signal,
2494
+ callId: toolCall.id,
2495
+ ...(this.currentState?.id !== undefined && {
2496
+ conversationId: this.currentState.id,
2497
+ }),
2498
+ client: this.options.client,
2499
+ runExtras: buildRunExtras(tool, runBinding),
1388
2500
  });
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)
2501
+ const executed = await this.raceToolDeadline(executionPromise, String(toolCall.name), timeoutMs, controller);
2502
+ if (executed === 'timeout') {
2503
+ return this.buildToolTimeoutOutcome(toolCall, tool, timeoutMs, preliminaryResultsForCall);
2504
+ }
2505
+ if (executed.type === 'parse_error') {
2506
+ this.broadcastToolResult(toolCall.id, isMcpTool(tool) ? 'mcp' : 'client', {
2507
+ error: executed.errorMessage,
2508
+ });
2509
+ return executed;
2510
+ }
2511
+ if (executed.type === 'hook_blocked') {
2512
+ return executed;
2513
+ }
2514
+ const result = executed.result;
2515
+ if (result === null) {
2516
+ // HITL tool paused — surface as manual (no output this round)
2517
+ return {
2518
+ type: 'paused',
2519
+ toolCall,
2520
+ };
2521
+ }
2522
+ if (isAsyncToolInvocation(result)) {
2523
+ // Background/deferred: the round's aggregation loop turns this into
2524
+ // a placeholder output (and, for background, tracks the work in the
2525
+ // registry). The per-call controller stays alive with the task.
2526
+ return {
2527
+ type: 'async',
2528
+ toolCall: executed.effectiveToolCall,
2529
+ tool,
2530
+ invocation: result,
2531
+ controller,
2532
+ timeoutMs,
2533
+ runBinding,
2534
+ preliminaryResultsForCall,
2535
+ };
2536
+ }
1397
2537
  return {
1398
- type: 'paused',
1399
- toolCall,
2538
+ type: 'execution',
2539
+ toolCall: executed.effectiveToolCall,
2540
+ tool,
2541
+ result,
2542
+ preliminaryResultsForCall,
1400
2543
  };
1401
2544
  }
2545
+ finally {
2546
+ releaseGates();
2547
+ // The inflight map only tracks round-synchronous executions; async
2548
+ // background tasks hand their controller to the registry, which owns
2549
+ // teardown from here (cancelTask / abortAll).
2550
+ this.inflightToolControllers.delete(toolCall.id);
2551
+ }
2552
+ }
2553
+ /**
2554
+ * The `tool_timeout` execution outcome for a call whose deadline fired:
2555
+ * the model receives an error output immediately (the abandoned body
2556
+ * keeps running detached).
2557
+ */
2558
+ buildToolTimeoutOutcome(toolCall, tool, timeoutMs, preliminaryResultsForCall) {
2559
+ const message = `Tool "${toolCall.name}" timed out after ${timeoutMs}ms`;
2560
+ this.broadcastToolResult(toolCall.id, isMcpTool(tool) ? 'mcp' : 'client', {
2561
+ error: message,
2562
+ });
1402
2563
  return {
1403
2564
  type: 'execution',
1404
- toolCall: executed.effectiveToolCall,
2565
+ toolCall,
1405
2566
  tool,
1406
- result,
2567
+ result: {
2568
+ result: null,
2569
+ error: Object.assign(new Error(message), {
2570
+ code: 'tool_timeout',
2571
+ }),
2572
+ },
1407
2573
  preliminaryResultsForCall,
1408
2574
  };
1409
2575
  }
2576
+ /**
2577
+ * PermissionRequest-hook denial for a call, as a `hook_blocked` outcome —
2578
+ * or null when the call was not denied. Consumes the entry so a later
2579
+ * round with a reused id is not affected.
2580
+ */
2581
+ consumeHookDenial(callId) {
2582
+ const denialReason = this.hookDeniedCalls.get(callId);
2583
+ if (denialReason === undefined) {
2584
+ return null;
2585
+ }
2586
+ this.hookDeniedCalls.delete(callId);
2587
+ return {
2588
+ type: 'hook_blocked',
2589
+ output: {
2590
+ type: 'function_call_output',
2591
+ id: `output_${callId}`,
2592
+ callId,
2593
+ output: JSON.stringify({
2594
+ error: denialReason,
2595
+ }),
2596
+ },
2597
+ };
2598
+ }
2599
+ /**
2600
+ * Race a tool execution against its deadline. Returns `'timeout'` when the
2601
+ * deadline fires first — the caller synthesizes the `tool_timeout` output —
2602
+ * and aborts the body's controller so cooperative tools stop working. The
2603
+ * timer is cleared on settle either way.
2604
+ */
2605
+ raceToolDeadline(execution, toolName, timeoutMs, controller) {
2606
+ if (timeoutMs === undefined || timeoutMs <= 0) {
2607
+ return execution;
2608
+ }
2609
+ return new Promise((resolve, reject) => {
2610
+ const timer = setTimeout(() => {
2611
+ controller.abort(new Error(`Tool "${toolName}" timed out after ${timeoutMs}ms`));
2612
+ resolve('timeout');
2613
+ }, timeoutMs);
2614
+ if (typeof timer === 'object' && 'unref' in timer && typeof timer.unref === 'function') {
2615
+ timer.unref();
2616
+ }
2617
+ execution.then((value) => {
2618
+ clearTimeout(timer);
2619
+ resolve(value);
2620
+ }, (error) => {
2621
+ clearTimeout(timer);
2622
+ reject(error instanceof Error ? error : new Error(String(error)));
2623
+ });
2624
+ });
2625
+ }
1410
2626
  async executeToolRound(toolCalls, turnContext) {
2627
+ // One executed batch = one doom-loop round: identical parallel calls in
2628
+ // this batch count as ONE piece of loop evidence and share a decision.
2629
+ await this.beginDoomLoopRound(toolCalls);
1411
2630
  const toolCallPromises = toolCalls.map((toolCall) => this.executeSingleToolCall(toolCall, turnContext));
1412
2631
  const settledResults = await Promise.allSettled(toolCallPromises);
1413
2632
  const toolResults = [];
1414
2633
  const pausedCalls = [];
2634
+ const deferredTasks = [];
2635
+ // Start ALL async invocations before consuming any outcome: the work
2636
+ // (and its grace window) begins in handleAsyncInvocation, so awaiting
2637
+ // it inside the ordered loop below would serialize N background calls
2638
+ // into (N-1)×graceMs of stagger. Kicked off here in parallel; the
2639
+ // ordered loop awaits the per-call promise, so OUTPUT order stays call
2640
+ // order (prompt-cache stability).
2641
+ const asyncOutcomes = new Map();
2642
+ for (let i = 0; i < settledResults.length; i++) {
2643
+ const settled = settledResults[i];
2644
+ if (settled?.status === 'fulfilled' && settled.value?.type === 'async') {
2645
+ asyncOutcomes.set(i, this.handleAsyncInvocation(settled.value));
2646
+ }
2647
+ }
1415
2648
  for (let i = 0; i < settledResults.length; i++) {
1416
2649
  const settled = settledResults[i];
1417
2650
  const originalToolCall = toolCalls[i];
@@ -1461,29 +2694,601 @@ export class ModelResult {
1461
2694
  pausedCalls.push(value.toolCall);
1462
2695
  continue;
1463
2696
  }
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,
2697
+ if (value.type === 'async') {
2698
+ // Background / deferred: the call escapes the round. Its handling
2699
+ // (grace-window race, registry tracking, placeholder synthesis)
2700
+ // was started above in parallel with the round's other async
2701
+ // calls; awaiting here keeps outputs in call order.
2702
+ const asyncOutcome = await (asyncOutcomes.get(i) ?? this.handleAsyncInvocation(value));
2703
+ toolResults.push(asyncOutcome.output);
2704
+ this.turnBroadcaster?.push({
2705
+ type: 'tool.call_output',
2706
+ output: asyncOutcome.output,
2707
+ timestamp: Date.now(),
2708
+ });
2709
+ if (asyncOutcome.deferredTask) {
2710
+ deferredTasks.push(asyncOutcome.deferredTask);
2711
+ }
2712
+ continue;
2713
+ }
2714
+ const toolResult = (value.result.error
2715
+ ? {
2716
+ error: value.result.error.message,
2717
+ }
2718
+ : value.result.result);
2719
+ this.broadcastToolResult(value.toolCall.id, isMcpTool(value.tool) ? 'mcp' : 'client', toolResult, value.preliminaryResultsForCall.length > 0 ? value.preliminaryResultsForCall : undefined);
2720
+ const outputForModel = await this.computeToolOutputForModel(value);
2721
+ const executedOutput = {
2722
+ type: 'function_call_output',
2723
+ id: `output_${value.toolCall.id}`,
2724
+ callId: value.toolCall.id,
2725
+ output: outputForModel,
2726
+ };
2727
+ toolResults.push(executedOutput);
2728
+ this.turnBroadcaster?.push({
2729
+ type: 'tool.call_output',
2730
+ output: executedOutput,
2731
+ timestamp: Date.now(),
2732
+ });
2733
+ }
2734
+ return {
2735
+ toolResults,
2736
+ pausedCalls,
2737
+ deferredTasks,
2738
+ };
2739
+ }
2740
+ /**
2741
+ * Handle one async invocation from a round.
2742
+ *
2743
+ * Background: start the work under the background pool, race it against
2744
+ * the grace window — settle in time and the call yields a plain
2745
+ * synchronous output (no async machinery visible); otherwise emit the
2746
+ * pending placeholder and let the registry track the task.
2747
+ *
2748
+ * Deferred: emit the placeholder and report the task for the pause path.
2749
+ */
2750
+ async handleAsyncInvocation(value) {
2751
+ const { toolCall, tool, invocation, controller, runBinding } = value;
2752
+ const registry = this.ensureAsyncToolRegistry();
2753
+ const source = isMcpTool(tool) ? 'mcp' : 'client';
2754
+ const logLimits = isUnifiedTool(tool) ? tool.function.logLimits : undefined;
2755
+ if (invocation.asyncMode === 'defer') {
2756
+ const collision = this.rejectDuplicateTaskId(toolCall, invocation.taskId, source);
2757
+ if (collision) {
2758
+ return collision;
2759
+ }
2760
+ const liveTask = registry.trackDeferred({
2761
+ callId: toolCall.id,
2762
+ taskId: invocation.taskId,
2763
+ name: String(toolCall.name),
2764
+ ...(invocation.expiresAt !== undefined && {
2765
+ expiresAt: invocation.expiresAt,
2766
+ }),
2767
+ ...(invocation.pollAfterMs !== undefined && {
2768
+ pollAfterMs: invocation.pollAfterMs,
2769
+ }),
2770
+ });
2771
+ runBinding.bind(liveTask);
2772
+ const task = {
2773
+ callId: toolCall.id,
2774
+ taskId: invocation.taskId,
2775
+ name: String(toolCall.name),
2776
+ mode: 'defer',
2777
+ status: 'working',
2778
+ startedAt: liveTask.startedAt,
2779
+ ...(invocation.pollAfterMs !== undefined && {
2780
+ pollAfterMs: invocation.pollAfterMs,
2781
+ }),
2782
+ ...(invocation.expiresAt !== undefined && {
2783
+ expiresAt: invocation.expiresAt,
2784
+ }),
2785
+ };
2786
+ this.broadcastAsyncStarted(toolCall, 'defer', invocation.taskId, invocation.ack);
2787
+ return {
2788
+ output: this.buildPendingPlaceholder(toolCall, invocation.taskId, invocation.ack, invocation.pollAfterMs),
2789
+ deferredTask: task,
2790
+ };
2791
+ }
2792
+ // Background: start the work under the background pool. The pool slot is
2793
+ // held for the duration of the work (queue wait counts against the
2794
+ // task's own timeout tracked by the registry).
2795
+ const taskId = registry.generateTaskId();
2796
+ const liveTask = new ToolTask({
2797
+ taskId,
2798
+ callId: toolCall.id,
2799
+ toolName: String(toolCall.name),
2800
+ mode: isAgentTool(tool) ? 'agent' : 'background',
2801
+ controller,
2802
+ ...(logLimits !== undefined && {
2803
+ limits: logLimits,
2804
+ }),
2805
+ // Carried so PostToolUse/PostToolUseFailure can fire at settlement
2806
+ // with the same payload shape as round-synchronous executions.
2807
+ input: (toolCall.arguments ?? {}),
2808
+ });
2809
+ runBinding.bind(liveTask);
2810
+ // Visible to steering / cancel / snapshots from the start — a large
2811
+ // graceMs must not create a window where the running task cannot be
2812
+ // reached (sendToTask queues into the inbox; cancelTask aborts the
2813
+ // controller, which the grace race observes as an error settlement).
2814
+ registry.register(liveTask);
2815
+ const work = this.runBackgroundWork(String(toolCall.name), invocation.run, controller);
2816
+ // Surface unhandled rejections nowhere — the registry's .then() below
2817
+ // (or the grace race) is the sole consumer.
2818
+ work.catch(() => undefined);
2819
+ // Grace window: work settling in time yields a plain synchronous output.
2820
+ const graceMs = invocation.graceMs;
2821
+ if (graceMs > 0) {
2822
+ // The timer is cleared when the work settles first (same discipline
2823
+ // as raceToolDeadline) — it's unref'd, but a leaked timer is still a
2824
+ // leaked timer.
2825
+ let graceTimer;
2826
+ const settled = await Promise.race([
2827
+ work.then((result) => ({
2828
+ outcome: 'ok',
2829
+ result,
2830
+ }), (error) => ({
2831
+ outcome: 'error',
2832
+ error,
2833
+ })),
2834
+ new Promise((resolve) => {
2835
+ graceTimer = setTimeout(() => resolve('pending'), graceMs);
2836
+ if (typeof graceTimer === 'object' &&
2837
+ 'unref' in graceTimer &&
2838
+ typeof graceTimer.unref === 'function') {
2839
+ graceTimer.unref();
2840
+ }
2841
+ }),
2842
+ ]);
2843
+ if (graceTimer !== undefined) {
2844
+ clearTimeout(graceTimer);
2845
+ }
2846
+ if (settled !== 'pending') {
2847
+ // Settled in-window: behave exactly like a regular tool. The call
2848
+ // yields a synchronous output, so the task leaves the registry —
2849
+ // including any settlement a racing cancelTask queued for it (the
2850
+ // sync output already reports the outcome; an envelope on top
2851
+ // would be a double delivery).
2852
+ registry.untrack(toolCall.id);
2853
+ if (settled.outcome === 'ok') {
2854
+ this.broadcastToolResult(toolCall.id, source, settled.result);
2855
+ const outputForModel = await this.computeToolOutputForModel({
2856
+ toolCall,
2857
+ tool,
2858
+ result: {
2859
+ result: settled.result,
2860
+ },
2861
+ });
2862
+ return {
2863
+ output: {
2864
+ type: 'function_call_output',
2865
+ id: `output_${toolCall.id}`,
2866
+ callId: toolCall.id,
2867
+ output: outputForModel,
2868
+ },
2869
+ };
2870
+ }
2871
+ const message = settled.error instanceof Error ? settled.error.message : String(settled.error);
2872
+ this.broadcastToolResult(toolCall.id, source, {
2873
+ error: message,
2874
+ });
2875
+ return {
2876
+ output: {
2877
+ type: 'function_call_output',
2878
+ id: `output_${toolCall.id}`,
2879
+ callId: toolCall.id,
2880
+ output: JSON.stringify({
2881
+ error: message,
2882
+ }),
2883
+ },
2884
+ };
2885
+ }
2886
+ }
2887
+ // Outlived the grace window: placeholder now, deliver later. The
2888
+ // registry owns the timeout from here (the per-call deadline raced in
2889
+ // executeSingleToolCall only bounds the ROUND's wait; a background task
2890
+ // gets its full budget).
2891
+ registry.trackBackground(liveTask, work, value.timeoutMs !== undefined
2892
+ ? {
2893
+ timeoutMs: value.timeoutMs,
2894
+ }
2895
+ : undefined);
2896
+ this.broadcastAsyncStarted(toolCall, liveTask.mode, taskId, invocation.ack);
2897
+ return {
2898
+ output: this.buildPendingPlaceholder(toolCall, taskId, invocation.ack),
2899
+ };
2900
+ }
2901
+ /**
2902
+ * Reject a `ctx.defer()` taskId already claimed by ANOTHER pending task.
2903
+ * Task ids are the resolution key (resumeToolResults matches on them), so
2904
+ * a duplicate would make one of the two calls unresolvable — the
2905
+ * conversation would wait forever. Caller-supplied ids
2906
+ * (`ctx.defer(ticket.id)`) carry no uniqueness guarantee; reject the
2907
+ * collision at start time, when the tool can still see it.
2908
+ * @returns the error output for the colliding call, or null when unique.
2909
+ */
2910
+ rejectDuplicateTaskId(toolCall, taskId, source) {
2911
+ const duplicate = this.asyncToolRegistry?.getTask(taskId) ??
2912
+ this.currentState?.pendingAsyncTools?.find((t) => t.taskId === taskId);
2913
+ if (!duplicate || duplicate.callId === toolCall.id) {
2914
+ return null;
2915
+ }
2916
+ 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).`;
2917
+ this.broadcastToolResult(toolCall.id, source, {
2918
+ error: message,
2919
+ });
2920
+ return {
2921
+ output: {
2922
+ type: 'function_call_output',
2923
+ id: `output_${toolCall.id}`,
2924
+ callId: toolCall.id,
2925
+ output: JSON.stringify({
2926
+ error: message,
2927
+ }),
2928
+ },
2929
+ };
2930
+ }
2931
+ /**
2932
+ * A mutable binding between a tool call's run context and its (created
2933
+ * later) ToolTask. `ctx.log()` entries before the task exists are
2934
+ * buffered; `ctx.onMessage()` registrations are forwarded on bind.
2935
+ */
2936
+ createRunBinding(_callId) {
2937
+ let task = null;
2938
+ const bufferedLogs = [];
2939
+ let pendingHandler = null;
2940
+ let pendingTranscriptSource = null;
2941
+ return {
2942
+ log: (entry) => {
2943
+ if (task) {
2944
+ task.appendLog(entry, typeof entry === 'string' ? 'text' : 'event');
2945
+ }
2946
+ else {
2947
+ bufferedLogs.push(entry);
2948
+ }
2949
+ },
2950
+ onMessage: (handler) => {
2951
+ if (task) {
2952
+ task.onMessage(handler);
2953
+ }
2954
+ else {
2955
+ pendingHandler = handler;
2956
+ }
2957
+ },
2958
+ setTranscriptSource: (source) => {
2959
+ if (task) {
2960
+ task.transcriptSource = source;
2961
+ }
2962
+ else {
2963
+ pendingTranscriptSource = source;
2964
+ }
2965
+ },
2966
+ bind: (bound) => {
2967
+ task = bound;
2968
+ for (const entry of bufferedLogs) {
2969
+ bound.appendLog(entry, typeof entry === 'string' ? 'text' : 'event');
2970
+ }
2971
+ bufferedLogs.length = 0;
2972
+ if (pendingHandler) {
2973
+ bound.onMessage(pendingHandler);
2974
+ pendingHandler = null;
2975
+ }
2976
+ if (pendingTranscriptSource) {
2977
+ bound.transcriptSource = pendingTranscriptSource;
2978
+ pendingTranscriptSource = null;
2979
+ }
2980
+ },
2981
+ task: () => task,
2982
+ };
2983
+ }
2984
+ /**
2985
+ * Run a background tool's work under the tool's own concurrency gate AND
2986
+ * the background pool. The round path does not touch the per-tool gate
2987
+ * for background lifecycles (the thunk had not run yet), so the gate is
2988
+ * acquired here for the body's lifetime — `maxConcurrency` bounds
2989
+ * executions of the BODY, wherever they run.
2990
+ *
2991
+ * Acquisition order: per-tool gate FIRST, then the pool. The narrow gate
2992
+ * is where a saturated tool's excess calls queue — queuing them on the
2993
+ * shared pool instead would let 16 waiters on one maxConcurrency:2 tool
2994
+ * hold every global slot while only two bodies run, starving every other
2995
+ * background tool. Order is deadlock-safe: only background bodies take
2996
+ * the per-tool gate (the round path skips it for them), and every body
2997
+ * acquires in this same fixed order.
2998
+ *
2999
+ * Queue wait counts against the task's own timeout (tracked by the
3000
+ * registry); an abort observed before the slots free up short-circuits
3001
+ * without running the body.
3002
+ */
3003
+ async runBackgroundWork(toolName, run, controller) {
3004
+ const gate = this.perToolGate(toolName);
3005
+ const releaseGate = gate ? await gate.acquire() : undefined;
3006
+ const pool = this.backgroundPool;
3007
+ const releasePool = pool ? await pool.acquire() : undefined;
3008
+ try {
3009
+ if (controller.signal.aborted) {
3010
+ throw controller.signal.reason instanceof Error
3011
+ ? controller.signal.reason
3012
+ : new Error(String(controller.signal.reason ?? 'aborted'));
3013
+ }
3014
+ return await run();
3015
+ }
3016
+ finally {
3017
+ releasePool?.();
3018
+ releaseGate?.();
3019
+ }
3020
+ }
3021
+ /** Broadcast a `tool.async_started` event on the turn broadcaster. */
3022
+ broadcastAsyncStarted(toolCall, mode, taskId, ack) {
3023
+ this.turnBroadcaster?.push({
3024
+ type: 'tool.async_started',
3025
+ toolCallId: toolCall.id,
3026
+ toolName: String(toolCall.name),
3027
+ taskId,
3028
+ mode,
3029
+ ...(ack !== undefined && {
3030
+ ack,
3031
+ }),
3032
+ timestamp: Date.now(),
3033
+ });
3034
+ }
3035
+ /** Broadcast a `tool.async_settled` event on the turn broadcaster. */
3036
+ broadcastAsyncSettled(settled, delivery) {
3037
+ this.turnBroadcaster?.push({
3038
+ type: 'tool.async_settled',
3039
+ toolCallId: settled.callId,
3040
+ taskId: settled.taskId,
3041
+ status: settled.status,
3042
+ ...(settled.result !== undefined && {
3043
+ result: settled.result,
3044
+ }),
3045
+ ...(settled.error !== undefined && {
3046
+ error: settled.error,
3047
+ }),
3048
+ delivery,
3049
+ timestamp: Date.now(),
3050
+ });
3051
+ }
3052
+ /**
3053
+ * Build the pending placeholder `function_call_output` for an async tool
3054
+ * call. Pairs the `function_call` immediately (providers 400 on unpaired
3055
+ * calls in follow-up history); the real result arrives later as a
3056
+ * `tool_task_result` envelope. The note steers the model: with check-ins
3057
+ * enabled it points at the universal `task` tool; with check-ins
3058
+ * disabled it forbids re-calling entirely.
3059
+ */
3060
+ buildPendingPlaceholder(toolCall, taskId, ack, pollAfterMs) {
3061
+ const checkinsEnabled = this.options.asyncTools?.checkins !== false;
3062
+ const payload = {
3063
+ status: 'pending',
3064
+ taskId,
3065
+ ...(pollAfterMs !== undefined && {
3066
+ pollAfterMs,
3067
+ }),
3068
+ ...(checkinsEnabled && {
3069
+ check: true,
3070
+ }),
3071
+ };
3072
+ const directive = checkinsEnabled
3073
+ ? `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.`
3074
+ : 'The result will be delivered to you automatically when ready — do not call this tool again to check on it.';
3075
+ if (typeof ack === 'string') {
3076
+ payload['note'] = `${ack} ${directive}`;
3077
+ }
3078
+ else {
3079
+ if (ack !== undefined && typeof ack === 'object') {
3080
+ Object.assign(payload, ack);
3081
+ }
3082
+ payload['note'] = `Started. ${directive}`;
3083
+ }
3084
+ return {
3085
+ type: 'function_call_output',
3086
+ id: `output_${toolCall.id}`,
3087
+ callId: toolCall.id,
3088
+ output: JSON.stringify(payload),
3089
+ };
3090
+ }
3091
+ /**
3092
+ * True when the built-in task tool is active for this run. Mirrors
3093
+ * `needsTaskTool` exactly: when a user tool claims the reserved name the
3094
+ * built-in is not registered, so calls named "task" must NOT be
3095
+ * intercepted — they belong to the user's tool.
3096
+ */
3097
+ taskToolActive() {
3098
+ if (this.options.asyncTools?.checkins === false) {
3099
+ return false;
3100
+ }
3101
+ const tools = this.options.tools ?? [];
3102
+ if (hasTaskToolNameCollision(tools)) {
3103
+ return false;
3104
+ }
3105
+ return tools.some((t) => isLongRunningTool(t));
3106
+ }
3107
+ /**
3108
+ * Answer a call to the universal `task` tool: resolve the taskId to its
3109
+ * live registry task (or persisted pendingAsyncTools entry, post-restart)
3110
+ * and its OWNING tool, then dispatch by action:
3111
+ *
3112
+ * - `check` (default): the owning tool's `check.execute` when declared
3113
+ * (custom `params` validated against `check.schema`), else the SDK
3114
+ * default status / logs / transcript views.
3115
+ * - `steer`: deliver `message` to the run body's inbox.
3116
+ * - `result`: the final result if settled, else the status view.
3117
+ * - `cancel`: cancel the task.
3118
+ */
3119
+ async answerTaskToolCall(toolCall) {
3120
+ const taskTool = buildTaskToolStub();
3121
+ const answer = (result, error) => {
3122
+ if (error === undefined) {
3123
+ this.broadcastToolResult(toolCall.id, 'client', result);
3124
+ }
3125
+ return {
3126
+ type: 'execution',
3127
+ toolCall,
3128
+ tool: taskTool,
3129
+ result: error
3130
+ ? {
3131
+ result: null,
3132
+ error,
3133
+ }
3134
+ : {
3135
+ result,
3136
+ },
3137
+ preliminaryResultsForCall: [],
3138
+ };
3139
+ };
3140
+ let input;
3141
+ try {
3142
+ input = validateToolInput(TaskToolInputSchema, toolCall.arguments ?? {});
3143
+ }
3144
+ catch (error) {
3145
+ return answer(null, error instanceof Error ? error : new Error(String(error)));
3146
+ }
3147
+ const liveTask = this.asyncToolRegistry?.getTask(input.taskId);
3148
+ const persisted = this.currentState?.pendingAsyncTools?.find((t) => t.taskId === input.taskId);
3149
+ if (!liveTask && !persisted) {
3150
+ return answer({
3151
+ error: 'unknown_task',
3152
+ taskId: input.taskId,
3153
+ hint: 'No task with this id exists in this conversation. It may belong to another conversation, or its record was dropped.',
3154
+ });
3155
+ }
3156
+ try {
3157
+ switch (input.action ?? 'check') {
3158
+ case 'cancel':
3159
+ return answer(this.taskToolCancel(input, liveTask));
3160
+ case 'steer':
3161
+ return answer(...this.taskToolSteer(input, liveTask));
3162
+ case 'result': {
3163
+ const settled = taskToolResultIfSettled(input, liveTask);
3164
+ if (settled !== null) {
3165
+ return answer(settled);
3166
+ }
3167
+ // Not settled: fall through to the status view.
3168
+ return answer(await this.taskToolCheck(input, liveTask, persisted));
3169
+ }
3170
+ default:
3171
+ return answer(await this.taskToolCheck(input, liveTask, persisted));
3172
+ }
3173
+ }
3174
+ catch (error) {
3175
+ return answer(null, error instanceof Error ? error : new Error(String(error)));
3176
+ }
3177
+ }
3178
+ /** `task` tool, action=cancel. */
3179
+ taskToolCancel(input, liveTask) {
3180
+ const cancelled = liveTask
3181
+ ? (this.asyncToolRegistry?.cancelTask(input.taskId, input.reason) ?? false)
3182
+ : false;
3183
+ return cancelled
3184
+ ? {
3185
+ taskId: input.taskId,
3186
+ status: 'cancelled',
3187
+ }
3188
+ : {
3189
+ taskId: input.taskId,
3190
+ error: 'not_cancellable',
3191
+ hint: liveTask
3192
+ ? 'The task has already settled.'
3193
+ : 'This task is owned by an external system — cancel it there (or via the tool’s .cancel() method).',
1476
3194
  };
1477
- toolResults.push(executedOutput);
1478
- this.turnBroadcaster?.push({
1479
- type: 'tool.call_output',
1480
- output: executedOutput,
1481
- timestamp: Date.now(),
1482
- });
3195
+ }
3196
+ /** `task` tool, action=steer. Returns [result, error?] for `answer`. */
3197
+ taskToolSteer(input, liveTask) {
3198
+ if (typeof input.message !== 'string' || input.message.length === 0) {
3199
+ return [
3200
+ null,
3201
+ new Error("action 'steer' requires a non-empty `message`"),
3202
+ ];
3203
+ }
3204
+ if (!liveTask || liveTask.mode === 'defer') {
3205
+ return [
3206
+ {
3207
+ taskId: input.taskId,
3208
+ error: 'not_steerable',
3209
+ hint: 'This task runs in an external system — steer it there.',
3210
+ },
3211
+ ];
3212
+ }
3213
+ liveTask.send(input.message);
3214
+ return [
3215
+ {
3216
+ taskId: input.taskId,
3217
+ steered: true,
3218
+ },
3219
+ ];
3220
+ }
3221
+ /**
3222
+ * `task` tool, action=check (and the status-view fallback for `result`
3223
+ * on an unsettled task): the owning tool's `check.execute` when
3224
+ * declared, else the SDK default views — live-task-backed when the task
3225
+ * is in this process, persisted-state-backed post-restart.
3226
+ */
3227
+ async taskToolCheck(input, liveTask, persisted) {
3228
+ const owningToolName = liveTask?.toolName ?? persisted?.name ?? '';
3229
+ const owningTool = this.options.tools?.find((t) => isClientTool(t) && t.function.name === owningToolName);
3230
+ const config = owningTool && isUnifiedTool(owningTool) ? owningTool.function.check : undefined;
3231
+ const { schema, execute } = resolveCheckConfig(config);
3232
+ const maxTranscriptChars = this.options.asyncTools?.maxTranscriptChars ?? 20000;
3233
+ // Custom params for the owning tool's handler are validated against
3234
+ // its check.schema when both are present.
3235
+ let customParams = input.params ?? {};
3236
+ if (schema && input.params !== undefined) {
3237
+ customParams = validateToolInput(schema, input.params);
3238
+ }
3239
+ if (liveTask) {
3240
+ const handle = this.buildTaskHandle(liveTask, maxTranscriptChars);
3241
+ const checkTurnContext = {
3242
+ numberOfTurns: this.allToolExecutionRounds.length + 1,
3243
+ toolCallStatus: liveTask.status,
3244
+ accumulatedYieldedEvents: liveTask.accumulatedYieldedEvents,
3245
+ task: handle,
3246
+ };
3247
+ return execute
3248
+ ? await Promise.resolve(execute(customParams, checkTurnContext))
3249
+ : defaultCheckResult({
3250
+ view: input.view,
3251
+ tail: input.tail,
3252
+ }, checkTurnContext, {
3253
+ maxTranscriptChars,
3254
+ });
1483
3255
  }
3256
+ // Cross-process / post-restart: only persisted status survives. A
3257
+ // custom check.execute still runs, with a state-backed context (no
3258
+ // live task handle — `turnContext.task` is absent here). Handlers
3259
+ // written against `task.statusView()` with optional chaining return
3260
+ // undefined in this situation; fall back to the persisted status view
3261
+ // rather than answering the model with nothing.
3262
+ const checkTurnContext = {
3263
+ numberOfTurns: this.allToolExecutionRounds.length + 1,
3264
+ toolCallStatus: persisted.status,
3265
+ accumulatedYieldedEvents: persisted.lastLog
3266
+ ? [
3267
+ persisted.lastLog.text,
3268
+ ]
3269
+ : [],
3270
+ };
3271
+ const customResult = execute
3272
+ ? await Promise.resolve(execute(customParams, checkTurnContext))
3273
+ : undefined;
3274
+ return (customResult ??
3275
+ persistedTaskCheckResult({
3276
+ view: input.view,
3277
+ }, persisted));
3278
+ }
3279
+ /** Build the narrow ToolTaskHandle façade for check.execute handlers. */
3280
+ buildTaskHandle(task, maxTranscriptChars) {
3281
+ const registry = this.asyncToolRegistry;
1484
3282
  return {
1485
- toolResults,
1486
- pausedCalls,
3283
+ taskId: task.taskId,
3284
+ toolName: task.toolName,
3285
+ mode: task.mode,
3286
+ status: () => task.status,
3287
+ statusView: () => task.toStatusView(),
3288
+ tailLogs: (n) => task.tailLogs(n),
3289
+ transcript: (maxChars) => task.renderTranscript(maxChars ?? maxTranscriptChars),
3290
+ send: (message) => task.send(message),
3291
+ cancel: (reason) => registry?.cancelTask(task.taskId, reason) ?? false,
1487
3292
  };
1488
3293
  }
1489
3294
  /**
@@ -1494,7 +3299,7 @@ export class ModelResult {
1494
3299
  */
1495
3300
  async resolveAsyncFunctionsForTurn(turnContext) {
1496
3301
  if (hasAsyncFunctions(this.options.request)) {
1497
- const resolved = await resolveAsyncFunctions(this.options.request, turnContext);
3302
+ const resolved = await this.resolveRequestForContext(turnContext);
1498
3303
  // Preserve accumulated input from previous turns
1499
3304
  const preservedInput = this.resolvedRequest?.input;
1500
3305
  const preservedStream = this.resolvedRequest?.stream;
@@ -1550,26 +3355,231 @@ export class ModelResult {
1550
3355
  if (!this.resolvedRequest) {
1551
3356
  throw new Error('Request not initialized');
1552
3357
  }
1553
- // Update resolvedRequest.input with accumulated conversation for next turn
3358
+ // Update resolvedRequest.input with accumulated conversation for next turn.
1554
3359
  this.resolvedRequest = {
1555
3360
  ...this.resolvedRequest,
1556
3361
  input: newInput,
1557
3362
  };
3363
+ // A forced tool choice (`required` or a specific tool) has served its
3364
+ // purpose once a tool round has executed; keeping it on follow-up turns
3365
+ // would forbid the model from ever answering in text, looping it through
3366
+ // tool calls until the step budget runs out (DEV-785). Relax it to `auto`
3367
+ // so the model can either call another tool or synthesize its answer.
3368
+ this.resolvedRequest = this.applyForcedToolChoicePolicy(this.resolvedRequest);
3369
+ // Escalation recovery: one-turn overrides (model swap / forced advisor
3370
+ // consult) applied to THIS dispatch only — `resolvedRequest` above keeps
3371
+ // the configured model/tools, so the next turn reverts automatically.
3372
+ const escalationOverrides = this.takeDoomLoopEscalationOverrides();
1558
3373
  const newRequest = {
1559
3374
  ...this.resolvedRequest,
3375
+ ...(escalationOverrides ?? {}),
1560
3376
  stream: true,
1561
3377
  };
3378
+ this.beginToolChoiceDispatch(newRequest.toolChoice);
1562
3379
  const startedAt = performance.now();
1563
3380
  const newResult = await betaResponsesSend(this.options.client, {
1564
3381
  responsesRequest: newRequest,
1565
- }, this.options.options);
3382
+ }, this.dispatchRequestOptions());
1566
3383
  if (!newResult.ok) {
1567
3384
  throw newResult.error;
1568
3385
  }
1569
3386
  const response = await this.materializeResponse(newResult.value, turnNumber);
3387
+ this.commitToolChoiceDispatch(responseHasToolCalls(response));
1570
3388
  await this.emitPostModelCall(response, startedAt, 'tool_round', turnNumber);
1571
3389
  return response;
1572
3390
  }
3391
+ /**
3392
+ * Append a response's output items to the accumulated request input,
3393
+ * exactly as `makeFollowupRequest` does before adding tool results. Used
3394
+ * by the end-of-run drain, where envelope messages must land AFTER the
3395
+ * final assistant output in the request.
3396
+ */
3397
+ accumulateResponseIntoInput(response) {
3398
+ if (!this.resolvedRequest) {
3399
+ return;
3400
+ }
3401
+ const originalInput = this.resolvedRequest.input;
3402
+ const normalizedOriginalInput = Array.isArray(originalInput)
3403
+ ? originalInput
3404
+ : originalInput
3405
+ ? [
3406
+ {
3407
+ role: 'user',
3408
+ content: originalInput,
3409
+ },
3410
+ ]
3411
+ : [];
3412
+ this.resolvedRequest = {
3413
+ ...this.resolvedRequest,
3414
+ input: [
3415
+ ...normalizedOriginalInput,
3416
+ ...(Array.isArray(response.output)
3417
+ ? response.output
3418
+ : [
3419
+ response.output,
3420
+ ]),
3421
+ ],
3422
+ };
3423
+ }
3424
+ /**
3425
+ * End-of-run handling for async background tasks still in flight (or
3426
+ * settled but unharvested) when the loop is about to finalize.
3427
+ *
3428
+ * - `'drain'` (default): wait for tasks (bounded by `drainTimeoutMs`),
3429
+ * inject their results, and give the model up to `maxDrainTurns` extra
3430
+ * `toolChoice: 'none'` turns so the final answer incorporates them.
3431
+ * - `'detach'`: return immediately; still-working tasks are marked
3432
+ * `orphaned` on persisted state and their eventual results are dropped.
3433
+ * - `'cancel'`: abort in-flight tasks and finish.
3434
+ *
3435
+ * @returns the (possibly replaced) final response.
3436
+ */
3437
+ async handleRunEndAsyncTasks(currentResponse) {
3438
+ const registry = this.asyncToolRegistry;
3439
+ if (!registry || (!registry.hasInFlight() && !registry.hasUnharvestedSettled())) {
3440
+ return currentResponse;
3441
+ }
3442
+ const config = this.options.asyncTools;
3443
+ const mode = config?.onRunEnd ?? 'drain';
3444
+ if (mode === 'cancel') {
3445
+ registry.abortAll('Run ended (onRunEnd: cancel)');
3446
+ await this.dropSettledTasks();
3447
+ return currentResponse;
3448
+ }
3449
+ if (mode === 'detach') {
3450
+ await this.detachWorkingTasks();
3451
+ return currentResponse;
3452
+ }
3453
+ // 'drain': deliver late results and let the model incorporate them.
3454
+ // Drain turns use the retry path (toolChoice forced to 'none' when tools
3455
+ // are present) so a drain turn cannot open a fresh tool round — it
3456
+ // exists purely to fold the delivered results into the final answer.
3457
+ // ONE deadline spans the whole drain phase: drainTimeoutMs bounds total
3458
+ // WAITING (documented contract), not per-turn waiting — maxDrainTurns
3459
+ // bounds the extra model turns, not the wait.
3460
+ const drainTimeoutMs = config?.drainTimeoutMs ?? 30000;
3461
+ const maxDrainTurns = config?.maxDrainTurns ?? 2;
3462
+ const drainDeadline = Date.now() + drainTimeoutMs;
3463
+ let response = currentResponse;
3464
+ for (let drainTurn = 0; drainTurn < maxDrainTurns; drainTurn++) {
3465
+ if (!registry.hasUnharvestedSettled() && registry.hasInFlight()) {
3466
+ const remaining = drainDeadline - Date.now();
3467
+ if (remaining <= 0) {
3468
+ break;
3469
+ }
3470
+ await registry.drain(remaining);
3471
+ }
3472
+ // Accumulate the current final output BEFORE injecting so the
3473
+ // envelope lands after the assistant's last message in the request.
3474
+ this.accumulateResponseIntoInput(response);
3475
+ // Queued steering (queueUserMessage / sendToTask) flushes here too —
3476
+ // a drain turn is a model dispatch, and guidance queued while the
3477
+ // last tool round was already finishing would otherwise be dropped.
3478
+ await this.flushDoomLoopSteer();
3479
+ const delivered = await this.flushAsyncToolDeliveries();
3480
+ if (!delivered) {
3481
+ // Drain timed out with nothing new settled — stop waiting.
3482
+ break;
3483
+ }
3484
+ const turnNumber = this.allToolExecutionRounds.length + 1 + drainTurn;
3485
+ response = await this.retryCurrentRequest(turnNumber);
3486
+ await this.saveResponseToState(response);
3487
+ if (!registry.hasInFlight() && !registry.hasUnharvestedSettled()) {
3488
+ break;
3489
+ }
3490
+ }
3491
+ // Drain budget exhausted with work still in flight: cut it loose.
3492
+ if (registry.hasInFlight()) {
3493
+ registry.abortAll('Async tool drain budget exhausted at run end');
3494
+ await this.dropSettledTasks();
3495
+ }
3496
+ return response;
3497
+ }
3498
+ /**
3499
+ * Broadcast every unharvested settlement as dropped (no in-run delivery),
3500
+ * mirror the terminal status onto persisted `pendingAsyncTools`, and
3501
+ * append a terminal `tool_task_result` envelope to persisted history.
3502
+ *
3503
+ * The status mirror prevents a stuck resume: an entry persisted as
3504
+ * `working` for a task that only ever settles in-memory (abortAll at
3505
+ * drain exhaustion / run cancel) would pin a later cross-process resume
3506
+ * to `awaiting_async_tools` forever. The envelope keeps the pending
3507
+ * placeholder's "the result will be delivered" promise honest for
3508
+ * PERSISTED conversations: the run is over (no more model dispatch), but
3509
+ * the terminal outcome rides along on the next `callModel({ state })`
3510
+ * instead of vanishing.
3511
+ */
3512
+ async dropSettledTasks() {
3513
+ const registry = this.asyncToolRegistry;
3514
+ if (!registry) {
3515
+ return;
3516
+ }
3517
+ const settled = registry.takeSettled();
3518
+ for (const task of settled) {
3519
+ this.broadcastAsyncSettled(task, 'dropped');
3520
+ }
3521
+ if (settled.length === 0 || !this.stateAccessor || !this.currentState) {
3522
+ return;
3523
+ }
3524
+ const terminalByCallId = new Map(settled.map((t) => [
3525
+ t.callId,
3526
+ t.status === 'completed' || t.status === 'cancelled' ? t.status : 'failed',
3527
+ ]));
3528
+ const envelopes = settled.map((t) => JSON.stringify({
3529
+ type: 'tool_task_result',
3530
+ tool: t.name,
3531
+ taskId: t.taskId,
3532
+ callId: t.callId,
3533
+ status: t.status,
3534
+ ...(t.result !== undefined && {
3535
+ result: t.result,
3536
+ }),
3537
+ ...(t.error !== undefined && {
3538
+ error: t.error,
3539
+ }),
3540
+ }));
3541
+ await this.saveStateSafely({
3542
+ messages: appendToMessages(this.currentState.messages, [
3543
+ {
3544
+ role: 'user',
3545
+ content: `${TASK_RESULT_BOUNDARY}\n${envelopes.join('\n')}`,
3546
+ },
3547
+ ]),
3548
+ ...(this.currentState.pendingAsyncTools?.length && {
3549
+ pendingAsyncTools: this.currentState.pendingAsyncTools.map((entry) => {
3550
+ const terminal = terminalByCallId.get(entry.callId);
3551
+ return terminal !== undefined
3552
+ ? {
3553
+ ...entry,
3554
+ status: terminal,
3555
+ }
3556
+ : entry;
3557
+ }),
3558
+ }),
3559
+ });
3560
+ }
3561
+ /**
3562
+ * `onRunEnd: 'detach'`: mark still-working tasks orphaned on persisted
3563
+ * state (their eventual results are dropped) and drop unharvested
3564
+ * settlements.
3565
+ */
3566
+ async detachWorkingTasks() {
3567
+ const registry = this.asyncToolRegistry;
3568
+ if (!registry) {
3569
+ return;
3570
+ }
3571
+ const orphaned = registry.markWorkingAsOrphaned();
3572
+ await this.dropSettledTasks();
3573
+ if (orphaned.length > 0 && this.stateAccessor && this.currentState) {
3574
+ const existing = (this.currentState.pendingAsyncTools ?? []).filter((t) => !orphaned.some((o) => o.callId === t.callId));
3575
+ await this.saveStateSafely({
3576
+ pendingAsyncTools: [
3577
+ ...existing,
3578
+ ...orphaned,
3579
+ ],
3580
+ });
3581
+ }
3582
+ }
1573
3583
  /**
1574
3584
  * Make a final no-tool-calls request to coerce a text response after the
1575
3585
  * loop was halted by `stopWhen` mid-tool-call. Reuses the resolved request
@@ -1632,7 +3642,7 @@ export class ModelResult {
1632
3642
  const startedAt = performance.now();
1633
3643
  const result = await betaResponsesSend(this.options.client, {
1634
3644
  responsesRequest: finalRequest,
1635
- }, this.options.options);
3645
+ }, this.dispatchRequestOptions());
1636
3646
  if (!result.ok) {
1637
3647
  throw result.error;
1638
3648
  }
@@ -1643,19 +3653,40 @@ export class ModelResult {
1643
3653
  /**
1644
3654
  * Validate the final response has required fields.
1645
3655
  *
3656
+ * Message prefixes (`Invalid final response: missing required fields`,
3657
+ * `Invalid final response: empty or invalid output`) are load-bearing —
3658
+ * callers and tests match on them. Detail is appended after an em dash;
3659
+ * never reword the prefixes.
3660
+ *
3661
+ * The detail exists because the bare "empty or invalid output" message was
3662
+ * routinely misread as "the model returned a tool call and validation
3663
+ * rejected it" (issue #45). It does not: this is a pure array-length check,
3664
+ * so a `function_call`-only output passes. Naming the actual defect —
3665
+ * empty array vs. wrong type — stops that misdiagnosis at the error string.
3666
+ *
1646
3667
  * @param response - The response to validate
1647
3668
  * @param allowEmptyOutput - When true, tolerate an empty (but present) output array
1648
3669
  * @throws Error if response is missing required fields or has invalid output
1649
3670
  */
1650
3671
  validateFinalResponse(response, allowEmptyOutput = false) {
1651
3672
  if (!response?.id || !response?.output) {
1652
- throw new Error('Invalid final response: missing required fields');
3673
+ const missing = [];
3674
+ if (!response?.id) {
3675
+ missing.push('id');
3676
+ }
3677
+ if (!response?.output) {
3678
+ missing.push('output');
3679
+ }
3680
+ throw new Error(`Invalid final response: missing required fields: ${missing.join(', ')}`);
1653
3681
  }
1654
3682
  if (!Array.isArray(response.output) || response.output.length === 0) {
1655
3683
  if (allowEmptyOutput) {
1656
3684
  return;
1657
3685
  }
1658
- throw new Error('Invalid final response: empty or invalid output');
3686
+ const detail = Array.isArray(response.output)
3687
+ ? `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`
3688
+ : `output is not an array (got ${describeNonRecord(response.output)}) for response "${response.id}"`;
3689
+ throw new Error(`Invalid final response: empty or invalid output — ${detail}`);
1659
3690
  }
1660
3691
  }
1661
3692
  /**
@@ -1684,7 +3715,7 @@ export class ModelResult {
1684
3715
  const startedAt = performance.now();
1685
3716
  const newResult = await betaResponsesSend(this.options.client, {
1686
3717
  responsesRequest: newRequest,
1687
- }, this.options.options);
3718
+ }, this.dispatchRequestOptions());
1688
3719
  if (!newResult.ok) {
1689
3720
  throw newResult.error;
1690
3721
  }
@@ -1692,6 +3723,47 @@ export class ModelResult {
1692
3723
  await this.emitPostModelCall(response, startedAt, 'retry', turnNumber);
1693
3724
  return response;
1694
3725
  }
3726
+ /**
3727
+ * Build the RequestOptions for one API dispatch, composing the run-level
3728
+ * cancellation signal (`options.signal`) with the caller's RequestOptions.
3729
+ *
3730
+ * The SDK only auto-wires `timeoutMs` into an abort signal when NO signal
3731
+ * is present on the options — a supplied signal silently disables the
3732
+ * timeout. Composing here keeps both: each request is bounded by
3733
+ * whichever of {run signal, caller signal, per-request timeout} fires
3734
+ * first. MUST be called once per dispatch — `AbortSignal.timeout()`
3735
+ * starts counting at creation, so a cached composite would burn its
3736
+ * budget across turns instead of per request.
3737
+ *
3738
+ * Also the per-request fail-fast checkpoint: an already-aborted run
3739
+ * signal throws its abort reason here, before any network I/O.
3740
+ */
3741
+ dispatchRequestOptions() {
3742
+ const base = this.options.options;
3743
+ const runSignal = this.options.signal;
3744
+ if (!runSignal) {
3745
+ return base;
3746
+ }
3747
+ runSignal.throwIfAborted();
3748
+ const signals = [
3749
+ runSignal,
3750
+ ];
3751
+ const callerSignal = base?.signal ?? base?.fetchOptions?.signal;
3752
+ if (callerSignal) {
3753
+ signals.push(callerSignal);
3754
+ }
3755
+ // Re-create the timeout the SDK would have wired had no signal been
3756
+ // present. Per-request option wins over the client-level default,
3757
+ // mirroring betaResponsesSend's own precedence.
3758
+ const timeoutMs = base?.timeoutMs ?? this.options.client._options.timeoutMs;
3759
+ if (typeof timeoutMs === 'number' && timeoutMs > 0) {
3760
+ signals.push(AbortSignal.timeout(timeoutMs));
3761
+ }
3762
+ return {
3763
+ ...base,
3764
+ signal: signals.length === 1 ? runSignal : AbortSignal.any(signals),
3765
+ };
3766
+ }
1695
3767
  /**
1696
3768
  * Resolve async functions in the request for a given turn context.
1697
3769
  * Extracts non-function fields and resolves any async parameter functions.
@@ -1701,15 +3773,106 @@ export class ModelResult {
1701
3773
  */
1702
3774
  async resolveRequestForContext(context) {
1703
3775
  if (hasAsyncFunctions(this.options.request)) {
1704
- return resolveAsyncFunctions(this.options.request, context);
3776
+ const resolved = await resolveAsyncFunctions(this.options.request, context);
3777
+ return this.applyResolvedForcedToolChoicePolicy(resolved);
1705
3778
  }
1706
3779
  // Already resolved, extract non-function fields.
1707
3780
  // Strip ALL client-only fields — keep this list in sync with
1708
3781
  // `clientOnlyFields` in async-params.ts, which handles the async path.
1709
3782
  // (`sharedContextSchema` is absent here: call-model.ts destructures it
1710
3783
  // 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;
3784
+ 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, ...rest } = this.options.request;
3785
+ return this.applyResolvedForcedToolChoicePolicy(rest);
3786
+ }
3787
+ /**
3788
+ * Record the freshly-resolved caller choice before deriving its wire value.
3789
+ * An unforced turn clears the consumed key, re-arming the same forced value
3790
+ * if it appears again later. A different forced key re-arms immediately.
3791
+ */
3792
+ applyResolvedForcedToolChoicePolicy(request) {
3793
+ this.configuredToolChoice = request.toolChoice;
3794
+ this.configuredForcedToolChoiceKey = forcedToolChoiceKey(request.toolChoice);
3795
+ if (this.configuredForcedToolChoiceKey === null) {
3796
+ this.preparedToolChoiceCommit =
3797
+ this.activeConsumedForcedToolChoiceKey === null
3798
+ ? null
3799
+ : {
3800
+ type: 'clear',
3801
+ };
3802
+ }
3803
+ else {
3804
+ this.preparedToolChoiceCommit =
3805
+ this.configuredForcedToolChoiceKey === this.activeConsumedForcedToolChoiceKey
3806
+ ? null
3807
+ : {
3808
+ type: 'consume',
3809
+ key: this.configuredForcedToolChoiceKey,
3810
+ };
3811
+ }
3812
+ return this.applyForcedToolChoicePolicy(request);
3813
+ }
3814
+ /**
3815
+ * Derive the caller-configured tool choice for the active logical run.
3816
+ * Engine-owned one-turn overrides (`none` for finalization, or a pinned
3817
+ * doom-loop advisor) are applied after this helper and therefore still win.
3818
+ */
3819
+ applyForcedToolChoicePolicy(request) {
3820
+ const toolChoice = this.configuredForcedToolChoiceKey !== null &&
3821
+ this.configuredForcedToolChoiceKey === this.activeConsumedForcedToolChoiceKey
3822
+ ? relaxForcedToolChoice(this.configuredToolChoice)
3823
+ : this.configuredToolChoice;
3824
+ this.preparedEffectiveToolChoice = toolChoice;
3825
+ return toolChoice === request.toolChoice
3826
+ ? request
3827
+ : {
3828
+ ...request,
3829
+ toolChoice,
3830
+ };
3831
+ }
3832
+ /**
3833
+ * Bind the prepared caller-policy transition to the actual wire request.
3834
+ * Engine-owned overrides (for example a forced advisor consult) deliberately
3835
+ * produce a different semantic key and therefore cannot commit caller state.
3836
+ */
3837
+ beginToolChoiceDispatch(actualToolChoice) {
3838
+ this.dispatchedCallerToolChoice =
3839
+ canonicalizeKeyMaterial(actualToolChoice ?? null) ===
3840
+ canonicalizeKeyMaterial(this.preparedEffectiveToolChoice ?? null);
3841
+ this.dispatchedToolChoiceCommit = this.dispatchedCallerToolChoice
3842
+ ? this.preparedToolChoiceCommit
3843
+ : null;
3844
+ }
3845
+ /**
3846
+ * Commit only after the prepared caller choice reached the wire and a
3847
+ * response was materialized. A pause before dispatch leaves persisted state
3848
+ * untouched; a forced choice is consumed only when that response called a
3849
+ * tool.
3850
+ */
3851
+ commitToolChoiceDispatch(hasToolCalls) {
3852
+ const callerChoiceDispatched = this.dispatchedCallerToolChoice;
3853
+ const commit = this.dispatchedToolChoiceCommit;
3854
+ this.dispatchedCallerToolChoice = false;
3855
+ this.dispatchedToolChoiceCommit = null;
3856
+ if (!callerChoiceDispatched) {
3857
+ return;
3858
+ }
3859
+ this.preparedToolChoiceCommit = null;
3860
+ if (commit?.type === 'clear') {
3861
+ this.activeConsumedForcedToolChoiceKey = null;
3862
+ }
3863
+ else if (commit?.type === 'consume' && hasToolCalls) {
3864
+ this.activeConsumedForcedToolChoiceKey = commit.key;
3865
+ }
3866
+ }
3867
+ /** Clear all runtime and persisted policy state for a terminal run exit. */
3868
+ resetForcedToolChoicePolicy() {
3869
+ this.activeConsumedForcedToolChoiceKey = null;
3870
+ this.configuredForcedToolChoiceKey = null;
3871
+ this.configuredToolChoice = undefined;
3872
+ this.preparedEffectiveToolChoice = undefined;
3873
+ this.preparedToolChoiceCommit = null;
3874
+ this.dispatchedToolChoiceCommit = null;
3875
+ this.dispatchedCallerToolChoice = false;
1713
3876
  }
1714
3877
  /**
1715
3878
  * Apply `onResponseReceived` hooks to the freshly-supplied input items
@@ -1791,6 +3954,72 @@ export class ModelResult {
1791
3954
  if (updates) {
1792
3955
  this.currentState = updateState(this.currentState, updates);
1793
3956
  }
3957
+ // Persist only the consumed semantic key, never the callback or a copied
3958
+ // effective toolChoice. Dynamic values are re-resolved on every turn.
3959
+ if (this.activeConsumedForcedToolChoiceKey !== null) {
3960
+ this.currentState = {
3961
+ ...this.currentState,
3962
+ consumedForcedToolChoiceKey: this.activeConsumedForcedToolChoiceKey,
3963
+ };
3964
+ }
3965
+ else if (this.currentState.consumedForcedToolChoiceKey !== undefined) {
3966
+ const nextState = {
3967
+ ...this.currentState,
3968
+ };
3969
+ delete nextState.consumedForcedToolChoiceKey;
3970
+ this.currentState = nextState;
3971
+ }
3972
+ // Piggyback the doom-loop detector snapshot on every persist so loop
3973
+ // memory survives serialize → resume (a resumed doom loop is still a
3974
+ // doom loop — provided the resuming call passes `doomLoop` again).
3975
+ // Plain bounded JSON; absent when detection is off. The engine-owned
3976
+ // fields ride along: `stopVerdict` keeps a condemned run condemned
3977
+ // across decision-only resumes; `pendingSteer` carries queued guidance
3978
+ // that had no flush opportunity before a pause.
3979
+ if (this.doomLoopMonitor) {
3980
+ const doomLoopState = {
3981
+ ...this.doomLoopMonitor.getState(),
3982
+ ...(this.doomLoopStop !== null && {
3983
+ stopVerdict: this.doomLoopStop,
3984
+ }),
3985
+ ...(this.pendingDoomLoopSteer.length > 0 && {
3986
+ pendingSteer: [
3987
+ ...this.pendingDoomLoopSteer,
3988
+ ],
3989
+ }),
3990
+ };
3991
+ this.currentState = {
3992
+ ...this.currentState,
3993
+ doomLoop: doomLoopState,
3994
+ };
3995
+ }
3996
+ // Mirror each live task's lastLog onto its persisted pendingAsyncTools
3997
+ // entry (bounded ~200 chars) — the one piece of progress that survives
3998
+ // a restart, surfaced by post-restart check calls. Debounced naturally:
3999
+ // this runs only when state is being saved anyway, never per log entry.
4000
+ if (this.asyncToolRegistry && this.currentState.pendingAsyncTools?.length) {
4001
+ const registry = this.asyncToolRegistry;
4002
+ this.currentState = {
4003
+ ...this.currentState,
4004
+ pendingAsyncTools: this.currentState.pendingAsyncTools.map((entry) => {
4005
+ const live = registry.findByCallId(entry.callId);
4006
+ const lastLog = live?.lastLog;
4007
+ if (!lastLog) {
4008
+ return entry;
4009
+ }
4010
+ const text = typeof lastLog.data === 'string'
4011
+ ? lastLog.data
4012
+ : (JSON.stringify(lastLog.data) ?? String(lastLog.data));
4013
+ return {
4014
+ ...entry,
4015
+ lastLog: {
4016
+ at: lastLog.at,
4017
+ text: text.length > 200 ? `${text.slice(0, 200)}…` : text,
4018
+ },
4019
+ };
4020
+ }),
4021
+ };
4022
+ }
1794
4023
  try {
1795
4024
  await this.stateAccessor.save(this.currentState);
1796
4025
  }
@@ -1831,6 +4060,34 @@ export class ModelResult {
1831
4060
  const loadedState = await this.stateAccessor.load();
1832
4061
  if (loadedState) {
1833
4062
  this.currentState = loadedState;
4063
+ this.activeConsumedForcedToolChoiceKey = loadedState.consumedForcedToolChoiceKey ?? null;
4064
+ // Rehydrate doom-loop state so a resumed run continues where it
4065
+ // left off: streak counters always; the queued steer guidance;
4066
+ // and the stop verdict under the condemnation rule below.
4067
+ if (this.doomLoopMonitor && loadedState.doomLoop !== undefined) {
4068
+ this.doomLoopMonitor.restore(loadedState.doomLoop);
4069
+ const persisted = loadedState.doomLoop;
4070
+ if (Array.isArray(persisted.pendingSteer)) {
4071
+ for (const message of persisted.pendingSteer) {
4072
+ if (typeof message === 'string') {
4073
+ this.queueDoomLoopSteer(message);
4074
+ }
4075
+ }
4076
+ }
4077
+ // Condemnation rule: a doom-stopped conversation STAYS stopped
4078
+ // across decision-only resumes (approveToolCalls /
4079
+ // rejectToolCalls — approving a call is not new conversation).
4080
+ // A fresh conversational turn (a plain callModel on the same
4081
+ // state, no decisions) clears the verdict: operator input is
4082
+ // new information. Streaks are kept either way, so renewed
4083
+ // repetition re-condemns quickly.
4084
+ if (persisted.stopVerdict) {
4085
+ const isDecisionResume = this.approvedToolCalls.length > 0 || this.rejectedToolCalls.length > 0;
4086
+ if (isDecisionResume) {
4087
+ this.doomLoopStop = persisted.stopVerdict;
4088
+ }
4089
+ }
4090
+ }
1834
4091
  // Check if we're resuming from awaiting_approval or awaiting_hitl
1835
4092
  // with decisions. `awaiting_hitl` reuses `processApprovalDecisions`
1836
4093
  // because the resume mechanism is identical — the caller supplies
@@ -1871,13 +4128,18 @@ export class ModelResult {
1871
4128
  }
1872
4129
  // Keep manual calls durable until the resumed request produces a
1873
4130
  // response. Clearing before the API call would lose the only copy if
1874
- // that request failed.
4131
+ // that request failed. `awaiting_async_tools` resumes the same way:
4132
+ // the pending-task bookkeeping was already updated by
4133
+ // resumeToolResults (or remains pending for tasks not yet resolved),
4134
+ // and the placeholder outputs are in history, so a fresh request is
4135
+ // valid.
1875
4136
  this.resumingFromClientTools = loadedState.status === 'awaiting_client_tools';
4137
+ this.resumingFromAsyncTools = loadedState.status === 'awaiting_async_tools';
1876
4138
  }
1877
4139
  else {
1878
4140
  this.currentState = createInitialState();
1879
4141
  }
1880
- if (!this.resumingFromClientTools) {
4142
+ if (!this.resumingFromClientTools && !this.resumingFromAsyncTools) {
1881
4143
  await this.saveStateSafely({
1882
4144
  status: 'in_progress',
1883
4145
  });
@@ -2003,9 +4265,10 @@ export class ModelResult {
2003
4265
  turnNumber: 0,
2004
4266
  };
2005
4267
  // Make the API request
4268
+ this.beginToolChoiceDispatch(request.toolChoice);
2006
4269
  const apiResult = await betaResponsesSend(this.options.client, {
2007
4270
  responsesRequest: request,
2008
- }, this.options.options);
4271
+ }, this.dispatchRequestOptions());
2009
4272
  if (!apiResult.ok) {
2010
4273
  throw apiResult.error;
2011
4274
  }
@@ -2051,6 +4314,16 @@ export class ModelResult {
2051
4314
  // Track approved HITL calls that paused (onToolCalled returned null) —
2052
4315
  // these stay in pendingToolCalls so the caller can resume them later.
2053
4316
  const hitlPausedIds = new Set();
4317
+ // The approved batch is one doom-loop round: N approved duplicates of
4318
+ // the same call count once (the sequential loop below still evaluates
4319
+ // in order; restored streaks from the persisted state carry forward).
4320
+ // Declared from the approved calls only — a pending call the user did not
4321
+ // approve is not part of this round.
4322
+ await this.beginDoomLoopRound([
4323
+ ...this.approvedToolCalls,
4324
+ ]
4325
+ .map((callId) => pendingCalls.find((tc) => tc.id === callId))
4326
+ .filter((tc) => tc !== undefined));
2054
4327
  // Process approvals - execute the approved tools. Route through
2055
4328
  // runToolWithHooks so PreToolUse/PostToolUse fire even on this path.
2056
4329
  for (const callId of this.approvedToolCalls) {
@@ -2083,6 +4356,10 @@ export class ModelResult {
2083
4356
  hitlPausedIds.add(callId);
2084
4357
  continue;
2085
4358
  }
4359
+ if (isAsyncToolInvocation(result)) {
4360
+ unsentResults.push(await this.settleAsyncInvocationAsUnsent(toolCall, result));
4361
+ continue;
4362
+ }
2086
4363
  if (result.error) {
2087
4364
  unsentResults.push(createRejectedResult(callId, String(toolCall.name), result.error.message));
2088
4365
  }
@@ -2131,6 +4408,11 @@ export class ModelResult {
2131
4408
  if (unsentResults.length > 0) {
2132
4409
  stateUpdates.unsentToolResults = unsentResults;
2133
4410
  }
4411
+ if (nextStatus === 'in_progress' && this.doomLoopStop) {
4412
+ // This approval continuation is terminal: no model request follows, so
4413
+ // a later fresh run must not inherit its consumed caller choice.
4414
+ this.resetForcedToolChoicePolicy();
4415
+ }
2134
4416
  await this.saveStateSafely(stateUpdates);
2135
4417
  // Clear optional properties if they should be empty
2136
4418
  const propsToClear = [];
@@ -2144,10 +4426,22 @@ export class ModelResult {
2144
4426
  this.clearOptionalStateProperties(propsToClear);
2145
4427
  await this.saveStateSafely();
2146
4428
  }
2147
- // If we are paused (for approval or for HITL), stop here
4429
+ // If we are paused (for approval or for HITL), stop here. Queued steer
4430
+ // guidance rides doomLoop.pendingSteer in the state persisted above;
4431
+ // the resumed run delivers it at its next safe flush point (injecting a
4432
+ // user message between dangling function_calls and their future
4433
+ // outputs would be invalid history).
2148
4434
  if (nextStatus !== 'in_progress') {
2149
4435
  return;
2150
4436
  }
4437
+ // A doom-stop armed while executing the approved calls (the restored
4438
+ // streak crossed the stop rung): halt WITHOUT the unsent-results model
4439
+ // request. The executed outputs are already persisted as unsent; the
4440
+ // condemned call got a blocked output. Session teardown reports the
4441
+ // doom reason via the armed verdict (see executeToolsIfNeeded).
4442
+ if (this.doomLoopStop) {
4443
+ return;
4444
+ }
2151
4445
  // Otherwise, continue with tool execution using unsent results
2152
4446
  await this.continueWithUnsentResults();
2153
4447
  }
@@ -2203,9 +4497,10 @@ export class ModelResult {
2203
4497
  turnNumber: turnContext.numberOfTurns,
2204
4498
  };
2205
4499
  // Make the API request
4500
+ this.beginToolChoiceDispatch(request.toolChoice);
2206
4501
  const apiResult = await betaResponsesSend(this.options.client, {
2207
4502
  responsesRequest: request,
2208
- }, this.options.options);
4503
+ }, this.dispatchRequestOptions());
2209
4504
  if (!apiResult.ok) {
2210
4505
  throw apiResult.error;
2211
4506
  }
@@ -2247,13 +4542,35 @@ export class ModelResult {
2247
4542
  this.currentState?.status === 'awaiting_hitl')) {
2248
4543
  return;
2249
4544
  }
4545
+ // A doom-stop armed while executing approved calls on resume:
4546
+ // processApprovalDecisions already skipped the unsent-results model
4547
+ // request; the executed outputs are persisted as unsent for a later
4548
+ // (fresh-input) resume. Halt here — like a pause, no stream exists.
4549
+ if (this.isResumingFromApproval && this.doomLoopStop) {
4550
+ sessionEndReason = 'doom_loop';
4551
+ this.resetForcedToolChoicePolicy();
4552
+ await this.saveStateSafely();
4553
+ return;
4554
+ }
2250
4555
  // Get initial response
2251
4556
  let currentResponse = await this.getInitialResponse();
4557
+ // toolChoice constrains model output, so a forced requirement is
4558
+ // satisfied as soon as this turn emits a tool call. Record it before
4559
+ // any approval/HITL/client/async pause can persist the run.
4560
+ const hasToolCalls = responseHasToolCalls(currentResponse);
4561
+ this.commitToolChoiceDispatch(hasToolCalls);
2252
4562
  // Save initial response to state
2253
4563
  await this.saveResponseToState(currentResponse);
4564
+ // Doom-loop text checkpoint for the initial response (within-response
4565
+ // token repetition + cross-step streak seed).
4566
+ await this.checkDoomLoopForResponse(currentResponse);
2254
4567
  // Check if tools should be executed
2255
- const hasToolCalls = currentResponse.output.some((item) => hasTypeProperty(item) && item.type === 'function_call');
2256
4568
  if (!this.options.tools?.length || !hasToolCalls) {
4569
+ // A text verdict on the initial response may have armed a stop —
4570
+ // report the run as doom-stopped, not as a normal completion.
4571
+ if (this.doomLoopStop) {
4572
+ sessionEndReason = 'doom_loop';
4573
+ }
2257
4574
  // No tool work: keep hard throw on empty/invalid final output.
2258
4575
  this.validateFinalResponse(currentResponse);
2259
4576
  this.finalResponse = currentResponse;
@@ -2282,11 +4599,27 @@ export class ModelResult {
2282
4599
  // runStopHook for the cap.
2283
4600
  let forceResumeCount = 0;
2284
4601
  while (true) {
4602
+ // Run-level cancellation: fail the loop at the turn boundary with
4603
+ // the abort reason. In-flight requests are aborted independently
4604
+ // by the composed dispatch signal (dispatchRequestOptions); this
4605
+ // check covers cancellation between requests (during tool
4606
+ // execution or hook work).
4607
+ this.options.signal?.throwIfAborted();
2285
4608
  // Check for external interruption
2286
4609
  if (await this.checkForInterruption(currentResponse)) {
2287
4610
  sessionEndReason = 'user';
2288
4611
  return;
2289
4612
  }
4613
+ // A doom-loop 'stop' verdict from the previous response's text
4614
+ // halts the run before any further spend. Seal first: this break
4615
+ // fires BEFORE this response's tool calls execute, so they need
4616
+ // synthesized halt outputs or the persisted history would carry
4617
+ // dangling function_calls (providers 400 on resume).
4618
+ if (this.doomLoopStop) {
4619
+ await this.sealDoomLoopStop(currentResponse);
4620
+ sessionEndReason = 'doom_loop';
4621
+ break;
4622
+ }
2290
4623
  // Check stop conditions
2291
4624
  if (await this.shouldStopExecution()) {
2292
4625
  // Emit the Stop hook -- handlers can force resume or inject a
@@ -2331,7 +4664,7 @@ export class ModelResult {
2331
4664
  // Resolve async functions for this turn
2332
4665
  await this.resolveAsyncFunctionsForTurn(turnContext);
2333
4666
  // Execute tools
2334
- const { toolResults, pausedCalls } = await this.executeToolRound(currentToolCalls, turnContext);
4667
+ const { toolResults, pausedCalls, deferredTasks = [], } = await this.executeToolRound(currentToolCalls, turnContext);
2335
4668
  // A tool round with observable progress resets the consecutive
2336
4669
  // forceResume counter so a legitimate override earlier in the run
2337
4670
  // does not count against a later, independent one.
@@ -2384,6 +4717,15 @@ export class ModelResult {
2384
4717
  await this.persistHitlPause(currentResponse, pausedCalls);
2385
4718
  return;
2386
4719
  }
4720
+ // Deferred tools started durable external tasks this round: pause.
4721
+ // Unlike HITL, the round is fully paired (placeholder outputs were
4722
+ // persisted above), so the resumed conversation history is valid;
4723
+ // the pause exists so the model's next turn can incorporate the
4724
+ // real results supplied via .resolve()/resumeToolResults().
4725
+ if (deferredTasks.length > 0) {
4726
+ await this.persistAsyncToolPause(currentResponse, deferredTasks);
4727
+ return;
4728
+ }
2387
4729
  // Manual (client-executed) tools produce no output this round —
2388
4730
  // `executeToolRound` returns nothing for them — so a mixed round of
2389
4731
  // auto-executed and manual calls would otherwise send a follow-up
@@ -2403,6 +4745,27 @@ export class ModelResult {
2403
4745
  await this.persistClientToolsPause(currentResponse, unresolvedToolCalls);
2404
4746
  return;
2405
4747
  }
4748
+ // A doom-loop 'stop' verdict armed during this round halts the run
4749
+ // before the next model request. Seal defensively: the condemned
4750
+ // call already got a blocked error output, but a TEXT stop armed
4751
+ // by a concurrent checkpoint could leave other calls unresolved.
4752
+ // No final text-coercion request is made: the point of 'stop' is
4753
+ // to cut spend on a run that is demonstrably not progressing.
4754
+ if (this.doomLoopStop) {
4755
+ await this.sealDoomLoopStop(currentResponse, new Set(toolResults.map((r) => r.callId)));
4756
+ sessionEndReason = 'doom_loop';
4757
+ break;
4758
+ }
4759
+ // Inject any queued doom-loop steer guidance so the very next
4760
+ // request carries it. It lands in the input before this round's
4761
+ // call/output items (the injection point predates the follow-up
4762
+ // accumulation) — semantically fine, the guidance names the
4763
+ // repeated pattern explicitly.
4764
+ await this.flushDoomLoopSteer();
4765
+ // Inject any background-tool results that settled since the last
4766
+ // flush. Same safe point as the steer flush: the round is fully
4767
+ // paired, so a user-role envelope here is valid history.
4768
+ await this.flushAsyncToolDeliveries();
2406
4769
  // Apply nextTurnParams
2407
4770
  await this.applyNextTurnParams(currentToolCalls);
2408
4771
  currentResponse = await this.makeFollowupRequest(currentResponse, toolResults, turnNumber);
@@ -2412,6 +4775,8 @@ export class ModelResult {
2412
4775
  await this.options.onTurnEnd?.(turnContext, currentResponse);
2413
4776
  // Save new response to state
2414
4777
  await this.saveResponseToState(currentResponse);
4778
+ // Doom-loop text checkpoint for the fresh response.
4779
+ await this.checkDoomLoopForResponse(currentResponse);
2415
4780
  currentRound++;
2416
4781
  }
2417
4782
  // If stopWhen broke the loop while the model was still emitting tool
@@ -2434,7 +4799,7 @@ export class ModelResult {
2434
4799
  };
2435
4800
  await this.options.onTurnStart?.(turnContext);
2436
4801
  await this.resolveAsyncFunctionsForTurn(turnContext);
2437
- const { toolResults, pausedCalls } = await this.executeToolRound(pendingToolCalls, turnContext);
4802
+ const { toolResults, pausedCalls, deferredTasks: finalDeferredTasks = [], } = await this.executeToolRound(pendingToolCalls, turnContext);
2438
4803
  // Track the executed round and persist real outputs BEFORE the HITL
2439
4804
  // pause check — mirrors the in-loop ordering at executeToolsIfNeeded
2440
4805
  // so a partial batch (HITL + regular tools) doesn't drop the regular
@@ -2455,30 +4820,49 @@ export class ModelResult {
2455
4820
  await this.persistHitlPause(currentResponse, pausedCalls);
2456
4821
  return;
2457
4822
  }
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);
4823
+ if (finalDeferredTasks.length > 0) {
4824
+ // Deferred tasks started on the halted turn — persist the pause
4825
+ // and skip the final text-coercion request; the resumed run
4826
+ // produces the final answer with the real results in hand.
4827
+ await this.persistAsyncToolPause(currentResponse, finalDeferredTasks);
4828
+ return;
4829
+ }
4830
+ // A doom-stop armed while executing the halted turn's pending
4831
+ // calls: seal and skip the final text-coercion request — the
4832
+ // design contract is "no further model requests after stop".
4833
+ if (this.doomLoopStop) {
4834
+ await this.sealDoomLoopStop(currentResponse, new Set(toolResults.map((r) => r.callId)));
4835
+ sessionEndReason = 'doom_loop';
4836
+ }
4837
+ else {
4838
+ // Apply any nextTurnParams from the executed tools so they affect
4839
+ // the final text-coercion request (mirrors the in-loop behavior).
4840
+ await this.applyNextTurnParams(pendingToolCalls);
4841
+ // Pair any manual tool calls (no execute fn) with stub outputs so
4842
+ // every function_call in the *request* has a matching output.
4843
+ // Stubs are NOT persisted to state — only real tool outputs are —
4844
+ // so a resumed conversation doesn't see "Tool execution skipped"
4845
+ // as if it were a real result.
4846
+ const executedCallIds = new Set(toolResults.map((r) => r.callId));
4847
+ const stubOutputs = pendingToolCalls
4848
+ .filter((tc) => !executedCallIds.has(tc.id))
4849
+ .map((tc) => ({
4850
+ type: 'function_call_output',
4851
+ callId: tc.id,
4852
+ output: 'Tool execution skipped: step limit reached.',
4853
+ }));
4854
+ const requestOutputs = [
4855
+ ...toolResults,
4856
+ ...stubOutputs,
4857
+ ];
4858
+ currentResponse = await this.makeFinalResponseRequest(currentResponse, requestOutputs, allowFinalResponse, turnNumber);
4859
+ await this.options.onTurnEnd?.(turnContext, currentResponse);
4860
+ await this.saveResponseToState(currentResponse);
4861
+ }
2481
4862
  }
4863
+ // Background tasks still in flight (or settled but undelivered) as
4864
+ // the loop finalizes: drain / detach / cancel per asyncTools config.
4865
+ currentResponse = await this.handleRunEndAsyncTasks(currentResponse);
2482
4866
  // Validate and finalize. Mini-class models intermittently return an
2483
4867
  // empty final turn after a successful tool round (the tool call was
2484
4868
  // the answer). Retry once, then tolerate empty output so a completed
@@ -2502,6 +4886,9 @@ export class ModelResult {
2502
4886
  }
2503
4887
  catch (error) {
2504
4888
  sessionEndReason = 'error';
4889
+ // A failing (or aborted) run must not leave tool bodies or
4890
+ // background tasks running against a dead conversation.
4891
+ this.abortAllToolWork(error instanceof Error ? error.message : String(error));
2505
4892
  throw error;
2506
4893
  }
2507
4894
  finally {
@@ -2552,6 +4939,10 @@ export class ModelResult {
2552
4939
  * Get the complete response object including usage information.
2553
4940
  * This will consume the stream until completion and execute any tools.
2554
4941
  * Returns the full OpenResponsesResult with usage data (inputTokens, outputTokens, cachedTokens, etc.)
4942
+ *
4943
+ * Note: in a multi-round tool loop this is the **final** round's response
4944
+ * only — its `usage` block covers that one generation, not the run. For
4945
+ * aggregate totals across every model call, use `getUsage()`.
2555
4946
  */
2556
4947
  async getResponse() {
2557
4948
  await this.executeToolsIfNeeded();
@@ -2640,6 +5031,17 @@ export class ModelResult {
2640
5031
  * - file_search_call: File search operations
2641
5032
  * - image_generation_call: Image generation operations
2642
5033
  * - function_call_output: Results from executed tools
5034
+ *
5035
+ * This stream carries **output items only** — no usage or response-level
5036
+ * metadata. The `response.completed` events (which hold each round's usage
5037
+ * block) are not surfaced here, and `getResponse()` afterwards returns only
5038
+ * the *final* round's response, so a multi-round tool loop's `tool_calls`
5039
+ * generations are not accounted for by either. For usage, reach for:
5040
+ * - `getUsage()` — aggregate token/cost totals across every round
5041
+ * - the `PostModelCall` hook — one emit per model call, with
5042
+ * `turnType`/`turnNumber` and that call's own `usage`
5043
+ * - `getFullResponsesStream()` — raw events including each round's
5044
+ * `response.completed`, and therefore its per-round usage block
2643
5045
  */
2644
5046
  getItemsStream() {
2645
5047
  // Build the allowed-item-type scope from the tools actually passed to
@@ -3062,23 +5464,82 @@ export class ModelResult {
3062
5464
  }
3063
5465
  }
3064
5466
  /**
3065
- * Cancel the underlying stream and all consumers
5467
+ * Cancel the underlying stream and all consumers, and abort in-flight
5468
+ * tool executions and background tasks (their `ctx.signal` fires).
5469
+ *
5470
+ * Note: prior releases only cancelled the stream; aborting tool work is
5471
+ * new with async tool support.
3066
5472
  */
3067
5473
  async cancel() {
5474
+ this.abortAllToolWork('Cancelled via ModelResult.cancel()');
3068
5475
  if (this.reusableStream) {
3069
5476
  await this.reusableStream.cancel();
3070
5477
  }
3071
5478
  }
5479
+ /**
5480
+ * Snapshot of every async tool task tracked by this run (background and
5481
+ * deferred), including settled ones.
5482
+ */
5483
+ getAsyncTasks() {
5484
+ return this.asyncToolRegistry?.snapshot() ?? [];
5485
+ }
5486
+ /**
5487
+ * Cancel one in-process async task by its task id. Background tasks abort
5488
+ * their `ctx.signal`; the model receives a `status: 'cancelled'` envelope
5489
+ * at the next delivery point so it is not left waiting forever.
5490
+ *
5491
+ * For deferred tasks in a paused (possibly other-process) conversation,
5492
+ * use the deferred tool's `.cancel()` method instead.
5493
+ *
5494
+ * @returns true when a working task was found and cancelled.
5495
+ */
5496
+ cancelTask(taskId, reason) {
5497
+ return this.asyncToolRegistry?.cancelTask(taskId, reason) ?? false;
5498
+ }
5499
+ /**
5500
+ * Send a steering message to a running in-process task. Delivered to the
5501
+ * run body's `ctx.onMessage` handler (queued until one registers). Agent
5502
+ * tools (`tool.agent()`) auto-forward messages into the child
5503
+ * conversation as user messages at the child's next turn boundary.
5504
+ *
5505
+ * Throws for deferred tasks — their work runs in an external system;
5506
+ * steer it there.
5507
+ *
5508
+ * @returns true when a working task received (or queued) the message.
5509
+ */
5510
+ sendToTask(taskId, message) {
5511
+ return this.asyncToolRegistry?.sendToTask(taskId, message) ?? false;
5512
+ }
5513
+ /**
5514
+ * Queue a user-role message for injection into this run's conversation at
5515
+ * the next safe turn boundary (the same mechanism doom-loop steer
5516
+ * guidance uses — never between a dangling `function_call` and its
5517
+ * output). The primary consumer is `tool.agent()`, which forwards
5518
+ * steering messages into child conversations through this.
5519
+ *
5520
+ * Delivery requires a next dispatch: boundaries are post-tool-round
5521
+ * follow-ups, pause-persists, and end-of-run drain turns. A message
5522
+ * queued while the run is composing its FINAL text response (no further
5523
+ * dispatch of any kind) has no boundary left and is not delivered —
5524
+ * steer while the run still has work in flight.
5525
+ */
5526
+ queueUserMessage(text) {
5527
+ this.queueDoomLoopSteer(text);
5528
+ }
3072
5529
  // =========================================================================
3073
5530
  // Multi-Turn Conversation State Methods
3074
5531
  // =========================================================================
3075
5532
  /**
3076
5533
  * 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.
5534
+ * Returns true when the conversation is paused waiting on the CALLER —
5535
+ * approval/rejection (`awaiting_approval`), HITL tool resume
5536
+ * (`awaiting_hitl`), client-executed manual tools
5537
+ * (`awaiting_client_tools`), or an external async task resolution
5538
+ * (`awaiting_async_tools`, cleared via the deferred tool's `.resolve()` /
5539
+ * `.fail()` / `.cancel()` or `resumeToolResults()` — NOT via
5540
+ * `approveToolCalls`). Also returns true whenever `pendingToolCalls` is
5541
+ * populated regardless of status. To branch on the pause KIND, read
5542
+ * `(await getState()).status` instead.
3082
5543
  */
3083
5544
  async requiresApproval() {
3084
5545
  await this.initStreamGuarded({
@@ -3087,7 +5548,8 @@ export class ModelResult {
3087
5548
  const status = this.currentState?.status;
3088
5549
  if (status === 'awaiting_approval' ||
3089
5550
  status === 'awaiting_hitl' ||
3090
- status === 'awaiting_client_tools') {
5551
+ status === 'awaiting_client_tools' ||
5552
+ status === 'awaiting_async_tools') {
3091
5553
  return true;
3092
5554
  }
3093
5555
  // Also check if pendingToolCalls is populated
@@ -3126,5 +5588,139 @@ export class ModelResult {
3126
5588
  }
3127
5589
  return this.currentState;
3128
5590
  }
5591
+ /**
5592
+ * The doom-loop verdict that stopped this run, or null when the run was
5593
+ * not stopped by doom-loop detection (including when detection is off).
5594
+ * Resolves after the tool loop settles. For per-event observation use the
5595
+ * `DoomLoopDetected` hook.
5596
+ */
5597
+ async getDoomLoopVerdict() {
5598
+ await this.initStreamGuarded({
5599
+ requireStream: false,
5600
+ });
5601
+ if (!this.isResumingFromApproval) {
5602
+ await this.executeToolsIfNeeded();
5603
+ }
5604
+ return this.doomLoopStop;
5605
+ }
5606
+ /**
5607
+ * Aggregate token/cost usage across **every** model call this run made —
5608
+ * the initial request, each tool-round follow-up, the empty-final retry,
5609
+ * the `allowFinalResponse` final turn, and approval-resume requests.
5610
+ *
5611
+ * This is the pull-based counterpart to the `SessionEnd.totalUsage` hook
5612
+ * payload (same `SessionUsageTotals` shape, same numbers) and exists
5613
+ * because `getResponse()` returns only the **final** round's response: in a
5614
+ * multi-round tool loop the `tool_calls` generations' tokens are otherwise
5615
+ * unreachable, and `getItemsStream()` carries output items only — no
5616
+ * `response.completed` usage block.
5617
+ *
5618
+ * Gates on run completion the same way `getResponse()` does, so totals are
5619
+ * final whether you await it directly, after `getResponse()`, or after
5620
+ * consuming any of the streaming getters — except on an approval-resumed
5621
+ * run, where reading usage never advances the tool loop: the resume
5622
+ * request's own generation is awaited and counted, but no further rounds
5623
+ * are driven, so totals can be mid-run. Await `getResponse()`/`getText()`
5624
+ * first for final totals there. On a run that paused (approval / HITL) or
5625
+ * failed, it returns the totals accrued so far — `modelCalls: 0` with
5626
+ * zeroed tokens when no model call ever completed.
5627
+ *
5628
+ * Unlike `getResponse()`, this never rejects: a failed run still consumed
5629
+ * tokens, and cost accounting typically runs in a `finally`/`catch` where a
5630
+ * second throw would mask the run's original error. Await the run itself
5631
+ * (e.g. `getResponse()`) if you need to observe the failure.
5632
+ *
5633
+ * `cost` is present only when the server reported cost accounting for at
5634
+ * least one call; a `0` would be indistinguishable from "free".
5635
+ *
5636
+ * For per-call granularity use the `PostModelCall` hook (one emit per model
5637
+ * call, with `turnType`/`turnNumber`) or `getFullResponsesStream()`, whose
5638
+ * `response.completed` events carry each round's own usage block.
5639
+ */
5640
+ async getUsage() {
5641
+ try {
5642
+ await this.initStreamGuarded({
5643
+ requireStream: false,
5644
+ });
5645
+ if (!this.isResumingFromApproval) {
5646
+ await this.executeToolsIfNeeded();
5647
+ }
5648
+ else if (this.pendingModelCall && this.reusableStream) {
5649
+ // The resume dispatch returned a live event stream whose telemetry
5650
+ // is still parked (the non-streaming branch folds usage in
5651
+ // immediately — see continueWithUnsentResults). Consuming the
5652
+ // reusable stream is a passive observation: it buffers events
5653
+ // without executing tools or mutating conversation state, so the
5654
+ // resume generation is counted without advancing the loop.
5655
+ await this.emitPendingModelCallOnce(await consumeStreamForCompletion(this.reusableStream));
5656
+ }
5657
+ }
5658
+ catch (error) {
5659
+ // Intentionally swallowed — see the "never rejects" note above. The
5660
+ // aggregate below still reports whatever calls completed before the
5661
+ // failure; the warn leaves a diagnostic thread for the swallowed cause.
5662
+ console.warn('[getUsage] run failed; reporting totals accrued so far:', error);
5663
+ }
5664
+ return this.snapshotSessionUsage();
5665
+ }
5666
+ }
5667
+ /**
5668
+ * The unified-run affordances (`ctx.defer` for deferred tools, `ctx.log`,
5669
+ * `ctx.onMessage`, the transcript-source slot) threaded into a tool's run
5670
+ * context via `runExtras`.
5671
+ */
5672
+ function buildRunExtras(tool, runBinding) {
5673
+ return {
5674
+ ...(isUnifiedTool(tool) &&
5675
+ tool.function.lifecycle === 'deferred' && {
5676
+ defer: (taskId, options) => ({
5677
+ __deferred: true,
5678
+ taskId,
5679
+ ...(options ?? {}),
5680
+ }),
5681
+ }),
5682
+ log: runBinding.log,
5683
+ onMessage: runBinding.onMessage,
5684
+ // Live getter through the binding — the ToolTask (and its id) is only
5685
+ // created when the call escapes the round, after this object is built.
5686
+ get taskId() {
5687
+ return runBinding.task()?.taskId;
5688
+ },
5689
+ // Transcript slot only — deliberately NOT `task`: TurnContext.task is
5690
+ // the ToolTaskHandle facade (check calls only) and a run body must not
5691
+ // see a transcript-only object under that name.
5692
+ taskTranscript: {
5693
+ set transcriptSource(source) {
5694
+ runBinding.setTranscriptSource(source);
5695
+ },
5696
+ get transcriptSource() {
5697
+ return runBinding.task()?.transcriptSource;
5698
+ },
5699
+ },
5700
+ };
5701
+ }
5702
+ /**
5703
+ * `task` tool, action=result on a SETTLED live task: the final result
5704
+ * (completed) or terminal status + error. Null when the task is not
5705
+ * settled in-process — the caller falls back to the status view.
5706
+ */
5707
+ function taskToolResultIfSettled(input, liveTask) {
5708
+ if (liveTask?.status === 'completed') {
5709
+ return {
5710
+ taskId: input.taskId,
5711
+ status: 'completed',
5712
+ result: liveTask.result,
5713
+ };
5714
+ }
5715
+ if (liveTask && liveTask.status !== 'working') {
5716
+ return {
5717
+ taskId: input.taskId,
5718
+ status: liveTask.status,
5719
+ ...(liveTask.error !== undefined && {
5720
+ error: liveTask.error,
5721
+ }),
5722
+ };
5723
+ }
5724
+ return null;
3129
5725
  }
3130
5726
  //# sourceMappingURL=model-result.js.map