@openrouter/agent 0.10.0 → 1.0.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 (89) hide show
  1. package/README.md +44 -7
  2. package/esm/index.d.ts +5 -2
  3. package/esm/index.d.ts.map +1 -1
  4. package/esm/index.js.map +1 -1
  5. package/esm/inner-loop/call-model.d.ts.map +1 -1
  6. package/esm/inner-loop/call-model.js +2 -1
  7. package/esm/inner-loop/call-model.js.map +1 -1
  8. package/esm/lib/anthropic-compat.d.ts +4 -1
  9. package/esm/lib/anthropic-compat.d.ts.map +1 -1
  10. package/esm/lib/anthropic-compat.js +11 -10
  11. package/esm/lib/anthropic-compat.js.map +1 -1
  12. package/esm/lib/async-params.d.ts +9 -1
  13. package/esm/lib/async-params.d.ts.map +1 -1
  14. package/esm/lib/async-params.js +1 -0
  15. package/esm/lib/async-params.js.map +1 -1
  16. package/esm/lib/chat-compat.d.ts +4 -1
  17. package/esm/lib/chat-compat.d.ts.map +1 -1
  18. package/esm/lib/chat-compat.js +53 -18
  19. package/esm/lib/chat-compat.js.map +1 -1
  20. package/esm/lib/chat-compat.test.js +205 -0
  21. package/esm/lib/chat-compat.test.js.map +1 -1
  22. package/esm/lib/conversation-state.d.ts +1 -1
  23. package/esm/lib/conversation-state.d.ts.map +1 -1
  24. package/esm/lib/conversation-state.js +48 -13
  25. package/esm/lib/conversation-state.js.map +1 -1
  26. package/esm/lib/hooks-schemas.d.ts +3 -7
  27. package/esm/lib/hooks-schemas.d.ts.map +1 -1
  28. package/esm/lib/hooks-schemas.js.map +1 -1
  29. package/esm/lib/item-types.d.ts +16 -1
  30. package/esm/lib/item-types.d.ts.map +1 -1
  31. package/esm/lib/model-result.d.ts +29 -1
  32. package/esm/lib/model-result.d.ts.map +1 -1
  33. package/esm/lib/model-result.js +405 -126
  34. package/esm/lib/model-result.js.map +1 -1
  35. package/esm/lib/reusable-stream.d.ts +30 -3
  36. package/esm/lib/reusable-stream.d.ts.map +1 -1
  37. package/esm/lib/reusable-stream.js +127 -32
  38. package/esm/lib/reusable-stream.js.map +1 -1
  39. package/esm/lib/stream-transformers.d.ts +12 -1
  40. package/esm/lib/stream-transformers.d.ts.map +1 -1
  41. package/esm/lib/stream-transformers.js +31 -1
  42. package/esm/lib/stream-transformers.js.map +1 -1
  43. package/esm/lib/tool-event-broadcaster.d.ts +18 -5
  44. package/esm/lib/tool-event-broadcaster.d.ts.map +1 -1
  45. package/esm/lib/tool-event-broadcaster.js +90 -19
  46. package/esm/lib/tool-event-broadcaster.js.map +1 -1
  47. package/esm/lib/tool-executor.d.ts.map +1 -1
  48. package/esm/lib/tool-executor.js +4 -5
  49. package/esm/lib/tool-executor.js.map +1 -1
  50. package/esm/lib/tool-orchestrator.d.ts +0 -1
  51. package/esm/lib/tool-orchestrator.d.ts.map +1 -1
  52. package/esm/lib/tool-orchestrator.js +1 -4
  53. package/esm/lib/tool-orchestrator.js.map +1 -1
  54. package/esm/lib/tool-types.d.ts +15 -1
  55. package/esm/lib/tool-types.d.ts.map +1 -1
  56. package/esm/lib/tool-types.js.map +1 -1
  57. package/esm/lib/tool.d.ts +10 -2
  58. package/esm/lib/tool.d.ts.map +1 -1
  59. package/esm/lib/tool.js +3 -0
  60. package/esm/lib/tool.js.map +1 -1
  61. package/esm/lib/turn-context.d.ts +1 -1
  62. package/esm/lib/turn-context.d.ts.map +1 -1
  63. package/esm/lib/turn-context.js +1 -2
  64. package/esm/lib/turn-context.js.map +1 -1
  65. package/esm/lib/user-agent.d.ts +3 -0
  66. package/esm/lib/user-agent.d.ts.map +1 -0
  67. package/esm/lib/user-agent.js +5 -0
  68. package/esm/lib/user-agent.js.map +1 -0
  69. package/esm/mcp/create-mcp-tools.d.ts +3 -2
  70. package/esm/mcp/create-mcp-tools.d.ts.map +1 -1
  71. package/esm/mcp/create-mcp-tools.js +11 -3
  72. package/esm/mcp/create-mcp-tools.js.map +1 -1
  73. package/esm/mcp/handle.d.ts +8 -0
  74. package/esm/mcp/handle.d.ts.map +1 -1
  75. package/esm/mcp/handle.js +14 -0
  76. package/esm/mcp/handle.js.map +1 -1
  77. package/esm/mcp/rehydrate.d.ts.map +1 -1
  78. package/esm/mcp/rehydrate.js +9 -1
  79. package/esm/mcp/rehydrate.js.map +1 -1
  80. package/esm/mcp/types.d.ts +8 -1
  81. package/esm/mcp/types.d.ts.map +1 -1
  82. package/esm/mcp/version.d.ts +1 -1
  83. package/esm/mcp/version.d.ts.map +1 -1
  84. package/esm/mcp/version.js +1 -1
  85. package/esm/mcp/version.js.map +1 -1
  86. package/esm/openrouter.d.ts.map +1 -1
  87. package/esm/openrouter.js +10 -5
  88. package/esm/openrouter.js.map +1 -1
  89. package/package.json +5 -1
@@ -1,7 +1,8 @@
1
1
  import { betaResponsesSend } from '@openrouter/sdk/funcs/betaResponsesSend';
2
+ import * as z4 from 'zod/v4';
2
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,16 +601,13 @@ 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, toolName, source, result, preliminaryResults) {
604
+ broadcastToolResult(toolCallId, toolName, source, result) {
567
605
  this.toolEventBroadcaster?.push({
568
606
  type: 'tool_result',
569
607
  toolCallId,
570
608
  toolName,
571
609
  source,
572
610
  result,
573
- ...(preliminaryResults?.length && {
574
- preliminaryResults,
575
- }),
576
611
  });
577
612
  this.turnBroadcaster?.push({
578
613
  type: 'tool.result',
@@ -581,9 +616,6 @@ export class ModelResult {
581
616
  source,
582
617
  result,
583
618
  timestamp: Date.now(),
584
- ...(preliminaryResults?.length && {
585
- preliminaryResults,
586
- }),
587
619
  });
588
620
  }
589
621
  /**
@@ -642,7 +674,10 @@ export class ModelResult {
642
674
  */
643
675
  async materializeResponse(value, turnNumber) {
644
676
  if (isEventStream(value)) {
645
- const stream = new ReusableReadableStream(value);
677
+ const stream = new ReusableReadableStream(value, {
678
+ streamReplay: this.streamReplay,
679
+ isTerminalValue: isTerminalResponseStreamEvent,
680
+ });
646
681
  if (this.turnBroadcaster) {
647
682
  return this.pipeAndConsumeStream(stream, turnNumber);
648
683
  }
@@ -667,6 +702,17 @@ export class ModelResult {
667
702
  if (this.finalResponse) {
668
703
  return this.finalResponse;
669
704
  }
705
+ const initialPipePromise = this.initialPipePromise;
706
+ if (initialPipePromise) {
707
+ await initialPipePromise;
708
+ }
709
+ if (this.initialResponseError) {
710
+ throw this.initialResponseError;
711
+ }
712
+ if (this.initialResponse) {
713
+ await this.emitPendingModelCallOnce(this.initialResponse);
714
+ return this.initialResponse;
715
+ }
670
716
  if (this.reusableStream) {
671
717
  const response = await consumeStreamForCompletion(this.reusableStream);
672
718
  await this.emitPendingModelCallOnce(response);
@@ -674,6 +720,24 @@ export class ModelResult {
674
720
  }
675
721
  throw new Error('Neither stream nor response initialized');
676
722
  }
723
+ extractCachedCompletion() {
724
+ if (this.initialResponseError) {
725
+ throw this.initialResponseError;
726
+ }
727
+ if (this.initialResponse) {
728
+ return this.initialResponse;
729
+ }
730
+ if (!this.reusableStream) {
731
+ throw new Error('Stream not initialized');
732
+ }
733
+ return extractCompletionFromBuffer(this.reusableStream);
734
+ }
735
+ tryExtractCachedCompletion() {
736
+ if (this.initialResponse) {
737
+ return this.initialResponse;
738
+ }
739
+ return this.reusableStream ? tryExtractCompletionFromBuffer(this.reusableStream) : undefined;
740
+ }
677
741
  /**
678
742
  * Save response output to state.
679
743
  * Appends the response output to the message history and records the response ID.
@@ -928,7 +992,7 @@ export class ModelResult {
928
992
  * - `execution`: The tool ran. `result` is the ToolExecutionResult.
929
993
  * `effectiveToolCall` reflects any `mutatedInput` piped by PreToolUse.
930
994
  */
931
- async runToolWithHooks(tool, toolCall, turnContext, onPreliminaryResult, extras) {
995
+ async runToolWithHooks(tool, toolCall, turnContext, onPreliminaryResult, extras, runPreToolUse = true) {
932
996
  // Reject raw-string arguments before any hook fires. When the model
933
997
  // produces invalid JSON, the parser leaves `toolCall.arguments` as the
934
998
  // raw string; handing that to PreToolUse would either fail payload
@@ -938,23 +1002,6 @@ export class ModelResult {
938
1002
  // synthetic error without running the tool or firing hooks.
939
1003
  const rawArgs = toolCall.arguments;
940
1004
  if (typeof rawArgs === 'string') {
941
- // Malformed calls are classic doom-loop fuel: a model stuck emitting
942
- // the same invalid JSON re-triggers this parse error forever. Record
943
- // the raw string as the call's identity so the streak trips the
944
- // detector instead of bouncing off the parse error unbounded. The
945
- // parse-error output below already prevents execution, so 'block'
946
- // needs no extra handling; 'steer'/'stop' side effects are applied
947
- // inside the ordered evaluation.
948
- if (this.doomLoopMonitor) {
949
- await this.enqueueDoomLoopEvaluation({
950
- toolName: String(toolCall.name),
951
- keyMaterial: rawArgs,
952
- fallbackKeyMaterial: null,
953
- allowBlock: true,
954
- detector: 'tool-fingerprint',
955
- toolCall,
956
- });
957
- }
958
1005
  const errorMessage = `Failed to parse tool call arguments for "${toolCall.name}": The model provided invalid JSON. ` +
959
1006
  `Raw arguments received: "${rawArgs}". ` +
960
1007
  'Please provide valid JSON arguments for this tool call.';
@@ -972,32 +1019,20 @@ export class ModelResult {
972
1019
  },
973
1020
  };
974
1021
  }
975
- // Doom-loop checkpoint — BEFORE PreToolUse, on the arguments the MODEL
976
- // issued (pre-mutation): the loop evidence is the model repeating
977
- // itself, and a PreToolUse hook that rewrites input each call (e.g.
978
- // injecting a nonce) must not mask that repetition. A 'block' verdict
979
- // returns the same `hook_blocked` shape as a PreToolUse block, so every
980
- // caller handles it identically; PreToolUse deliberately does not fire
981
- // for doom-blocked calls (nothing will execute — mirrors parse_error).
982
- const doomOutcome = await this.checkDoomLoopBeforeExecution(tool, toolCall);
983
- if (doomOutcome.blocked) {
1022
+ const prepared = this.preparedToolCalls.get(toolCall.id);
1023
+ this.preparedToolCalls.delete(toolCall.id);
1024
+ if (prepared?.type === 'blocked') {
984
1025
  return {
985
1026
  type: 'hook_blocked',
986
1027
  toolCall,
987
- reason: doomOutcome.reason,
988
- output: {
989
- type: 'function_call_output',
990
- id: `output_${toolCall.id}`,
991
- callId: toolCall.id,
992
- output: JSON.stringify({
993
- error: doomOutcome.reason,
994
- }),
995
- },
1028
+ reason: prepared.reason,
1029
+ output: prepared.output,
996
1030
  };
997
1031
  }
998
- let effectiveToolCall = toolCall;
999
- // Emit PreToolUse hook -- can block or mutate input.
1000
- if (this.hooksManager) {
1032
+ let effectiveToolCall = prepared?.type === 'ready' ? prepared.toolCall : toolCall;
1033
+ // Emit PreToolUse here only when the approval gate has not already done
1034
+ // so (for example, an approved call resumed from older persisted state).
1035
+ if (this.hooksManager && !prepared && runPreToolUse) {
1001
1036
  // The hook payload coerces null/undefined arguments to {} for schema
1002
1037
  // validation, but `effectiveToolCall.arguments` only changes when the
1003
1038
  // chain reports an actual mutation (`emit.mutated`), so tools that
@@ -1834,7 +1869,7 @@ export class ModelResult {
1834
1869
  // Sync backward scan of the retained buffer — not a consumer
1835
1870
  // replay, which would cost one microtask hop per buffered event
1836
1871
  // on every hook-less streaming teardown.
1837
- await this.emitPendingModelCallOnce(extractCompletionFromBuffer(this.reusableStream));
1872
+ await this.emitPendingModelCallOnce(this.extractCachedCompletion());
1838
1873
  }
1839
1874
  else if (this.reusableStream) {
1840
1875
  // Consumers stop at the terminal event (streamTerminationEvents),
@@ -1845,7 +1880,7 @@ export class ModelResult {
1845
1880
  // dropping the parked telemetry. Stays silent (no emit, no
1846
1881
  // throw) when nothing terminal was buffered — e.g. an errored
1847
1882
  // mid-flight stream, where no materialized response exists.
1848
- const buffered = tryExtractCompletionFromBuffer(this.reusableStream);
1883
+ const buffered = this.tryExtractCachedCompletion();
1849
1884
  if (buffered) {
1850
1885
  await this.emitPendingModelCallOnce(buffered);
1851
1886
  }
@@ -1951,6 +1986,152 @@ export class ModelResult {
1951
1986
  }),
1952
1987
  };
1953
1988
  }
1989
+ async prepareToolCallForApproval(toolCall) {
1990
+ if (!this.hooksManager || typeof toolCall.arguments === 'string') {
1991
+ return toolCall;
1992
+ }
1993
+ const preResult = await this.hooksManager.emit('PreToolUse', {
1994
+ toolName: toolCall.name,
1995
+ toolInput: (toolCall.arguments ?? {}),
1996
+ }, this.hookEmitContext(toolCall.name));
1997
+ if (preResult.blocked) {
1998
+ const block = preResult.results.find((result) => result.block)?.block;
1999
+ const reason = typeof block === 'string' ? block : 'Blocked by PreToolUse hook';
2000
+ this.preparedToolCalls.set(toolCall.id, {
2001
+ type: 'blocked',
2002
+ reason,
2003
+ output: {
2004
+ type: 'function_call_output',
2005
+ id: `output_${toolCall.id}`,
2006
+ callId: toolCall.id,
2007
+ output: JSON.stringify({
2008
+ error: reason,
2009
+ }),
2010
+ },
2011
+ });
2012
+ return toolCall;
2013
+ }
2014
+ const effectiveToolCall = preResult.mutated
2015
+ ? {
2016
+ ...toolCall,
2017
+ arguments: preResult.finalPayload.toolInput,
2018
+ }
2019
+ : toolCall;
2020
+ this.preparedToolCalls.set(toolCall.id, {
2021
+ type: 'ready',
2022
+ toolCall: effectiveToolCall,
2023
+ mutated: preResult.mutated,
2024
+ });
2025
+ return effectiveToolCall;
2026
+ }
2027
+ approvalResponseKey(response) {
2028
+ let occurrence = this.approvalResponseOccurrences.get(response);
2029
+ if (occurrence === undefined) {
2030
+ occurrence = this.nextApprovalResponseOccurrence++;
2031
+ this.approvalResponseOccurrences.set(response, occurrence);
2032
+ }
2033
+ return `${response.id}:${occurrence}`;
2034
+ }
2035
+ approvalGateKey(phase, responseKey, occurrence) {
2036
+ return JSON.stringify([
2037
+ phase,
2038
+ responseKey,
2039
+ occurrence,
2040
+ ]);
2041
+ }
2042
+ assignApprovalCallOccurrences(response, toolCalls) {
2043
+ let identities = this.approvalCallOccurrences.get(response);
2044
+ if (!identities) {
2045
+ identities = [];
2046
+ this.approvalCallOccurrences.set(response, identities);
2047
+ }
2048
+ const occurrences = new Map();
2049
+ for (const [index, toolCall] of toolCalls.entries()) {
2050
+ const identity = identities[index] ?? `call:${this.nextApprovalCallOccurrence++}`;
2051
+ identities[index] = identity;
2052
+ occurrences.set(toolCall, identity);
2053
+ }
2054
+ return occurrences;
2055
+ }
2056
+ /** Re-check only approval sources whose answer can depend on input. */
2057
+ async mutatedInputRequiresApproval(toolCall, context) {
2058
+ const tools = this.options.tools;
2059
+ if (!tools) {
2060
+ return false;
2061
+ }
2062
+ const tool = tools.find((candidate) => isClientTool(candidate) && candidate.function.name === toolCall.name);
2063
+ if (this.requireApprovalFn) {
2064
+ return toolRequiresApproval(toolCall, tools, context, this.requireApprovalFn);
2065
+ }
2066
+ if (!tool || !isClientTool(tool) || typeof tool.function.requireApproval !== 'function') {
2067
+ return false;
2068
+ }
2069
+ return toolRequiresApproval(toolCall, tools, context);
2070
+ }
2071
+ async emitPreparedFailure(toolCall, reason) {
2072
+ if (!this.hooksManager) {
2073
+ return;
2074
+ }
2075
+ await this.hooksManager.emit('PostToolUseFailure', {
2076
+ toolName: toolCall.name,
2077
+ toolInput: (toolCall.arguments ?? {}),
2078
+ error: new Error(reason),
2079
+ }, this.hookEmitContext(toolCall.name));
2080
+ }
2081
+ async blockPreparedToolCall(toolCall, reason) {
2082
+ this.preparedToolCalls.set(toolCall.id, {
2083
+ type: 'blocked',
2084
+ reason,
2085
+ output: {
2086
+ type: 'function_call_output',
2087
+ id: `output_${toolCall.id}`,
2088
+ callId: toolCall.id,
2089
+ output: JSON.stringify({
2090
+ error: reason,
2091
+ }),
2092
+ },
2093
+ });
2094
+ await this.emitPreparedFailure(toolCall, reason);
2095
+ }
2096
+ async validatePreparedMutation(effective) {
2097
+ const tool = this.options.tools?.find((candidate) => isClientTool(candidate) && candidate.function.name === effective.name);
2098
+ if (!tool || !isClientTool(tool)) {
2099
+ return 'ready';
2100
+ }
2101
+ const parsed = z4.safeParse(tool.function.inputSchema, effective.arguments);
2102
+ if (!parsed.success || !isRecord(parsed.data)) {
2103
+ await this.blockPreparedToolCall(effective, `PreToolUse produced invalid input for "${effective.name}"`);
2104
+ return 'blocked';
2105
+ }
2106
+ return 'ready';
2107
+ }
2108
+ async prepareAfterInitialApproval(toolCall, context, responseKey, occurrence) {
2109
+ const effective = await this.prepareToolCallForApproval(toolCall);
2110
+ const prepared = this.preparedToolCalls.get(toolCall.id);
2111
+ if (prepared?.type !== 'ready' || !prepared.mutated) {
2112
+ return prepared?.type === 'blocked' ? 'blocked' : 'ready';
2113
+ }
2114
+ if ((await this.validatePreparedMutation(effective)) === 'blocked') {
2115
+ return 'blocked';
2116
+ }
2117
+ const key = this.approvalGateKey('mutated', responseKey, occurrence);
2118
+ if (this.completedApprovalGates.has(key)) {
2119
+ return 'ready';
2120
+ }
2121
+ this.completedApprovalGates.add(key);
2122
+ if (!(await this.mutatedInputRequiresApproval(effective, context))) {
2123
+ return 'ready';
2124
+ }
2125
+ const { decision, reason } = await this.emitPermissionRequest(effective);
2126
+ if (decision === 'allow') {
2127
+ return 'ready';
2128
+ }
2129
+ if (decision === 'deny') {
2130
+ await this.blockPreparedToolCall(effective, reason ?? 'Denied by PermissionRequest hook');
2131
+ return 'blocked';
2132
+ }
2133
+ return 'pending';
2134
+ }
1954
2135
  /**
1955
2136
  * Run the UserPromptSubmit hook, supporting both string and structured
1956
2137
  * inputs. If a handler returns a mutated prompt, the returned object
@@ -2058,9 +2239,6 @@ export class ModelResult {
2058
2239
  * @returns Array of unsent tool results for later submission
2059
2240
  */
2060
2241
  async executeAutoApproveTools(toolCalls, turnContext) {
2061
- // Auto-approved batch = one doom-loop round: identical parallel calls
2062
- // count once (see beginDoomLoopRound).
2063
- await this.beginDoomLoopRound(toolCalls);
2064
2242
  const toolCallPromises = toolCalls.map(async (tc) => {
2065
2243
  const tool = this.options.tools?.find((t) => isClientTool(t) && t.function.name === tc.name);
2066
2244
  if (!tool || !isAutoResolvableTool(tool)) {
@@ -2111,52 +2289,70 @@ export class ModelResult {
2111
2289
  }
2112
2290
  return results;
2113
2291
  }
2114
- /**
2115
- * Check for tools requiring approval and handle accordingly.
2116
- * Partitions tool calls into those needing approval and those that can auto-execute.
2117
- *
2118
- * @param toolCalls - The tool calls to check
2119
- * @param currentRound - The current execution round (1-indexed)
2120
- * @param currentResponse - The current response to save if pausing
2121
- * @returns True if execution should pause for approval, false to continue
2122
- * @throws Error if approval is required but no state accessor is configured
2123
- */
2124
- async handleApprovalCheck(toolCalls, currentRound, currentResponse) {
2125
- if (!this.options.tools) {
2126
- return false;
2292
+ async classifyInitialApprovalCalls(toolCalls, responseKey, occurrences) {
2293
+ const unseenCalls = [];
2294
+ const blockedCalls = [];
2295
+ const unseenKeys = toolCalls.filter((toolCall) => !this.completedApprovalGates.has(this.approvalGateKey('initial', responseKey, occurrences.get(toolCall) ?? '')));
2296
+ await this.beginDoomLoopRound(unseenKeys);
2297
+ for (const toolCall of toolCalls) {
2298
+ const key = this.approvalGateKey('initial', responseKey, occurrences.get(toolCall) ?? '');
2299
+ if (this.completedApprovalGates.has(key)) {
2300
+ continue;
2301
+ }
2302
+ this.completedApprovalGates.add(key);
2303
+ const tool = this.options.tools?.find((candidate) => isClientTool(candidate) && candidate.function.name === toolCall.name);
2304
+ if (tool && isAutoResolvableTool(tool)) {
2305
+ const rawArgs = toolCall.arguments;
2306
+ const doomOutcome = typeof rawArgs === 'string' && this.doomLoopMonitor
2307
+ ? await this.enqueueDoomLoopEvaluation({
2308
+ toolName: String(toolCall.name),
2309
+ keyMaterial: rawArgs,
2310
+ fallbackKeyMaterial: null,
2311
+ allowBlock: true,
2312
+ detector: 'tool-fingerprint',
2313
+ toolCall,
2314
+ }).then((decision) => ({
2315
+ blocked: decision.action === 'block' || decision.action === 'stop',
2316
+ reason: decision.message ?? 'Blocked by doom loop',
2317
+ }))
2318
+ : await this.checkDoomLoopBeforeExecution(tool, toolCall);
2319
+ if (doomOutcome.blocked) {
2320
+ this.preparedToolCalls.set(toolCall.id, {
2321
+ type: 'blocked',
2322
+ reason: doomOutcome.reason,
2323
+ output: {
2324
+ type: 'function_call_output',
2325
+ id: `output_${toolCall.id}`,
2326
+ callId: toolCall.id,
2327
+ output: JSON.stringify({
2328
+ error: doomOutcome.reason,
2329
+ }),
2330
+ },
2331
+ });
2332
+ blockedCalls.push(toolCall);
2333
+ continue;
2334
+ }
2335
+ }
2336
+ unseenCalls.push(toolCall);
2127
2337
  }
2128
- const turnContext = {
2129
- numberOfTurns: currentRound,
2130
- // context is handled via contextStore, not on TurnContext
2338
+ return {
2339
+ unseenCalls,
2340
+ blockedCalls,
2131
2341
  };
2132
- const { requiresApproval: needsApproval, autoExecute } = await partitionToolCalls(toolCalls, this.options.tools, turnContext, this.requireApprovalFn ?? undefined);
2133
- // Nothing needs an approval gate: return immediately WITHOUT executing
2134
- // anything. The main loop's executeToolRound runs every call exactly
2135
- // once; pre-executing here would double-run side-effecting tools.
2136
- if (needsApproval.length === 0) {
2137
- return false;
2138
- }
2139
- // Run the PermissionRequest hook for each tool that needs approval.
2140
- // This lets hooks short-circuit the approval flow in either direction:
2141
- // 'allow' promotes the call past the gate (executed once by the normal
2142
- // round), 'deny' synthesizes a rejection (recorded so the round emits a
2143
- // rejected output instead of executing), 'ask_user' falls through to the
2144
- // human approval flow.
2342
+ }
2343
+ async resolveApprovalPhases(needsApproval, autoExecute, turnContext, responseKey, occurrences) {
2145
2344
  const denied = [];
2146
2345
  const stillPending = [];
2147
2346
  if (this.hooksManager) {
2148
2347
  for (const tc of needsApproval) {
2149
2348
  const { decision, reason } = await this.emitPermissionRequest(tc);
2150
- if (decision === 'allow') {
2151
- // Promoted past the gate; the normal tool round executes it once.
2152
- }
2153
- else if (decision === 'deny') {
2349
+ if (decision === 'deny') {
2154
2350
  denied.push({
2155
2351
  tc,
2156
2352
  reason: reason ?? 'Denied by PermissionRequest hook',
2157
2353
  });
2158
2354
  }
2159
- else {
2355
+ else if (decision !== 'allow') {
2160
2356
  stillPending.push(tc);
2161
2357
  }
2162
2358
  }
@@ -2164,10 +2360,59 @@ export class ModelResult {
2164
2360
  else {
2165
2361
  stillPending.push(...needsApproval);
2166
2362
  }
2363
+ const initialSurvivors = [
2364
+ ...autoExecute,
2365
+ ...needsApproval.filter((tc) => !stillPending.some((pending) => pending.id === tc.id) &&
2366
+ !denied.some((entry) => entry.tc.id === tc.id)),
2367
+ ];
2368
+ for (const toolCall of initialSurvivors) {
2369
+ const tool = this.options.tools?.find((candidate) => isClientTool(candidate) && candidate.function.name === toolCall.name);
2370
+ if (!tool || !isAutoResolvableTool(tool)) {
2371
+ continue;
2372
+ }
2373
+ if ((await this.prepareAfterInitialApproval(toolCall, turnContext, responseKey, occurrences.get(toolCall) ?? '')) === 'pending') {
2374
+ const prepared = this.preparedToolCalls.get(toolCall.id);
2375
+ if (prepared?.type === 'ready') {
2376
+ stillPending.push(prepared.toolCall);
2377
+ }
2378
+ }
2379
+ }
2380
+ return {
2381
+ denied,
2382
+ stillPending,
2383
+ initialSurvivors,
2384
+ };
2385
+ }
2386
+ /**
2387
+ * Check for tools requiring approval and handle accordingly.
2388
+ * Partitions tool calls into those needing approval and those that can auto-execute.
2389
+ *
2390
+ * @param toolCalls - The tool calls to check
2391
+ * @param currentRound - The current execution round (1-indexed)
2392
+ * @param currentResponse - The current response to save if pausing
2393
+ * @returns True if execution should pause for approval, false to continue
2394
+ * @throws Error if approval is required but no state accessor is configured
2395
+ */
2396
+ async handleApprovalCheck(suppliedToolCalls, currentRound, currentResponse) {
2397
+ if (!this.options.tools) {
2398
+ return false;
2399
+ }
2400
+ const turnContext = {
2401
+ numberOfTurns: currentRound,
2402
+ };
2403
+ const responseKey = this.approvalResponseKey(currentResponse);
2404
+ // Always enumerate the complete response so subset visits find the original
2405
+ // response-local occurrence and appended calls receive fresh identities.
2406
+ const responseToolCalls = extractToolCallsFromResponse(currentResponse);
2407
+ const toolCalls = responseToolCalls.length > 0 ? responseToolCalls : suppliedToolCalls;
2408
+ const occurrences = this.assignApprovalCallOccurrences(currentResponse, toolCalls);
2409
+ const { unseenCalls, blockedCalls } = await this.classifyInitialApprovalCalls(toolCalls, responseKey, occurrences);
2410
+ if (unseenCalls.length === 0 && blockedCalls.length === 0) {
2411
+ return false;
2412
+ }
2413
+ const { requiresApproval: needsApproval, autoExecute } = await partitionToolCalls(unseenCalls, this.options.tools, turnContext, this.requireApprovalFn ?? undefined);
2414
+ const { denied, stillPending, initialSurvivors } = await this.resolveApprovalPhases(needsApproval, autoExecute, turnContext, responseKey, occurrences);
2167
2415
  if (stillPending.length === 0) {
2168
- // The hook resolved every gated call, so we do not pause. Record denied
2169
- // calls so executeToolRound synthesizes rejections instead of running
2170
- // them; allowed calls execute once via the normal round.
2171
2416
  for (const d of denied) {
2172
2417
  this.hookDeniedCalls.set(d.tc.id, d.reason);
2173
2418
  }
@@ -2182,7 +2427,12 @@ export class ModelResult {
2182
2427
  // We are pausing: the normal tool round will NOT run for this response,
2183
2428
  // so execute the auto-approved calls now and persist their results as
2184
2429
  // unsent so the resume path can pick them up without re-executing.
2185
- const unsentResults = await this.executeAutoApproveTools(autoExecute, turnContext);
2430
+ const pendingIds = new Set(stillPending.map((call) => call.id));
2431
+ const executableNow = [
2432
+ ...initialSurvivors.filter((call) => !pendingIds.has(call.id)),
2433
+ ...blockedCalls,
2434
+ ];
2435
+ const unsentResults = await this.executeAutoApproveTools(executableNow, turnContext);
2186
2436
  // Combine pre-denied results (from hook "deny") with executed results.
2187
2437
  const combinedResults = [
2188
2438
  ...unsentResults,
@@ -2190,13 +2440,20 @@ export class ModelResult {
2190
2440
  ];
2191
2441
  // Save state with pending approvals (only reached when stillPending > 0).
2192
2442
  const stateUpdates = {
2193
- pendingToolCalls: stillPending,
2443
+ pendingToolCalls: stillPending.map((toolCall) => ({
2444
+ ...toolCall,
2445
+ ...(this.preparedToolCalls.has(toolCall.id) && {
2446
+ preToolUseApplied: true,
2447
+ }),
2448
+ })),
2194
2449
  status: 'awaiting_approval',
2195
2450
  };
2196
2451
  if (combinedResults.length > 0) {
2197
2452
  stateUpdates.unsentToolResults = combinedResults;
2198
2453
  }
2199
2454
  await this.saveStateSafely(stateUpdates);
2455
+ this.preparedToolCalls.clear();
2456
+ this.hookDeniedCalls.clear();
2200
2457
  this.finalResponse = currentResponse;
2201
2458
  return true; // Pause for approval
2202
2459
  }
@@ -2448,13 +2705,10 @@ export class ModelResult {
2448
2705
  if (hookDenied !== null) {
2449
2706
  return hookDenied;
2450
2707
  }
2451
- const preliminaryResultsForCall = [];
2452
2708
  const hasBroadcaster = this.toolEventBroadcaster || this.turnBroadcaster;
2453
2709
  const onPreliminaryResult = hasBroadcaster
2454
2710
  ? (callId, resultValue) => {
2455
- const typedResult = resultValue;
2456
- preliminaryResultsForCall.push(typedResult);
2457
- this.broadcastPreliminaryResult(callId, String(toolCall.name), typedResult);
2711
+ this.broadcastPreliminaryResult(callId, String(toolCall.name), resultValue);
2458
2712
  }
2459
2713
  : undefined;
2460
2714
  // Compose the per-call abort signal (run signal + tool/run timeout +
@@ -2504,7 +2758,7 @@ export class ModelResult {
2504
2758
  });
2505
2759
  const executed = await this.raceToolDeadline(executionPromise, String(toolCall.name), timeoutMs, controller);
2506
2760
  if (executed === 'timeout') {
2507
- return this.buildToolTimeoutOutcome(toolCall, tool, timeoutMs, preliminaryResultsForCall);
2761
+ return this.buildToolTimeoutOutcome(toolCall, tool, timeoutMs);
2508
2762
  }
2509
2763
  if (executed.type === 'parse_error') {
2510
2764
  this.broadcastToolResult(toolCall.id, String(toolCall.name), isMcpTool(tool) ? 'mcp' : 'client', {
@@ -2535,7 +2789,6 @@ export class ModelResult {
2535
2789
  controller,
2536
2790
  timeoutMs,
2537
2791
  runBinding,
2538
- preliminaryResultsForCall,
2539
2792
  };
2540
2793
  }
2541
2794
  return {
@@ -2543,7 +2796,6 @@ export class ModelResult {
2543
2796
  toolCall: executed.effectiveToolCall,
2544
2797
  tool,
2545
2798
  result,
2546
- preliminaryResultsForCall,
2547
2799
  };
2548
2800
  }
2549
2801
  finally {
@@ -2559,7 +2811,7 @@ export class ModelResult {
2559
2811
  * the model receives an error output immediately (the abandoned body
2560
2812
  * keeps running detached).
2561
2813
  */
2562
- buildToolTimeoutOutcome(toolCall, tool, timeoutMs, preliminaryResultsForCall) {
2814
+ buildToolTimeoutOutcome(toolCall, tool, timeoutMs) {
2563
2815
  const message = `Tool "${toolCall.name}" timed out after ${timeoutMs}ms`;
2564
2816
  this.broadcastToolResult(toolCall.id, String(toolCall.name), isMcpTool(tool) ? 'mcp' : 'client', {
2565
2817
  error: message,
@@ -2574,7 +2826,6 @@ export class ModelResult {
2574
2826
  code: 'tool_timeout',
2575
2827
  }),
2576
2828
  },
2577
- preliminaryResultsForCall,
2578
2829
  };
2579
2830
  }
2580
2831
  /**
@@ -2628,9 +2879,6 @@ export class ModelResult {
2628
2879
  });
2629
2880
  }
2630
2881
  async executeToolRound(toolCalls, turnContext) {
2631
- // One executed batch = one doom-loop round: identical parallel calls in
2632
- // this batch count as ONE piece of loop evidence and share a decision.
2633
- await this.beginDoomLoopRound(toolCalls);
2634
2882
  const toolCallPromises = toolCalls.map((toolCall) => this.executeSingleToolCall(toolCall, turnContext));
2635
2883
  const settledResults = await Promise.allSettled(toolCallPromises);
2636
2884
  const toolResults = [];
@@ -2720,7 +2968,7 @@ export class ModelResult {
2720
2968
  error: value.result.error.message,
2721
2969
  }
2722
2970
  : value.result.result);
2723
- this.broadcastToolResult(value.toolCall.id, String(value.toolCall.name), isMcpTool(value.tool) ? 'mcp' : 'client', toolResult, value.preliminaryResultsForCall.length > 0 ? value.preliminaryResultsForCall : undefined);
2971
+ this.broadcastToolResult(value.toolCall.id, String(value.toolCall.name), isMcpTool(value.tool) ? 'mcp' : 'client', toolResult);
2724
2972
  const outputForModel = await this.computeToolOutputForModel(value);
2725
2973
  const executedOutput = {
2726
2974
  type: 'function_call_output',
@@ -3138,7 +3386,6 @@ export class ModelResult {
3138
3386
  : {
3139
3387
  result,
3140
3388
  },
3141
- preliminaryResultsForCall: [],
3142
3389
  };
3143
3390
  };
3144
3391
  let input;
@@ -3799,7 +4046,7 @@ export class ModelResult {
3799
4046
  // `clientOnlyFields` in async-params.ts, which handles the async path.
3800
4047
  // (`sharedContextSchema` is absent here: call-model.ts destructures it
3801
4048
  // before the request reaches ModelResult.)
3802
- const { stopWhen: _, state: _s, requireApproval: _r, approveToolCalls: _a, rejectToolCalls: _rj, context: _c, onTurnStart: _ots, onTurnEnd: _ote, allowFinalResponse: _afr, strictFinalResponse: _sfr, hooks: _h, doomLoop: _dl, signal: _sig, toolTimeoutMs: _ttm, toolConcurrency: _tc, asyncTools: _at, activeTools: _activeTools, ...rest } = this.options.request;
4049
+ 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;
3803
4050
  const resolved = {
3804
4051
  ...rest,
3805
4052
  };
@@ -4304,7 +4551,7 @@ export class ModelResult {
4304
4551
  // Handle both streaming and non-streaming responses
4305
4552
  // The API may return a non-streaming response even when stream: true is requested
4306
4553
  if (isEventStream(apiResult.value)) {
4307
- this.reusableStream = new ReusableReadableStream(apiResult.value);
4554
+ this.setReusableStream(apiResult.value);
4308
4555
  }
4309
4556
  else if (this.isNonStreamingResponse(apiResult.value)) {
4310
4557
  // API returned a complete response directly - use it as the final response
@@ -4333,19 +4580,12 @@ export class ModelResult {
4333
4580
  numberOfTurns: this.allToolExecutionRounds.length + 1,
4334
4581
  // context is handled via contextStore, not on TurnContext
4335
4582
  };
4583
+ // Calls that pause again after PreToolUse mutation remain pending without
4584
+ // re-running the hook on the next resume.
4585
+ const secondGatePausedIds = new Set();
4336
4586
  // Track approved HITL calls that paused (onToolCalled returned null) —
4337
4587
  // these stay in pendingToolCalls so the caller can resume them later.
4338
4588
  const hitlPausedIds = new Set();
4339
- // The approved batch is one doom-loop round: N approved duplicates of
4340
- // the same call count once (the sequential loop below still evaluates
4341
- // in order; restored streaks from the persisted state carry forward).
4342
- // Declared from the approved calls only — a pending call the user did not
4343
- // approve is not part of this round.
4344
- await this.beginDoomLoopRound([
4345
- ...this.approvedToolCalls,
4346
- ]
4347
- .map((callId) => pendingCalls.find((tc) => tc.id === callId))
4348
- .filter((tc) => tc !== undefined));
4349
4589
  // Process approvals - execute the approved tools. Route through
4350
4590
  // runToolWithHooks so PreToolUse/PostToolUse fire even on this path.
4351
4591
  for (const callId of this.approvedToolCalls) {
@@ -4359,7 +4599,20 @@ export class ModelResult {
4359
4599
  unsentResults.push(createRejectedResult(callId, String(toolCall.name), 'Tool not found or not executable'));
4360
4600
  continue;
4361
4601
  }
4362
- const hookOutcome = await this.runToolWithHooks(tool, toolCall, turnContext);
4602
+ if (toolCall.preToolUseApplied !== true) {
4603
+ const prepared = await this.prepareAfterInitialApproval(toolCall, turnContext, `persisted:${this.currentState.previousResponseId ?? 'unknown'}`, callId);
4604
+ if (prepared === 'pending') {
4605
+ const ready = this.preparedToolCalls.get(callId);
4606
+ if (ready?.type === 'ready') {
4607
+ Object.assign(toolCall, ready.toolCall, {
4608
+ preToolUseApplied: true,
4609
+ });
4610
+ }
4611
+ secondGatePausedIds.add(callId);
4612
+ continue;
4613
+ }
4614
+ }
4615
+ const hookOutcome = await this.runToolWithHooks(tool, toolCall, turnContext, undefined, undefined, false);
4363
4616
  if (hookOutcome.type === 'parse_error') {
4364
4617
  this.broadcastToolResult(callId, String(toolCall.name), this.toolSourceByName(String(toolCall.name)), {
4365
4618
  error: hookOutcome.errorMessage,
@@ -4395,7 +4648,13 @@ export class ModelResult {
4395
4648
  if (!toolCall) {
4396
4649
  continue;
4397
4650
  }
4398
- unsentResults.push(createRejectedResult(callId, String(toolCall.name), 'Rejected by user'));
4651
+ const reason = 'Rejected by user';
4652
+ if (toolCall.preToolUseApplied === true) {
4653
+ await this.emitPreparedFailure(toolCall, reason);
4654
+ }
4655
+ this.preparedToolCalls.delete(callId);
4656
+ this.hookDeniedCalls.delete(callId);
4657
+ unsentResults.push(createRejectedResult(callId, String(toolCall.name), reason));
4399
4658
  }
4400
4659
  // Remove processed calls from pending. Approved HITL calls that paused are
4401
4660
  // NOT considered processed — they stay on pendingToolCalls so getPendingToolCalls()
@@ -4403,7 +4662,7 @@ export class ModelResult {
4403
4662
  const processedIds = new Set([
4404
4663
  ...this.approvedToolCalls,
4405
4664
  ...this.rejectedToolCalls,
4406
- ].filter((id) => !hitlPausedIds.has(id)));
4665
+ ].filter((id) => !hitlPausedIds.has(id) && !secondGatePausedIds.has(id)));
4407
4666
  const remainingPending = pendingCalls.filter((tc) => !processedIds.has(tc.id));
4408
4667
  // Determine status:
4409
4668
  // - Any still-unprocessed approval-required call keeps us in 'awaiting_approval'
@@ -4454,6 +4713,8 @@ export class ModelResult {
4454
4713
  // user message between dangling function_calls and their future
4455
4714
  // outputs would be invalid history).
4456
4715
  if (nextStatus !== 'in_progress') {
4716
+ this.preparedToolCalls.clear();
4717
+ this.hookDeniedCalls.clear();
4457
4718
  return;
4458
4719
  }
4459
4720
  // A doom-stop armed while executing the approved calls (the restored
@@ -4528,7 +4789,7 @@ export class ModelResult {
4528
4789
  }
4529
4790
  // Handle both streaming and non-streaming responses
4530
4791
  if (isEventStream(apiResult.value)) {
4531
- this.reusableStream = new ReusableReadableStream(apiResult.value);
4792
+ this.setReusableStream(apiResult.value);
4532
4793
  }
4533
4794
  else if (this.isNonStreamingResponse(apiResult.value)) {
4534
4795
  this.finalResponse = apiResult.value;
@@ -4816,6 +5077,24 @@ export class ModelResult {
4816
5077
  pendingToolCalls.length > 0 &&
4817
5078
  this.hasExecutableToolCalls(pendingToolCalls)) {
4818
5079
  const turnNumber = currentRound + 1;
5080
+ // Gate these calls exactly like a normal round would. This path
5081
+ // executes real tools, so it needs the same approval check as the
5082
+ // in-loop call sites above — without it, `stopWhen` firing on a turn
5083
+ // that carries a `requireApproval` call would run that call
5084
+ // unguarded, and hook-based 'deny' would never fire either (the
5085
+ // deny bookkeeping lives inside handleApprovalCheck).
5086
+ //
5087
+ // On pause, handleApprovalCheck persists `pendingToolCalls` +
5088
+ // status 'awaiting_approval', executes any auto-approved calls as
5089
+ // unsent results, and sets `finalResponse` — so returning here is
5090
+ // safe: nothing executed, so there is no round to record, and we
5091
+ // must NOT fall through to markStateComplete() or the final
5092
+ // text-coercion request. `sessionEndReason` stays 'max_turns' —
5093
+ // accurate (the loop did stop on the stop condition) and consistent
5094
+ // with the HITL pause return further down this same block.
5095
+ if (await this.handleApprovalCheck(pendingToolCalls, turnNumber, currentResponse)) {
5096
+ return;
5097
+ }
4819
5098
  const turnContext = {
4820
5099
  numberOfTurns: turnNumber,
4821
5100
  };
@@ -5403,7 +5682,7 @@ export class ModelResult {
5403
5682
  if (!this.reusableStream) {
5404
5683
  throw new Error('Stream not initialized');
5405
5684
  }
5406
- const completedResponse = await consumeStreamForCompletion(this.reusableStream);
5685
+ const completedResponse = await this.getInitialResponse();
5407
5686
  await this.emitPendingModelCallOnce(completedResponse);
5408
5687
  return extractToolCallsFromResponse(completedResponse);
5409
5688
  }
@@ -5676,7 +5955,7 @@ export class ModelResult {
5676
5955
  // reusable stream is a passive observation: it buffers events
5677
5956
  // without executing tools or mutating conversation state, so the
5678
5957
  // resume generation is counted without advancing the loop.
5679
- await this.emitPendingModelCallOnce(await consumeStreamForCompletion(this.reusableStream));
5958
+ await this.emitPendingModelCallOnce(await this.getInitialResponse());
5680
5959
  }
5681
5960
  }
5682
5961
  catch (error) {