@openrouter/agent 0.9.0 → 0.11.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 (155) hide show
  1. package/README.md +267 -2
  2. package/THIRD_PARTY_NOTICES.md +25 -0
  3. package/esm/index.d.ts +4 -3
  4. package/esm/index.d.ts.map +1 -1
  5. package/esm/index.js +1 -1
  6. package/esm/index.js.map +1 -1
  7. package/esm/inner-loop/call-model.d.ts.map +1 -1
  8. package/esm/inner-loop/call-model.js +26 -7
  9. package/esm/inner-loop/call-model.js.map +1 -1
  10. package/esm/lib/agent-tool.d.ts +1 -1
  11. package/esm/lib/agent-tool.d.ts.map +1 -1
  12. package/esm/lib/agent-tool.js.map +1 -1
  13. package/esm/lib/async-params.d.ts +23 -1
  14. package/esm/lib/async-params.d.ts.map +1 -1
  15. package/esm/lib/async-params.js +26 -1
  16. package/esm/lib/async-params.js.map +1 -1
  17. package/esm/lib/conversation-state.d.ts +1 -1
  18. package/esm/lib/conversation-state.d.ts.map +1 -1
  19. package/esm/lib/conversation-state.js +48 -13
  20. package/esm/lib/conversation-state.js.map +1 -1
  21. package/esm/lib/hooks-schemas.d.ts +3 -7
  22. package/esm/lib/hooks-schemas.d.ts.map +1 -1
  23. package/esm/lib/hooks-schemas.js.map +1 -1
  24. package/esm/lib/model-result.d.ts +32 -4
  25. package/esm/lib/model-result.d.ts.map +1 -1
  26. package/esm/lib/model-result.js +444 -128
  27. package/esm/lib/model-result.js.map +1 -1
  28. package/esm/lib/next-turn-params.d.ts.map +1 -1
  29. package/esm/lib/next-turn-params.js +3 -1
  30. package/esm/lib/next-turn-params.js.map +1 -1
  31. package/esm/lib/next-turn-params.test.js +72 -0
  32. package/esm/lib/next-turn-params.test.js.map +1 -1
  33. package/esm/lib/reusable-stream.d.ts +30 -3
  34. package/esm/lib/reusable-stream.d.ts.map +1 -1
  35. package/esm/lib/reusable-stream.js +127 -32
  36. package/esm/lib/reusable-stream.js.map +1 -1
  37. package/esm/lib/stream-transformers.d.ts +1 -1
  38. package/esm/lib/stream-transformers.d.ts.map +1 -1
  39. package/esm/lib/stream-transformers.js.map +1 -1
  40. package/esm/lib/tool-event-broadcaster.d.ts +18 -5
  41. package/esm/lib/tool-event-broadcaster.d.ts.map +1 -1
  42. package/esm/lib/tool-event-broadcaster.js +90 -19
  43. package/esm/lib/tool-event-broadcaster.js.map +1 -1
  44. package/esm/lib/tool-set-types.d.ts +298 -0
  45. package/esm/lib/tool-set-types.d.ts.map +1 -0
  46. package/esm/lib/tool-set-types.js +2 -0
  47. package/esm/lib/tool-set-types.js.map +1 -0
  48. package/esm/lib/tool-set.d.ts +157 -0
  49. package/esm/lib/tool-set.d.ts.map +1 -0
  50. package/esm/lib/tool-set.js +473 -0
  51. package/esm/lib/tool-set.js.map +1 -0
  52. package/esm/lib/tool-types.d.ts +203 -29
  53. package/esm/lib/tool-types.d.ts.map +1 -1
  54. package/esm/lib/tool-types.js.map +1 -1
  55. package/esm/lib/tool.d.ts +47 -21
  56. package/esm/lib/tool.d.ts.map +1 -1
  57. package/esm/lib/tool.js +13 -3
  58. package/esm/lib/tool.js.map +1 -1
  59. package/esm/lib/turn-context.d.ts +1 -1
  60. package/esm/lib/turn-context.d.ts.map +1 -1
  61. package/esm/lib/turn-context.js +1 -2
  62. package/esm/lib/turn-context.js.map +1 -1
  63. package/esm/mcp/auth/auth-resolver.d.ts +14 -0
  64. package/esm/mcp/auth/auth-resolver.d.ts.map +1 -0
  65. package/esm/mcp/auth/auth-resolver.js +27 -0
  66. package/esm/mcp/auth/auth-resolver.js.map +1 -0
  67. package/esm/mcp/auth/auth-types.d.ts +33 -0
  68. package/esm/mcp/auth/auth-types.d.ts.map +1 -0
  69. package/esm/mcp/auth/auth-types.js +4 -0
  70. package/esm/mcp/auth/auth-types.js.map +1 -0
  71. package/esm/mcp/build-tools.d.ts +29 -0
  72. package/esm/mcp/build-tools.d.ts.map +1 -0
  73. package/esm/mcp/build-tools.js +80 -0
  74. package/esm/mcp/build-tools.js.map +1 -0
  75. package/esm/mcp/cache/cache-store.d.ts +24 -0
  76. package/esm/mcp/cache/cache-store.d.ts.map +1 -0
  77. package/esm/mcp/cache/cache-store.js +20 -0
  78. package/esm/mcp/cache/cache-store.js.map +1 -0
  79. package/esm/mcp/cache/cache-types.d.ts +71 -0
  80. package/esm/mcp/cache/cache-types.d.ts.map +1 -0
  81. package/esm/mcp/cache/cache-types.js +49 -0
  82. package/esm/mcp/cache/cache-types.js.map +1 -0
  83. package/esm/mcp/cache/serialize.d.ts +25 -0
  84. package/esm/mcp/cache/serialize.d.ts.map +1 -0
  85. package/esm/mcp/cache/serialize.js +96 -0
  86. package/esm/mcp/cache/serialize.js.map +1 -0
  87. package/esm/mcp/close-quietly.d.ts +24 -0
  88. package/esm/mcp/close-quietly.d.ts.map +1 -0
  89. package/esm/mcp/close-quietly.js +30 -0
  90. package/esm/mcp/close-quietly.js.map +1 -0
  91. package/esm/mcp/create-mcp-tools.d.ts +13 -0
  92. package/esm/mcp/create-mcp-tools.d.ts.map +1 -0
  93. package/esm/mcp/create-mcp-tools.js +87 -0
  94. package/esm/mcp/create-mcp-tools.js.map +1 -0
  95. package/esm/mcp/elicitation.d.ts +11 -0
  96. package/esm/mcp/elicitation.d.ts.map +1 -0
  97. package/esm/mcp/elicitation.js +51 -0
  98. package/esm/mcp/elicitation.js.map +1 -0
  99. package/esm/mcp/errors.d.ts +91 -0
  100. package/esm/mcp/errors.d.ts.map +1 -0
  101. package/esm/mcp/errors.js +91 -0
  102. package/esm/mcp/errors.js.map +1 -0
  103. package/esm/mcp/handle.d.ts +66 -0
  104. package/esm/mcp/handle.d.ts.map +1 -0
  105. package/esm/mcp/handle.js +323 -0
  106. package/esm/mcp/handle.js.map +1 -0
  107. package/esm/mcp/index.d.ts +13 -0
  108. package/esm/mcp/index.d.ts.map +1 -0
  109. package/esm/mcp/index.js +11 -0
  110. package/esm/mcp/index.js.map +1 -0
  111. package/esm/mcp/mcp-connection.d.ts +150 -0
  112. package/esm/mcp/mcp-connection.d.ts.map +1 -0
  113. package/esm/mcp/mcp-connection.js +478 -0
  114. package/esm/mcp/mcp-connection.js.map +1 -0
  115. package/esm/mcp/mcp-sdk.d.ts +10 -0
  116. package/esm/mcp/mcp-sdk.d.ts.map +1 -0
  117. package/esm/mcp/mcp-sdk.js +37 -0
  118. package/esm/mcp/mcp-sdk.js.map +1 -0
  119. package/esm/mcp/rehydrate.d.ts +76 -0
  120. package/esm/mcp/rehydrate.d.ts.map +1 -0
  121. package/esm/mcp/rehydrate.js +505 -0
  122. package/esm/mcp/rehydrate.js.map +1 -0
  123. package/esm/mcp/resource-tools.d.ts +17 -0
  124. package/esm/mcp/resource-tools.d.ts.map +1 -0
  125. package/esm/mcp/resource-tools.js +155 -0
  126. package/esm/mcp/resource-tools.js.map +1 -0
  127. package/esm/mcp/result-mapper.d.ts +19 -0
  128. package/esm/mcp/result-mapper.d.ts.map +1 -0
  129. package/esm/mcp/result-mapper.js +40 -0
  130. package/esm/mcp/result-mapper.js.map +1 -0
  131. package/esm/mcp/schema/json-schema-guards.d.ts +14 -0
  132. package/esm/mcp/schema/json-schema-guards.d.ts.map +1 -0
  133. package/esm/mcp/schema/json-schema-guards.js +19 -0
  134. package/esm/mcp/schema/json-schema-guards.js.map +1 -0
  135. package/esm/mcp/schema/json-schema-to-zod.d.ts +20 -0
  136. package/esm/mcp/schema/json-schema-to-zod.d.ts.map +1 -0
  137. package/esm/mcp/schema/json-schema-to-zod.js +72 -0
  138. package/esm/mcp/schema/json-schema-to-zod.js.map +1 -0
  139. package/esm/mcp/tool-wrapper.d.ts +41 -0
  140. package/esm/mcp/tool-wrapper.d.ts.map +1 -0
  141. package/esm/mcp/tool-wrapper.js +131 -0
  142. package/esm/mcp/tool-wrapper.js.map +1 -0
  143. package/esm/mcp/transport-types.d.ts +45 -0
  144. package/esm/mcp/transport-types.d.ts.map +1 -0
  145. package/esm/mcp/transport-types.js +2 -0
  146. package/esm/mcp/transport-types.js.map +1 -0
  147. package/esm/mcp/types.d.ts +119 -0
  148. package/esm/mcp/types.d.ts.map +1 -0
  149. package/esm/mcp/types.js +2 -0
  150. package/esm/mcp/types.js.map +1 -0
  151. package/esm/mcp/version.d.ts +3 -0
  152. package/esm/mcp/version.d.ts.map +1 -0
  153. package/esm/mcp/version.js +5 -0
  154. package/esm/mcp/version.js.map +1 -0
  155. package/package.json +48 -7
@@ -1,7 +1,8 @@
1
1
  import { betaResponsesSend } from '@openrouter/sdk/funcs/betaResponsesSend';
2
- import { hasAsyncFunctions, resolveAsyncFunctions } from './async-params.js';
2
+ import * as z4 from 'zod/v4';
3
+ import { hasAsyncFunctions, resolveAsyncFunctions, stripToolSetSnapshotMetadata, } from './async-params.js';
3
4
  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 { appendToMessages, createInitialState, createRejectedResult, createUnsentResult, extractTextFromResponse as extractTextFromResponseState, normalizeInputToArray, partitionToolCalls, toolRequiresApproval, unsentResultsToAPIFormat, updateState, } from './conversation-state.js';
5
6
  import { canonicalizeKeyMaterial, DoomLoopMonitor, resolveDoomLoopOption, resolveLoopKeyMaterial, } from './doom-loop.js';
6
7
  import { applyNextTurnParamsToRequest, executeNextTurnParamsFunctions, } from './next-turn-params.js';
7
8
  import { ReusableReadableStream } from './reusable-stream.js';
@@ -143,6 +144,11 @@ function isEventStream(value) {
143
144
  const maybeStream = value;
144
145
  return typeof maybeStream.getReader === 'function';
145
146
  }
147
+ function isTerminalResponseStreamEvent(event) {
148
+ return (isResponseCompletedEvent(event) ||
149
+ isResponseFailedEvent(event) ||
150
+ isResponseIncompleteEvent(event));
151
+ }
146
152
  /**
147
153
  * Map the server's usage block onto the hook-facing ModelCallUsage shape.
148
154
  * Returns undefined when the response carried no usage accounting.
@@ -302,6 +308,8 @@ export class ModelResult {
302
308
  this.turnBroadcaster = null;
303
309
  this.initialStreamPipeStarted = false;
304
310
  this.initialPipePromise = null;
311
+ this.initialResponse = null;
312
+ this.initialResponseError = null;
305
313
  // Context store for typed tool context (persists across turns)
306
314
  this.contextStore = null;
307
315
  // Tracks whether SessionStart has already been emitted, so SessionEnd can be
@@ -317,6 +325,15 @@ export class ModelResult {
317
325
  // normal tool round consults this to synthesize rejected outputs instead of
318
326
  // executing the calls.
319
327
  this.hookDeniedCalls = new Map();
328
+ // PreToolUse outcomes parked between the approval gates and execution.
329
+ this.preparedToolCalls = new Map();
330
+ // Approval is idempotent per response object, tool-call occurrence, and phase.
331
+ // Occurrence tokens avoid content canonicalization and distinguish duplicates.
332
+ this.completedApprovalGates = new Set();
333
+ this.approvalResponseOccurrences = new WeakMap();
334
+ this.approvalCallOccurrences = new WeakMap();
335
+ this.nextApprovalResponseOccurrence = 0;
336
+ this.nextApprovalCallOccurrence = 0;
320
337
  // Running aggregate across every PostModelCall emit; surfaced as
321
338
  // SessionEnd.totalUsage.
322
339
  this.sessionUsage = {
@@ -382,6 +399,7 @@ export class ModelResult {
382
399
  // id. Aborted on run abort / cancel() so cooperative tool bodies stop.
383
400
  this.inflightToolControllers = new Map();
384
401
  this.options = options;
402
+ this.streamReplay = options.streamReplay ?? 'full';
385
403
  this.hooksManager = options.hooks;
386
404
  const doomLoopConfig = resolveDoomLoopOption(options.doomLoop);
387
405
  this.doomLoopMonitor = doomLoopConfig ? new DoomLoopMonitor(doomLoopConfig) : null;
@@ -475,7 +493,7 @@ export class ModelResult {
475
493
  */
476
494
  ensureTurnBroadcaster() {
477
495
  if (!this.turnBroadcaster) {
478
- this.turnBroadcaster = new ToolEventBroadcaster();
496
+ this.turnBroadcaster = new ToolEventBroadcaster(this.streamReplay);
479
497
  }
480
498
  return this.turnBroadcaster;
481
499
  }
@@ -511,7 +529,27 @@ export class ModelResult {
511
529
  timestamp: Date.now(),
512
530
  });
513
531
  })().catch((error) => {
514
- broadcaster.complete(error instanceof Error ? error : new Error(String(error)));
532
+ const normalizedError = error instanceof Error ? error : new Error(String(error));
533
+ this.initialResponseError = normalizedError;
534
+ broadcaster.complete(normalizedError);
535
+ });
536
+ }
537
+ captureInitialStreamEvent(event) {
538
+ if (isResponseCompletedEvent(event) || isResponseIncompleteEvent(event)) {
539
+ this.initialResponse = event.response;
540
+ return;
541
+ }
542
+ if (isResponseFailedEvent(event)) {
543
+ this.initialResponseError = new Error(`Response failed: ${JSON.stringify(event.response.error)}`);
544
+ }
545
+ }
546
+ setReusableStream(stream) {
547
+ this.initialResponse = null;
548
+ this.initialResponseError = null;
549
+ this.reusableStream = new ReusableReadableStream(stream, {
550
+ streamReplay: this.streamReplay,
551
+ onValue: (event) => this.captureInitialStreamEvent(event),
552
+ isTerminalValue: isTerminalResponseStreamEvent,
515
553
  });
516
554
  }
517
555
  /**
@@ -563,10 +601,11 @@ export class ModelResult {
563
601
  * Push a tool result event to both the legacy tool event broadcaster
564
602
  * and the unified turn broadcaster.
565
603
  */
566
- broadcastToolResult(toolCallId, source, result, preliminaryResults) {
604
+ broadcastToolResult(toolCallId, toolName, source, result, preliminaryResults) {
567
605
  this.toolEventBroadcaster?.push({
568
606
  type: 'tool_result',
569
607
  toolCallId,
608
+ toolName,
570
609
  source,
571
610
  result,
572
611
  ...(preliminaryResults?.length && {
@@ -576,6 +615,7 @@ export class ModelResult {
576
615
  this.turnBroadcaster?.push({
577
616
  type: 'tool.result',
578
617
  toolCallId,
618
+ toolName,
579
619
  source,
580
620
  result,
581
621
  timestamp: Date.now(),
@@ -588,15 +628,17 @@ export class ModelResult {
588
628
  * Push a preliminary result event to both the legacy tool event broadcaster
589
629
  * and the unified turn broadcaster.
590
630
  */
591
- broadcastPreliminaryResult(toolCallId, result) {
631
+ broadcastPreliminaryResult(toolCallId, toolName, result) {
592
632
  this.toolEventBroadcaster?.push({
593
633
  type: 'preliminary_result',
594
634
  toolCallId,
635
+ toolName,
595
636
  result,
596
637
  });
597
638
  this.turnBroadcaster?.push({
598
639
  type: 'tool.preliminary_result',
599
640
  toolCallId,
641
+ toolName,
600
642
  result,
601
643
  timestamp: Date.now(),
602
644
  });
@@ -638,7 +680,10 @@ export class ModelResult {
638
680
  */
639
681
  async materializeResponse(value, turnNumber) {
640
682
  if (isEventStream(value)) {
641
- const stream = new ReusableReadableStream(value);
683
+ const stream = new ReusableReadableStream(value, {
684
+ streamReplay: this.streamReplay,
685
+ isTerminalValue: isTerminalResponseStreamEvent,
686
+ });
642
687
  if (this.turnBroadcaster) {
643
688
  return this.pipeAndConsumeStream(stream, turnNumber);
644
689
  }
@@ -663,6 +708,17 @@ export class ModelResult {
663
708
  if (this.finalResponse) {
664
709
  return this.finalResponse;
665
710
  }
711
+ const initialPipePromise = this.initialPipePromise;
712
+ if (initialPipePromise) {
713
+ await initialPipePromise;
714
+ }
715
+ if (this.initialResponseError) {
716
+ throw this.initialResponseError;
717
+ }
718
+ if (this.initialResponse) {
719
+ await this.emitPendingModelCallOnce(this.initialResponse);
720
+ return this.initialResponse;
721
+ }
666
722
  if (this.reusableStream) {
667
723
  const response = await consumeStreamForCompletion(this.reusableStream);
668
724
  await this.emitPendingModelCallOnce(response);
@@ -670,6 +726,24 @@ export class ModelResult {
670
726
  }
671
727
  throw new Error('Neither stream nor response initialized');
672
728
  }
729
+ extractCachedCompletion() {
730
+ if (this.initialResponseError) {
731
+ throw this.initialResponseError;
732
+ }
733
+ if (this.initialResponse) {
734
+ return this.initialResponse;
735
+ }
736
+ if (!this.reusableStream) {
737
+ throw new Error('Stream not initialized');
738
+ }
739
+ return extractCompletionFromBuffer(this.reusableStream);
740
+ }
741
+ tryExtractCachedCompletion() {
742
+ if (this.initialResponse) {
743
+ return this.initialResponse;
744
+ }
745
+ return this.reusableStream ? tryExtractCompletionFromBuffer(this.reusableStream) : undefined;
746
+ }
673
747
  /**
674
748
  * Save response output to state.
675
749
  * Appends the response output to the message history and records the response ID.
@@ -924,7 +998,7 @@ export class ModelResult {
924
998
  * - `execution`: The tool ran. `result` is the ToolExecutionResult.
925
999
  * `effectiveToolCall` reflects any `mutatedInput` piped by PreToolUse.
926
1000
  */
927
- async runToolWithHooks(tool, toolCall, turnContext, onPreliminaryResult, extras) {
1001
+ async runToolWithHooks(tool, toolCall, turnContext, onPreliminaryResult, extras, runPreToolUse = true) {
928
1002
  // Reject raw-string arguments before any hook fires. When the model
929
1003
  // produces invalid JSON, the parser leaves `toolCall.arguments` as the
930
1004
  // raw string; handing that to PreToolUse would either fail payload
@@ -934,23 +1008,6 @@ export class ModelResult {
934
1008
  // synthetic error without running the tool or firing hooks.
935
1009
  const rawArgs = toolCall.arguments;
936
1010
  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
- }
954
1011
  const errorMessage = `Failed to parse tool call arguments for "${toolCall.name}": The model provided invalid JSON. ` +
955
1012
  `Raw arguments received: "${rawArgs}". ` +
956
1013
  'Please provide valid JSON arguments for this tool call.';
@@ -968,32 +1025,20 @@ export class ModelResult {
968
1025
  },
969
1026
  };
970
1027
  }
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) {
1028
+ const prepared = this.preparedToolCalls.get(toolCall.id);
1029
+ this.preparedToolCalls.delete(toolCall.id);
1030
+ if (prepared?.type === 'blocked') {
980
1031
  return {
981
1032
  type: 'hook_blocked',
982
1033
  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
- },
1034
+ reason: prepared.reason,
1035
+ output: prepared.output,
992
1036
  };
993
1037
  }
994
- let effectiveToolCall = toolCall;
995
- // Emit PreToolUse hook -- can block or mutate input.
996
- if (this.hooksManager) {
1038
+ let effectiveToolCall = prepared?.type === 'ready' ? prepared.toolCall : toolCall;
1039
+ // Emit PreToolUse here only when the approval gate has not already done
1040
+ // so (for example, an approved call resumed from older persisted state).
1041
+ if (this.hooksManager && !prepared && runPreToolUse) {
997
1042
  // The hook payload coerces null/undefined arguments to {} for schema
998
1043
  // validation, but `effectiveToolCall.arguments` only changes when the
999
1044
  // chain reports an actual mutation (`emit.mutated`), so tools that
@@ -1830,7 +1875,7 @@ export class ModelResult {
1830
1875
  // Sync backward scan of the retained buffer — not a consumer
1831
1876
  // replay, which would cost one microtask hop per buffered event
1832
1877
  // on every hook-less streaming teardown.
1833
- await this.emitPendingModelCallOnce(extractCompletionFromBuffer(this.reusableStream));
1878
+ await this.emitPendingModelCallOnce(this.extractCachedCompletion());
1834
1879
  }
1835
1880
  else if (this.reusableStream) {
1836
1881
  // Consumers stop at the terminal event (streamTerminationEvents),
@@ -1841,7 +1886,7 @@ export class ModelResult {
1841
1886
  // dropping the parked telemetry. Stays silent (no emit, no
1842
1887
  // throw) when nothing terminal was buffered — e.g. an errored
1843
1888
  // mid-flight stream, where no materialized response exists.
1844
- const buffered = tryExtractCompletionFromBuffer(this.reusableStream);
1889
+ const buffered = this.tryExtractCachedCompletion();
1845
1890
  if (buffered) {
1846
1891
  await this.emitPendingModelCallOnce(buffered);
1847
1892
  }
@@ -1947,6 +1992,152 @@ export class ModelResult {
1947
1992
  }),
1948
1993
  };
1949
1994
  }
1995
+ async prepareToolCallForApproval(toolCall) {
1996
+ if (!this.hooksManager || typeof toolCall.arguments === 'string') {
1997
+ return toolCall;
1998
+ }
1999
+ const preResult = await this.hooksManager.emit('PreToolUse', {
2000
+ toolName: toolCall.name,
2001
+ toolInput: (toolCall.arguments ?? {}),
2002
+ }, this.hookEmitContext(toolCall.name));
2003
+ if (preResult.blocked) {
2004
+ const block = preResult.results.find((result) => result.block)?.block;
2005
+ const reason = typeof block === 'string' ? block : 'Blocked by PreToolUse hook';
2006
+ this.preparedToolCalls.set(toolCall.id, {
2007
+ type: 'blocked',
2008
+ reason,
2009
+ output: {
2010
+ type: 'function_call_output',
2011
+ id: `output_${toolCall.id}`,
2012
+ callId: toolCall.id,
2013
+ output: JSON.stringify({
2014
+ error: reason,
2015
+ }),
2016
+ },
2017
+ });
2018
+ return toolCall;
2019
+ }
2020
+ const effectiveToolCall = preResult.mutated
2021
+ ? {
2022
+ ...toolCall,
2023
+ arguments: preResult.finalPayload.toolInput,
2024
+ }
2025
+ : toolCall;
2026
+ this.preparedToolCalls.set(toolCall.id, {
2027
+ type: 'ready',
2028
+ toolCall: effectiveToolCall,
2029
+ mutated: preResult.mutated,
2030
+ });
2031
+ return effectiveToolCall;
2032
+ }
2033
+ approvalResponseKey(response) {
2034
+ let occurrence = this.approvalResponseOccurrences.get(response);
2035
+ if (occurrence === undefined) {
2036
+ occurrence = this.nextApprovalResponseOccurrence++;
2037
+ this.approvalResponseOccurrences.set(response, occurrence);
2038
+ }
2039
+ return `${response.id}:${occurrence}`;
2040
+ }
2041
+ approvalGateKey(phase, responseKey, occurrence) {
2042
+ return JSON.stringify([
2043
+ phase,
2044
+ responseKey,
2045
+ occurrence,
2046
+ ]);
2047
+ }
2048
+ assignApprovalCallOccurrences(response, toolCalls) {
2049
+ let identities = this.approvalCallOccurrences.get(response);
2050
+ if (!identities) {
2051
+ identities = [];
2052
+ this.approvalCallOccurrences.set(response, identities);
2053
+ }
2054
+ const occurrences = new Map();
2055
+ for (const [index, toolCall] of toolCalls.entries()) {
2056
+ const identity = identities[index] ?? `call:${this.nextApprovalCallOccurrence++}`;
2057
+ identities[index] = identity;
2058
+ occurrences.set(toolCall, identity);
2059
+ }
2060
+ return occurrences;
2061
+ }
2062
+ /** Re-check only approval sources whose answer can depend on input. */
2063
+ async mutatedInputRequiresApproval(toolCall, context) {
2064
+ const tools = this.options.tools;
2065
+ if (!tools) {
2066
+ return false;
2067
+ }
2068
+ const tool = tools.find((candidate) => isClientTool(candidate) && candidate.function.name === toolCall.name);
2069
+ if (this.requireApprovalFn) {
2070
+ return toolRequiresApproval(toolCall, tools, context, this.requireApprovalFn);
2071
+ }
2072
+ if (!tool || !isClientTool(tool) || typeof tool.function.requireApproval !== 'function') {
2073
+ return false;
2074
+ }
2075
+ return toolRequiresApproval(toolCall, tools, context);
2076
+ }
2077
+ async emitPreparedFailure(toolCall, reason) {
2078
+ if (!this.hooksManager) {
2079
+ return;
2080
+ }
2081
+ await this.hooksManager.emit('PostToolUseFailure', {
2082
+ toolName: toolCall.name,
2083
+ toolInput: (toolCall.arguments ?? {}),
2084
+ error: new Error(reason),
2085
+ }, this.hookEmitContext(toolCall.name));
2086
+ }
2087
+ async blockPreparedToolCall(toolCall, reason) {
2088
+ this.preparedToolCalls.set(toolCall.id, {
2089
+ type: 'blocked',
2090
+ reason,
2091
+ output: {
2092
+ type: 'function_call_output',
2093
+ id: `output_${toolCall.id}`,
2094
+ callId: toolCall.id,
2095
+ output: JSON.stringify({
2096
+ error: reason,
2097
+ }),
2098
+ },
2099
+ });
2100
+ await this.emitPreparedFailure(toolCall, reason);
2101
+ }
2102
+ async validatePreparedMutation(effective) {
2103
+ const tool = this.options.tools?.find((candidate) => isClientTool(candidate) && candidate.function.name === effective.name);
2104
+ if (!tool || !isClientTool(tool)) {
2105
+ return 'ready';
2106
+ }
2107
+ const parsed = z4.safeParse(tool.function.inputSchema, effective.arguments);
2108
+ if (!parsed.success || !isRecord(parsed.data)) {
2109
+ await this.blockPreparedToolCall(effective, `PreToolUse produced invalid input for "${effective.name}"`);
2110
+ return 'blocked';
2111
+ }
2112
+ return 'ready';
2113
+ }
2114
+ async prepareAfterInitialApproval(toolCall, context, responseKey, occurrence) {
2115
+ const effective = await this.prepareToolCallForApproval(toolCall);
2116
+ const prepared = this.preparedToolCalls.get(toolCall.id);
2117
+ if (prepared?.type !== 'ready' || !prepared.mutated) {
2118
+ return prepared?.type === 'blocked' ? 'blocked' : 'ready';
2119
+ }
2120
+ if ((await this.validatePreparedMutation(effective)) === 'blocked') {
2121
+ return 'blocked';
2122
+ }
2123
+ const key = this.approvalGateKey('mutated', responseKey, occurrence);
2124
+ if (this.completedApprovalGates.has(key)) {
2125
+ return 'ready';
2126
+ }
2127
+ this.completedApprovalGates.add(key);
2128
+ if (!(await this.mutatedInputRequiresApproval(effective, context))) {
2129
+ return 'ready';
2130
+ }
2131
+ const { decision, reason } = await this.emitPermissionRequest(effective);
2132
+ if (decision === 'allow') {
2133
+ return 'ready';
2134
+ }
2135
+ if (decision === 'deny') {
2136
+ await this.blockPreparedToolCall(effective, reason ?? 'Denied by PermissionRequest hook');
2137
+ return 'blocked';
2138
+ }
2139
+ return 'pending';
2140
+ }
1950
2141
  /**
1951
2142
  * Run the UserPromptSubmit hook, supporting both string and structured
1952
2143
  * inputs. If a handler returns a mutated prompt, the returned object
@@ -2054,9 +2245,6 @@ export class ModelResult {
2054
2245
  * @returns Array of unsent tool results for later submission
2055
2246
  */
2056
2247
  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);
2060
2248
  const toolCallPromises = toolCalls.map(async (tc) => {
2061
2249
  const tool = this.options.tools?.find((t) => isClientTool(t) && t.function.name === tc.name);
2062
2250
  if (!tool || !isAutoResolvableTool(tool)) {
@@ -2067,7 +2255,7 @@ export class ModelResult {
2067
2255
  // JSON-parse failures so hooks never see a malformed payload.
2068
2256
  const hookOutcome = await this.runToolWithHooks(tool, tc, turnContext);
2069
2257
  if (hookOutcome.type === 'parse_error') {
2070
- this.broadcastToolResult(tc.id, isMcpTool(tool) ? 'mcp' : 'client', {
2258
+ this.broadcastToolResult(tc.id, String(tc.name), isMcpTool(tool) ? 'mcp' : 'client', {
2071
2259
  error: hookOutcome.errorMessage,
2072
2260
  });
2073
2261
  return createRejectedResult(tc.id, String(tc.name), hookOutcome.errorMessage);
@@ -2107,52 +2295,70 @@ export class ModelResult {
2107
2295
  }
2108
2296
  return results;
2109
2297
  }
2110
- /**
2111
- * Check for tools requiring approval and handle accordingly.
2112
- * Partitions tool calls into those needing approval and those that can auto-execute.
2113
- *
2114
- * @param toolCalls - The tool calls to check
2115
- * @param currentRound - The current execution round (1-indexed)
2116
- * @param currentResponse - The current response to save if pausing
2117
- * @returns True if execution should pause for approval, false to continue
2118
- * @throws Error if approval is required but no state accessor is configured
2119
- */
2120
- async handleApprovalCheck(toolCalls, currentRound, currentResponse) {
2121
- if (!this.options.tools) {
2122
- return false;
2298
+ async classifyInitialApprovalCalls(toolCalls, responseKey, occurrences) {
2299
+ const unseenCalls = [];
2300
+ const blockedCalls = [];
2301
+ const unseenKeys = toolCalls.filter((toolCall) => !this.completedApprovalGates.has(this.approvalGateKey('initial', responseKey, occurrences.get(toolCall) ?? '')));
2302
+ await this.beginDoomLoopRound(unseenKeys);
2303
+ for (const toolCall of toolCalls) {
2304
+ const key = this.approvalGateKey('initial', responseKey, occurrences.get(toolCall) ?? '');
2305
+ if (this.completedApprovalGates.has(key)) {
2306
+ continue;
2307
+ }
2308
+ this.completedApprovalGates.add(key);
2309
+ const tool = this.options.tools?.find((candidate) => isClientTool(candidate) && candidate.function.name === toolCall.name);
2310
+ if (tool && isAutoResolvableTool(tool)) {
2311
+ const rawArgs = toolCall.arguments;
2312
+ const doomOutcome = typeof rawArgs === 'string' && this.doomLoopMonitor
2313
+ ? await this.enqueueDoomLoopEvaluation({
2314
+ toolName: String(toolCall.name),
2315
+ keyMaterial: rawArgs,
2316
+ fallbackKeyMaterial: null,
2317
+ allowBlock: true,
2318
+ detector: 'tool-fingerprint',
2319
+ toolCall,
2320
+ }).then((decision) => ({
2321
+ blocked: decision.action === 'block' || decision.action === 'stop',
2322
+ reason: decision.message ?? 'Blocked by doom loop',
2323
+ }))
2324
+ : await this.checkDoomLoopBeforeExecution(tool, toolCall);
2325
+ if (doomOutcome.blocked) {
2326
+ this.preparedToolCalls.set(toolCall.id, {
2327
+ type: 'blocked',
2328
+ reason: doomOutcome.reason,
2329
+ output: {
2330
+ type: 'function_call_output',
2331
+ id: `output_${toolCall.id}`,
2332
+ callId: toolCall.id,
2333
+ output: JSON.stringify({
2334
+ error: doomOutcome.reason,
2335
+ }),
2336
+ },
2337
+ });
2338
+ blockedCalls.push(toolCall);
2339
+ continue;
2340
+ }
2341
+ }
2342
+ unseenCalls.push(toolCall);
2123
2343
  }
2124
- const turnContext = {
2125
- numberOfTurns: currentRound,
2126
- // context is handled via contextStore, not on TurnContext
2344
+ return {
2345
+ unseenCalls,
2346
+ blockedCalls,
2127
2347
  };
2128
- const { requiresApproval: needsApproval, autoExecute } = await partitionToolCalls(toolCalls, this.options.tools, turnContext, this.requireApprovalFn ?? undefined);
2129
- // Nothing needs an approval gate: return immediately WITHOUT executing
2130
- // anything. The main loop's executeToolRound runs every call exactly
2131
- // once; pre-executing here would double-run side-effecting tools.
2132
- if (needsApproval.length === 0) {
2133
- return false;
2134
- }
2135
- // Run the PermissionRequest hook for each tool that needs approval.
2136
- // This lets hooks short-circuit the approval flow in either direction:
2137
- // 'allow' promotes the call past the gate (executed once by the normal
2138
- // round), 'deny' synthesizes a rejection (recorded so the round emits a
2139
- // rejected output instead of executing), 'ask_user' falls through to the
2140
- // human approval flow.
2348
+ }
2349
+ async resolveApprovalPhases(needsApproval, autoExecute, turnContext, responseKey, occurrences) {
2141
2350
  const denied = [];
2142
2351
  const stillPending = [];
2143
2352
  if (this.hooksManager) {
2144
2353
  for (const tc of needsApproval) {
2145
2354
  const { decision, reason } = await this.emitPermissionRequest(tc);
2146
- if (decision === 'allow') {
2147
- // Promoted past the gate; the normal tool round executes it once.
2148
- }
2149
- else if (decision === 'deny') {
2355
+ if (decision === 'deny') {
2150
2356
  denied.push({
2151
2357
  tc,
2152
2358
  reason: reason ?? 'Denied by PermissionRequest hook',
2153
2359
  });
2154
2360
  }
2155
- else {
2361
+ else if (decision !== 'allow') {
2156
2362
  stillPending.push(tc);
2157
2363
  }
2158
2364
  }
@@ -2160,10 +2366,59 @@ export class ModelResult {
2160
2366
  else {
2161
2367
  stillPending.push(...needsApproval);
2162
2368
  }
2369
+ const initialSurvivors = [
2370
+ ...autoExecute,
2371
+ ...needsApproval.filter((tc) => !stillPending.some((pending) => pending.id === tc.id) &&
2372
+ !denied.some((entry) => entry.tc.id === tc.id)),
2373
+ ];
2374
+ for (const toolCall of initialSurvivors) {
2375
+ const tool = this.options.tools?.find((candidate) => isClientTool(candidate) && candidate.function.name === toolCall.name);
2376
+ if (!tool || !isAutoResolvableTool(tool)) {
2377
+ continue;
2378
+ }
2379
+ if ((await this.prepareAfterInitialApproval(toolCall, turnContext, responseKey, occurrences.get(toolCall) ?? '')) === 'pending') {
2380
+ const prepared = this.preparedToolCalls.get(toolCall.id);
2381
+ if (prepared?.type === 'ready') {
2382
+ stillPending.push(prepared.toolCall);
2383
+ }
2384
+ }
2385
+ }
2386
+ return {
2387
+ denied,
2388
+ stillPending,
2389
+ initialSurvivors,
2390
+ };
2391
+ }
2392
+ /**
2393
+ * Check for tools requiring approval and handle accordingly.
2394
+ * Partitions tool calls into those needing approval and those that can auto-execute.
2395
+ *
2396
+ * @param toolCalls - The tool calls to check
2397
+ * @param currentRound - The current execution round (1-indexed)
2398
+ * @param currentResponse - The current response to save if pausing
2399
+ * @returns True if execution should pause for approval, false to continue
2400
+ * @throws Error if approval is required but no state accessor is configured
2401
+ */
2402
+ async handleApprovalCheck(suppliedToolCalls, currentRound, currentResponse) {
2403
+ if (!this.options.tools) {
2404
+ return false;
2405
+ }
2406
+ const turnContext = {
2407
+ numberOfTurns: currentRound,
2408
+ };
2409
+ const responseKey = this.approvalResponseKey(currentResponse);
2410
+ // Always enumerate the complete response so subset visits find the original
2411
+ // response-local occurrence and appended calls receive fresh identities.
2412
+ const responseToolCalls = extractToolCallsFromResponse(currentResponse);
2413
+ const toolCalls = responseToolCalls.length > 0 ? responseToolCalls : suppliedToolCalls;
2414
+ const occurrences = this.assignApprovalCallOccurrences(currentResponse, toolCalls);
2415
+ const { unseenCalls, blockedCalls } = await this.classifyInitialApprovalCalls(toolCalls, responseKey, occurrences);
2416
+ if (unseenCalls.length === 0 && blockedCalls.length === 0) {
2417
+ return false;
2418
+ }
2419
+ const { requiresApproval: needsApproval, autoExecute } = await partitionToolCalls(unseenCalls, this.options.tools, turnContext, this.requireApprovalFn ?? undefined);
2420
+ const { denied, stillPending, initialSurvivors } = await this.resolveApprovalPhases(needsApproval, autoExecute, turnContext, responseKey, occurrences);
2163
2421
  if (stillPending.length === 0) {
2164
- // The hook resolved every gated call, so we do not pause. Record denied
2165
- // calls so executeToolRound synthesizes rejections instead of running
2166
- // them; allowed calls execute once via the normal round.
2167
2422
  for (const d of denied) {
2168
2423
  this.hookDeniedCalls.set(d.tc.id, d.reason);
2169
2424
  }
@@ -2178,7 +2433,12 @@ export class ModelResult {
2178
2433
  // We are pausing: the normal tool round will NOT run for this response,
2179
2434
  // so execute the auto-approved calls now and persist their results as
2180
2435
  // unsent so the resume path can pick them up without re-executing.
2181
- const unsentResults = await this.executeAutoApproveTools(autoExecute, turnContext);
2436
+ const pendingIds = new Set(stillPending.map((call) => call.id));
2437
+ const executableNow = [
2438
+ ...initialSurvivors.filter((call) => !pendingIds.has(call.id)),
2439
+ ...blockedCalls,
2440
+ ];
2441
+ const unsentResults = await this.executeAutoApproveTools(executableNow, turnContext);
2182
2442
  // Combine pre-denied results (from hook "deny") with executed results.
2183
2443
  const combinedResults = [
2184
2444
  ...unsentResults,
@@ -2186,13 +2446,20 @@ export class ModelResult {
2186
2446
  ];
2187
2447
  // Save state with pending approvals (only reached when stillPending > 0).
2188
2448
  const stateUpdates = {
2189
- pendingToolCalls: stillPending,
2449
+ pendingToolCalls: stillPending.map((toolCall) => ({
2450
+ ...toolCall,
2451
+ ...(this.preparedToolCalls.has(toolCall.id) && {
2452
+ preToolUseApplied: true,
2453
+ }),
2454
+ })),
2190
2455
  status: 'awaiting_approval',
2191
2456
  };
2192
2457
  if (combinedResults.length > 0) {
2193
2458
  stateUpdates.unsentToolResults = combinedResults;
2194
2459
  }
2195
2460
  await this.saveStateSafely(stateUpdates);
2461
+ this.preparedToolCalls.clear();
2462
+ this.hookDeniedCalls.clear();
2196
2463
  this.finalResponse = currentResponse;
2197
2464
  return true; // Pause for approval
2198
2465
  }
@@ -2315,7 +2582,7 @@ export class ModelResult {
2315
2582
  // Emit the canonical tool.result event with the FINAL value so
2316
2583
  // consumers watching only tool.result see real results exactly once.
2317
2584
  if (task.status === 'completed') {
2318
- this.broadcastToolResult(task.callId, this.toolSourceByName(task.name), task.result);
2585
+ this.broadcastToolResult(task.callId, String(task.name), this.toolSourceByName(String(task.name)), task.result);
2319
2586
  }
2320
2587
  // PostToolUse/PostToolUseFailure fire at SETTLEMENT for async tools —
2321
2588
  // the observation-only audit surface (secret scanning, output review)
@@ -2450,7 +2717,7 @@ export class ModelResult {
2450
2717
  ? (callId, resultValue) => {
2451
2718
  const typedResult = resultValue;
2452
2719
  preliminaryResultsForCall.push(typedResult);
2453
- this.broadcastPreliminaryResult(callId, typedResult);
2720
+ this.broadcastPreliminaryResult(callId, String(toolCall.name), typedResult);
2454
2721
  }
2455
2722
  : undefined;
2456
2723
  // Compose the per-call abort signal (run signal + tool/run timeout +
@@ -2503,7 +2770,7 @@ export class ModelResult {
2503
2770
  return this.buildToolTimeoutOutcome(toolCall, tool, timeoutMs, preliminaryResultsForCall);
2504
2771
  }
2505
2772
  if (executed.type === 'parse_error') {
2506
- this.broadcastToolResult(toolCall.id, isMcpTool(tool) ? 'mcp' : 'client', {
2773
+ this.broadcastToolResult(toolCall.id, String(toolCall.name), isMcpTool(tool) ? 'mcp' : 'client', {
2507
2774
  error: executed.errorMessage,
2508
2775
  });
2509
2776
  return executed;
@@ -2557,7 +2824,7 @@ export class ModelResult {
2557
2824
  */
2558
2825
  buildToolTimeoutOutcome(toolCall, tool, timeoutMs, preliminaryResultsForCall) {
2559
2826
  const message = `Tool "${toolCall.name}" timed out after ${timeoutMs}ms`;
2560
- this.broadcastToolResult(toolCall.id, isMcpTool(tool) ? 'mcp' : 'client', {
2827
+ this.broadcastToolResult(toolCall.id, String(toolCall.name), isMcpTool(tool) ? 'mcp' : 'client', {
2561
2828
  error: message,
2562
2829
  });
2563
2830
  return {
@@ -2624,9 +2891,6 @@ export class ModelResult {
2624
2891
  });
2625
2892
  }
2626
2893
  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);
2630
2894
  const toolCallPromises = toolCalls.map((toolCall) => this.executeSingleToolCall(toolCall, turnContext));
2631
2895
  const settledResults = await Promise.allSettled(toolCallPromises);
2632
2896
  const toolResults = [];
@@ -2654,7 +2918,7 @@ export class ModelResult {
2654
2918
  if (settled.status === 'rejected') {
2655
2919
  const errorMessage = settled.reason instanceof Error ? settled.reason.message : String(settled.reason);
2656
2920
  // `runToolWithHooks` is the single point of emission for PostToolUseFailure.
2657
- this.broadcastToolResult(originalToolCall.id, this.toolSourceByName(originalToolCall.name), {
2921
+ this.broadcastToolResult(originalToolCall.id, String(originalToolCall.name), this.toolSourceByName(String(originalToolCall.name)), {
2658
2922
  error: errorMessage,
2659
2923
  });
2660
2924
  const rejectedOutput = {
@@ -2716,7 +2980,7 @@ export class ModelResult {
2716
2980
  error: value.result.error.message,
2717
2981
  }
2718
2982
  : value.result.result);
2719
- this.broadcastToolResult(value.toolCall.id, isMcpTool(value.tool) ? 'mcp' : 'client', toolResult, value.preliminaryResultsForCall.length > 0 ? value.preliminaryResultsForCall : undefined);
2983
+ this.broadcastToolResult(value.toolCall.id, String(value.toolCall.name), isMcpTool(value.tool) ? 'mcp' : 'client', toolResult, value.preliminaryResultsForCall.length > 0 ? value.preliminaryResultsForCall : undefined);
2720
2984
  const outputForModel = await this.computeToolOutputForModel(value);
2721
2985
  const executedOutput = {
2722
2986
  type: 'function_call_output',
@@ -2851,7 +3115,7 @@ export class ModelResult {
2851
3115
  // would be a double delivery).
2852
3116
  registry.untrack(toolCall.id);
2853
3117
  if (settled.outcome === 'ok') {
2854
- this.broadcastToolResult(toolCall.id, source, settled.result);
3118
+ this.broadcastToolResult(toolCall.id, String(toolCall.name), source, settled.result);
2855
3119
  const outputForModel = await this.computeToolOutputForModel({
2856
3120
  toolCall,
2857
3121
  tool,
@@ -2869,7 +3133,7 @@ export class ModelResult {
2869
3133
  };
2870
3134
  }
2871
3135
  const message = settled.error instanceof Error ? settled.error.message : String(settled.error);
2872
- this.broadcastToolResult(toolCall.id, source, {
3136
+ this.broadcastToolResult(toolCall.id, String(toolCall.name), source, {
2873
3137
  error: message,
2874
3138
  });
2875
3139
  return {
@@ -2914,7 +3178,7 @@ export class ModelResult {
2914
3178
  return null;
2915
3179
  }
2916
3180
  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, {
3181
+ this.broadcastToolResult(toolCall.id, String(toolCall.name), source, {
2918
3182
  error: message,
2919
3183
  });
2920
3184
  return {
@@ -3120,7 +3384,7 @@ export class ModelResult {
3120
3384
  const taskTool = buildTaskToolStub();
3121
3385
  const answer = (result, error) => {
3122
3386
  if (error === undefined) {
3123
- this.broadcastToolResult(toolCall.id, 'client', result);
3387
+ this.broadcastToolResult(toolCall.id, String(toolCall.name), 'client', result);
3124
3388
  }
3125
3389
  return {
3126
3390
  type: 'execution',
@@ -3323,9 +3587,23 @@ export class ModelResult {
3323
3587
  return;
3324
3588
  }
3325
3589
  const computedParams = await executeNextTurnParamsFunctions(toolCalls, this.options.tools, this.resolvedRequest);
3326
- if (Object.keys(computedParams).length > 0) {
3327
- this.resolvedRequest = applyNextTurnParamsToRequest(this.resolvedRequest, computedParams);
3590
+ if (Object.keys(computedParams).length === 0) {
3591
+ return;
3328
3592
  }
3593
+ const nextRequest = applyNextTurnParamsToRequest(this.resolvedRequest, computedParams);
3594
+ /*
3595
+ * A tool-computed `toolChoice` becomes the new caller-level policy, not a
3596
+ * one-turn override. Merging it onto the request alone is not enough:
3597
+ * `makeFollowupRequest` re-derives the wire choice from
3598
+ * `configuredToolChoice` via `applyForcedToolChoicePolicy`, which would
3599
+ * discard the tool's value before dispatch. Re-running the resolved-policy
3600
+ * bookkeeping re-stamps the configured choice and its forced-choice
3601
+ * consumption key together, so relaxation stays consistent for later turns.
3602
+ */
3603
+ this.resolvedRequest =
3604
+ 'toolChoice' in computedParams
3605
+ ? this.applyResolvedForcedToolChoicePolicy(nextRequest)
3606
+ : nextRequest;
3329
3607
  }
3330
3608
  /**
3331
3609
  * Make a follow-up API request with tool results.
@@ -3781,8 +4059,12 @@ export class ModelResult {
3781
4059
  // `clientOnlyFields` in async-params.ts, which handles the async path.
3782
4060
  // (`sharedContextSchema` is absent here: call-model.ts destructures it
3783
4061
  // before the request reaches ModelResult.)
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);
4062
+ const { stopWhen: _, state: _s, requireApproval: _r, approveToolCalls: _a, rejectToolCalls: _rj, context: _c, onTurnStart: _ots, onTurnEnd: _ote, allowFinalResponse: _afr, strictFinalResponse: _sfr, hooks: _h, doomLoop: _dl, streamReplay: _sr, signal: _sig, toolTimeoutMs: _ttm, toolConcurrency: _tc, asyncTools: _at, activeTools: _activeTools, ...rest } = this.options.request;
4063
+ const resolved = {
4064
+ ...rest,
4065
+ };
4066
+ stripToolSetSnapshotMetadata(resolved);
4067
+ return this.applyResolvedForcedToolChoicePolicy(resolved);
3786
4068
  }
3787
4069
  /**
3788
4070
  * Record the freshly-resolved caller choice before deriving its wire value.
@@ -4282,7 +4564,7 @@ export class ModelResult {
4282
4564
  // Handle both streaming and non-streaming responses
4283
4565
  // The API may return a non-streaming response even when stream: true is requested
4284
4566
  if (isEventStream(apiResult.value)) {
4285
- this.reusableStream = new ReusableReadableStream(apiResult.value);
4567
+ this.setReusableStream(apiResult.value);
4286
4568
  }
4287
4569
  else if (this.isNonStreamingResponse(apiResult.value)) {
4288
4570
  // API returned a complete response directly - use it as the final response
@@ -4311,19 +4593,12 @@ export class ModelResult {
4311
4593
  numberOfTurns: this.allToolExecutionRounds.length + 1,
4312
4594
  // context is handled via contextStore, not on TurnContext
4313
4595
  };
4596
+ // Calls that pause again after PreToolUse mutation remain pending without
4597
+ // re-running the hook on the next resume.
4598
+ const secondGatePausedIds = new Set();
4314
4599
  // Track approved HITL calls that paused (onToolCalled returned null) —
4315
4600
  // these stay in pendingToolCalls so the caller can resume them later.
4316
4601
  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));
4327
4602
  // Process approvals - execute the approved tools. Route through
4328
4603
  // runToolWithHooks so PreToolUse/PostToolUse fire even on this path.
4329
4604
  for (const callId of this.approvedToolCalls) {
@@ -4337,9 +4612,22 @@ export class ModelResult {
4337
4612
  unsentResults.push(createRejectedResult(callId, String(toolCall.name), 'Tool not found or not executable'));
4338
4613
  continue;
4339
4614
  }
4340
- const hookOutcome = await this.runToolWithHooks(tool, toolCall, turnContext);
4615
+ if (toolCall.preToolUseApplied !== true) {
4616
+ const prepared = await this.prepareAfterInitialApproval(toolCall, turnContext, `persisted:${this.currentState.previousResponseId ?? 'unknown'}`, callId);
4617
+ if (prepared === 'pending') {
4618
+ const ready = this.preparedToolCalls.get(callId);
4619
+ if (ready?.type === 'ready') {
4620
+ Object.assign(toolCall, ready.toolCall, {
4621
+ preToolUseApplied: true,
4622
+ });
4623
+ }
4624
+ secondGatePausedIds.add(callId);
4625
+ continue;
4626
+ }
4627
+ }
4628
+ const hookOutcome = await this.runToolWithHooks(tool, toolCall, turnContext, undefined, undefined, false);
4341
4629
  if (hookOutcome.type === 'parse_error') {
4342
- this.broadcastToolResult(callId, this.toolSourceByName(String(toolCall.name)), {
4630
+ this.broadcastToolResult(callId, String(toolCall.name), this.toolSourceByName(String(toolCall.name)), {
4343
4631
  error: hookOutcome.errorMessage,
4344
4632
  });
4345
4633
  unsentResults.push(createRejectedResult(callId, String(toolCall.name), hookOutcome.errorMessage));
@@ -4373,7 +4661,13 @@ export class ModelResult {
4373
4661
  if (!toolCall) {
4374
4662
  continue;
4375
4663
  }
4376
- unsentResults.push(createRejectedResult(callId, String(toolCall.name), 'Rejected by user'));
4664
+ const reason = 'Rejected by user';
4665
+ if (toolCall.preToolUseApplied === true) {
4666
+ await this.emitPreparedFailure(toolCall, reason);
4667
+ }
4668
+ this.preparedToolCalls.delete(callId);
4669
+ this.hookDeniedCalls.delete(callId);
4670
+ unsentResults.push(createRejectedResult(callId, String(toolCall.name), reason));
4377
4671
  }
4378
4672
  // Remove processed calls from pending. Approved HITL calls that paused are
4379
4673
  // NOT considered processed — they stay on pendingToolCalls so getPendingToolCalls()
@@ -4381,7 +4675,7 @@ export class ModelResult {
4381
4675
  const processedIds = new Set([
4382
4676
  ...this.approvedToolCalls,
4383
4677
  ...this.rejectedToolCalls,
4384
- ].filter((id) => !hitlPausedIds.has(id)));
4678
+ ].filter((id) => !hitlPausedIds.has(id) && !secondGatePausedIds.has(id)));
4385
4679
  const remainingPending = pendingCalls.filter((tc) => !processedIds.has(tc.id));
4386
4680
  // Determine status:
4387
4681
  // - Any still-unprocessed approval-required call keeps us in 'awaiting_approval'
@@ -4432,6 +4726,8 @@ export class ModelResult {
4432
4726
  // user message between dangling function_calls and their future
4433
4727
  // outputs would be invalid history).
4434
4728
  if (nextStatus !== 'in_progress') {
4729
+ this.preparedToolCalls.clear();
4730
+ this.hookDeniedCalls.clear();
4435
4731
  return;
4436
4732
  }
4437
4733
  // A doom-stop armed while executing the approved calls (the restored
@@ -4506,7 +4802,7 @@ export class ModelResult {
4506
4802
  }
4507
4803
  // Handle both streaming and non-streaming responses
4508
4804
  if (isEventStream(apiResult.value)) {
4509
- this.reusableStream = new ReusableReadableStream(apiResult.value);
4805
+ this.setReusableStream(apiResult.value);
4510
4806
  }
4511
4807
  else if (this.isNonStreamingResponse(apiResult.value)) {
4512
4808
  this.finalResponse = apiResult.value;
@@ -4794,6 +5090,24 @@ export class ModelResult {
4794
5090
  pendingToolCalls.length > 0 &&
4795
5091
  this.hasExecutableToolCalls(pendingToolCalls)) {
4796
5092
  const turnNumber = currentRound + 1;
5093
+ // Gate these calls exactly like a normal round would. This path
5094
+ // executes real tools, so it needs the same approval check as the
5095
+ // in-loop call sites above — without it, `stopWhen` firing on a turn
5096
+ // that carries a `requireApproval` call would run that call
5097
+ // unguarded, and hook-based 'deny' would never fire either (the
5098
+ // deny bookkeeping lives inside handleApprovalCheck).
5099
+ //
5100
+ // On pause, handleApprovalCheck persists `pendingToolCalls` +
5101
+ // status 'awaiting_approval', executes any auto-approved calls as
5102
+ // unsent results, and sets `finalResponse` — so returning here is
5103
+ // safe: nothing executed, so there is no round to record, and we
5104
+ // must NOT fall through to markStateComplete() or the final
5105
+ // text-coercion request. `sessionEndReason` stays 'max_turns' —
5106
+ // accurate (the loop did stop on the stop condition) and consistent
5107
+ // with the HITL pause return further down this same block.
5108
+ if (await this.handleApprovalCheck(pendingToolCalls, turnNumber, currentResponse)) {
5109
+ return;
5110
+ }
4797
5111
  const turnContext = {
4798
5112
  numberOfTurns: turnNumber,
4799
5113
  };
@@ -5352,10 +5666,12 @@ export class ModelResult {
5352
5666
  continue;
5353
5667
  }
5354
5668
  if (event.type === 'tool.preliminary_result') {
5669
+ const prelim = event;
5355
5670
  yield {
5356
5671
  type: 'preliminary_result',
5357
- toolCallId: event.toolCallId,
5358
- result: event.result,
5672
+ toolCallId: prelim.toolCallId,
5673
+ toolName: prelim.toolName,
5674
+ result: prelim.result,
5359
5675
  };
5360
5676
  }
5361
5677
  }
@@ -5379,7 +5695,7 @@ export class ModelResult {
5379
5695
  if (!this.reusableStream) {
5380
5696
  throw new Error('Stream not initialized');
5381
5697
  }
5382
- const completedResponse = await consumeStreamForCompletion(this.reusableStream);
5698
+ const completedResponse = await this.getInitialResponse();
5383
5699
  await this.emitPendingModelCallOnce(completedResponse);
5384
5700
  return extractToolCallsFromResponse(completedResponse);
5385
5701
  }
@@ -5652,7 +5968,7 @@ export class ModelResult {
5652
5968
  // reusable stream is a passive observation: it buffers events
5653
5969
  // without executing tools or mutating conversation state, so the
5654
5970
  // resume generation is counted without advancing the loop.
5655
- await this.emitPendingModelCallOnce(await consumeStreamForCompletion(this.reusableStream));
5971
+ await this.emitPendingModelCallOnce(await this.getInitialResponse());
5656
5972
  }
5657
5973
  }
5658
5974
  catch (error) {