@openrouter/agent 0.10.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 (40) hide show
  1. package/README.md +18 -0
  2. package/esm/index.d.ts +2 -1
  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/async-params.d.ts +9 -1
  9. package/esm/lib/async-params.d.ts.map +1 -1
  10. package/esm/lib/async-params.js +1 -0
  11. package/esm/lib/async-params.js.map +1 -1
  12. package/esm/lib/conversation-state.d.ts +1 -1
  13. package/esm/lib/conversation-state.d.ts.map +1 -1
  14. package/esm/lib/conversation-state.js +48 -13
  15. package/esm/lib/conversation-state.js.map +1 -1
  16. package/esm/lib/hooks-schemas.d.ts +3 -7
  17. package/esm/lib/hooks-schemas.d.ts.map +1 -1
  18. package/esm/lib/hooks-schemas.js.map +1 -1
  19. package/esm/lib/model-result.d.ts +29 -1
  20. package/esm/lib/model-result.d.ts.map +1 -1
  21. package/esm/lib/model-result.js +400 -108
  22. package/esm/lib/model-result.js.map +1 -1
  23. package/esm/lib/reusable-stream.d.ts +30 -3
  24. package/esm/lib/reusable-stream.d.ts.map +1 -1
  25. package/esm/lib/reusable-stream.js +127 -32
  26. package/esm/lib/reusable-stream.js.map +1 -1
  27. package/esm/lib/tool-event-broadcaster.d.ts +18 -5
  28. package/esm/lib/tool-event-broadcaster.d.ts.map +1 -1
  29. package/esm/lib/tool-event-broadcaster.js +90 -19
  30. package/esm/lib/tool-event-broadcaster.js.map +1 -1
  31. package/esm/lib/tool-types.d.ts +5 -1
  32. package/esm/lib/tool-types.d.ts.map +1 -1
  33. package/esm/lib/tool-types.js.map +1 -1
  34. package/esm/lib/turn-context.d.ts +1 -1
  35. package/esm/lib/turn-context.d.ts.map +1 -1
  36. package/esm/lib/turn-context.js +1 -2
  37. package/esm/lib/turn-context.js.map +1 -1
  38. package/esm/mcp/version.d.ts +1 -1
  39. package/esm/mcp/version.js +1 -1
  40. 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
  /**
@@ -642,7 +680,10 @@ export class ModelResult {
642
680
  */
643
681
  async materializeResponse(value, turnNumber) {
644
682
  if (isEventStream(value)) {
645
- const stream = new ReusableReadableStream(value);
683
+ const stream = new ReusableReadableStream(value, {
684
+ streamReplay: this.streamReplay,
685
+ isTerminalValue: isTerminalResponseStreamEvent,
686
+ });
646
687
  if (this.turnBroadcaster) {
647
688
  return this.pipeAndConsumeStream(stream, turnNumber);
648
689
  }
@@ -667,6 +708,17 @@ export class ModelResult {
667
708
  if (this.finalResponse) {
668
709
  return this.finalResponse;
669
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
+ }
670
722
  if (this.reusableStream) {
671
723
  const response = await consumeStreamForCompletion(this.reusableStream);
672
724
  await this.emitPendingModelCallOnce(response);
@@ -674,6 +726,24 @@ export class ModelResult {
674
726
  }
675
727
  throw new Error('Neither stream nor response initialized');
676
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
+ }
677
747
  /**
678
748
  * Save response output to state.
679
749
  * Appends the response output to the message history and records the response ID.
@@ -928,7 +998,7 @@ export class ModelResult {
928
998
  * - `execution`: The tool ran. `result` is the ToolExecutionResult.
929
999
  * `effectiveToolCall` reflects any `mutatedInput` piped by PreToolUse.
930
1000
  */
931
- async runToolWithHooks(tool, toolCall, turnContext, onPreliminaryResult, extras) {
1001
+ async runToolWithHooks(tool, toolCall, turnContext, onPreliminaryResult, extras, runPreToolUse = true) {
932
1002
  // Reject raw-string arguments before any hook fires. When the model
933
1003
  // produces invalid JSON, the parser leaves `toolCall.arguments` as the
934
1004
  // raw string; handing that to PreToolUse would either fail payload
@@ -938,23 +1008,6 @@ export class ModelResult {
938
1008
  // synthetic error without running the tool or firing hooks.
939
1009
  const rawArgs = toolCall.arguments;
940
1010
  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
1011
  const errorMessage = `Failed to parse tool call arguments for "${toolCall.name}": The model provided invalid JSON. ` +
959
1012
  `Raw arguments received: "${rawArgs}". ` +
960
1013
  'Please provide valid JSON arguments for this tool call.';
@@ -972,32 +1025,20 @@ export class ModelResult {
972
1025
  },
973
1026
  };
974
1027
  }
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) {
1028
+ const prepared = this.preparedToolCalls.get(toolCall.id);
1029
+ this.preparedToolCalls.delete(toolCall.id);
1030
+ if (prepared?.type === 'blocked') {
984
1031
  return {
985
1032
  type: 'hook_blocked',
986
1033
  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
- },
1034
+ reason: prepared.reason,
1035
+ output: prepared.output,
996
1036
  };
997
1037
  }
998
- let effectiveToolCall = toolCall;
999
- // Emit PreToolUse hook -- can block or mutate input.
1000
- 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) {
1001
1042
  // The hook payload coerces null/undefined arguments to {} for schema
1002
1043
  // validation, but `effectiveToolCall.arguments` only changes when the
1003
1044
  // chain reports an actual mutation (`emit.mutated`), so tools that
@@ -1834,7 +1875,7 @@ export class ModelResult {
1834
1875
  // Sync backward scan of the retained buffer — not a consumer
1835
1876
  // replay, which would cost one microtask hop per buffered event
1836
1877
  // on every hook-less streaming teardown.
1837
- await this.emitPendingModelCallOnce(extractCompletionFromBuffer(this.reusableStream));
1878
+ await this.emitPendingModelCallOnce(this.extractCachedCompletion());
1838
1879
  }
1839
1880
  else if (this.reusableStream) {
1840
1881
  // Consumers stop at the terminal event (streamTerminationEvents),
@@ -1845,7 +1886,7 @@ export class ModelResult {
1845
1886
  // dropping the parked telemetry. Stays silent (no emit, no
1846
1887
  // throw) when nothing terminal was buffered — e.g. an errored
1847
1888
  // mid-flight stream, where no materialized response exists.
1848
- const buffered = tryExtractCompletionFromBuffer(this.reusableStream);
1889
+ const buffered = this.tryExtractCachedCompletion();
1849
1890
  if (buffered) {
1850
1891
  await this.emitPendingModelCallOnce(buffered);
1851
1892
  }
@@ -1951,6 +1992,152 @@ export class ModelResult {
1951
1992
  }),
1952
1993
  };
1953
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
+ }
1954
2141
  /**
1955
2142
  * Run the UserPromptSubmit hook, supporting both string and structured
1956
2143
  * inputs. If a handler returns a mutated prompt, the returned object
@@ -2058,9 +2245,6 @@ export class ModelResult {
2058
2245
  * @returns Array of unsent tool results for later submission
2059
2246
  */
2060
2247
  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
2248
  const toolCallPromises = toolCalls.map(async (tc) => {
2065
2249
  const tool = this.options.tools?.find((t) => isClientTool(t) && t.function.name === tc.name);
2066
2250
  if (!tool || !isAutoResolvableTool(tool)) {
@@ -2111,52 +2295,70 @@ export class ModelResult {
2111
2295
  }
2112
2296
  return results;
2113
2297
  }
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;
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);
2127
2343
  }
2128
- const turnContext = {
2129
- numberOfTurns: currentRound,
2130
- // context is handled via contextStore, not on TurnContext
2344
+ return {
2345
+ unseenCalls,
2346
+ blockedCalls,
2131
2347
  };
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.
2348
+ }
2349
+ async resolveApprovalPhases(needsApproval, autoExecute, turnContext, responseKey, occurrences) {
2145
2350
  const denied = [];
2146
2351
  const stillPending = [];
2147
2352
  if (this.hooksManager) {
2148
2353
  for (const tc of needsApproval) {
2149
2354
  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') {
2355
+ if (decision === 'deny') {
2154
2356
  denied.push({
2155
2357
  tc,
2156
2358
  reason: reason ?? 'Denied by PermissionRequest hook',
2157
2359
  });
2158
2360
  }
2159
- else {
2361
+ else if (decision !== 'allow') {
2160
2362
  stillPending.push(tc);
2161
2363
  }
2162
2364
  }
@@ -2164,10 +2366,59 @@ export class ModelResult {
2164
2366
  else {
2165
2367
  stillPending.push(...needsApproval);
2166
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);
2167
2421
  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
2422
  for (const d of denied) {
2172
2423
  this.hookDeniedCalls.set(d.tc.id, d.reason);
2173
2424
  }
@@ -2182,7 +2433,12 @@ export class ModelResult {
2182
2433
  // We are pausing: the normal tool round will NOT run for this response,
2183
2434
  // so execute the auto-approved calls now and persist their results as
2184
2435
  // unsent so the resume path can pick them up without re-executing.
2185
- 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);
2186
2442
  // Combine pre-denied results (from hook "deny") with executed results.
2187
2443
  const combinedResults = [
2188
2444
  ...unsentResults,
@@ -2190,13 +2446,20 @@ export class ModelResult {
2190
2446
  ];
2191
2447
  // Save state with pending approvals (only reached when stillPending > 0).
2192
2448
  const stateUpdates = {
2193
- pendingToolCalls: stillPending,
2449
+ pendingToolCalls: stillPending.map((toolCall) => ({
2450
+ ...toolCall,
2451
+ ...(this.preparedToolCalls.has(toolCall.id) && {
2452
+ preToolUseApplied: true,
2453
+ }),
2454
+ })),
2194
2455
  status: 'awaiting_approval',
2195
2456
  };
2196
2457
  if (combinedResults.length > 0) {
2197
2458
  stateUpdates.unsentToolResults = combinedResults;
2198
2459
  }
2199
2460
  await this.saveStateSafely(stateUpdates);
2461
+ this.preparedToolCalls.clear();
2462
+ this.hookDeniedCalls.clear();
2200
2463
  this.finalResponse = currentResponse;
2201
2464
  return true; // Pause for approval
2202
2465
  }
@@ -2628,9 +2891,6 @@ export class ModelResult {
2628
2891
  });
2629
2892
  }
2630
2893
  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
2894
  const toolCallPromises = toolCalls.map((toolCall) => this.executeSingleToolCall(toolCall, turnContext));
2635
2895
  const settledResults = await Promise.allSettled(toolCallPromises);
2636
2896
  const toolResults = [];
@@ -3799,7 +4059,7 @@ export class ModelResult {
3799
4059
  // `clientOnlyFields` in async-params.ts, which handles the async path.
3800
4060
  // (`sharedContextSchema` is absent here: call-model.ts destructures it
3801
4061
  // 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;
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;
3803
4063
  const resolved = {
3804
4064
  ...rest,
3805
4065
  };
@@ -4304,7 +4564,7 @@ export class ModelResult {
4304
4564
  // Handle both streaming and non-streaming responses
4305
4565
  // The API may return a non-streaming response even when stream: true is requested
4306
4566
  if (isEventStream(apiResult.value)) {
4307
- this.reusableStream = new ReusableReadableStream(apiResult.value);
4567
+ this.setReusableStream(apiResult.value);
4308
4568
  }
4309
4569
  else if (this.isNonStreamingResponse(apiResult.value)) {
4310
4570
  // API returned a complete response directly - use it as the final response
@@ -4333,19 +4593,12 @@ export class ModelResult {
4333
4593
  numberOfTurns: this.allToolExecutionRounds.length + 1,
4334
4594
  // context is handled via contextStore, not on TurnContext
4335
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();
4336
4599
  // Track approved HITL calls that paused (onToolCalled returned null) —
4337
4600
  // these stay in pendingToolCalls so the caller can resume them later.
4338
4601
  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
4602
  // Process approvals - execute the approved tools. Route through
4350
4603
  // runToolWithHooks so PreToolUse/PostToolUse fire even on this path.
4351
4604
  for (const callId of this.approvedToolCalls) {
@@ -4359,7 +4612,20 @@ export class ModelResult {
4359
4612
  unsentResults.push(createRejectedResult(callId, String(toolCall.name), 'Tool not found or not executable'));
4360
4613
  continue;
4361
4614
  }
4362
- 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);
4363
4629
  if (hookOutcome.type === 'parse_error') {
4364
4630
  this.broadcastToolResult(callId, String(toolCall.name), this.toolSourceByName(String(toolCall.name)), {
4365
4631
  error: hookOutcome.errorMessage,
@@ -4395,7 +4661,13 @@ export class ModelResult {
4395
4661
  if (!toolCall) {
4396
4662
  continue;
4397
4663
  }
4398
- 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));
4399
4671
  }
4400
4672
  // Remove processed calls from pending. Approved HITL calls that paused are
4401
4673
  // NOT considered processed — they stay on pendingToolCalls so getPendingToolCalls()
@@ -4403,7 +4675,7 @@ export class ModelResult {
4403
4675
  const processedIds = new Set([
4404
4676
  ...this.approvedToolCalls,
4405
4677
  ...this.rejectedToolCalls,
4406
- ].filter((id) => !hitlPausedIds.has(id)));
4678
+ ].filter((id) => !hitlPausedIds.has(id) && !secondGatePausedIds.has(id)));
4407
4679
  const remainingPending = pendingCalls.filter((tc) => !processedIds.has(tc.id));
4408
4680
  // Determine status:
4409
4681
  // - Any still-unprocessed approval-required call keeps us in 'awaiting_approval'
@@ -4454,6 +4726,8 @@ export class ModelResult {
4454
4726
  // user message between dangling function_calls and their future
4455
4727
  // outputs would be invalid history).
4456
4728
  if (nextStatus !== 'in_progress') {
4729
+ this.preparedToolCalls.clear();
4730
+ this.hookDeniedCalls.clear();
4457
4731
  return;
4458
4732
  }
4459
4733
  // A doom-stop armed while executing the approved calls (the restored
@@ -4528,7 +4802,7 @@ export class ModelResult {
4528
4802
  }
4529
4803
  // Handle both streaming and non-streaming responses
4530
4804
  if (isEventStream(apiResult.value)) {
4531
- this.reusableStream = new ReusableReadableStream(apiResult.value);
4805
+ this.setReusableStream(apiResult.value);
4532
4806
  }
4533
4807
  else if (this.isNonStreamingResponse(apiResult.value)) {
4534
4808
  this.finalResponse = apiResult.value;
@@ -4816,6 +5090,24 @@ export class ModelResult {
4816
5090
  pendingToolCalls.length > 0 &&
4817
5091
  this.hasExecutableToolCalls(pendingToolCalls)) {
4818
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
+ }
4819
5111
  const turnContext = {
4820
5112
  numberOfTurns: turnNumber,
4821
5113
  };
@@ -5403,7 +5695,7 @@ export class ModelResult {
5403
5695
  if (!this.reusableStream) {
5404
5696
  throw new Error('Stream not initialized');
5405
5697
  }
5406
- const completedResponse = await consumeStreamForCompletion(this.reusableStream);
5698
+ const completedResponse = await this.getInitialResponse();
5407
5699
  await this.emitPendingModelCallOnce(completedResponse);
5408
5700
  return extractToolCallsFromResponse(completedResponse);
5409
5701
  }
@@ -5676,7 +5968,7 @@ export class ModelResult {
5676
5968
  // reusable stream is a passive observation: it buffers events
5677
5969
  // without executing tools or mutating conversation state, so the
5678
5970
  // resume generation is counted without advancing the loop.
5679
- await this.emitPendingModelCallOnce(await consumeStreamForCompletion(this.reusableStream));
5971
+ await this.emitPendingModelCallOnce(await this.getInitialResponse());
5680
5972
  }
5681
5973
  }
5682
5974
  catch (error) {