@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.
- package/README.md +18 -0
- package/esm/index.d.ts +2 -1
- package/esm/index.d.ts.map +1 -1
- package/esm/index.js.map +1 -1
- package/esm/inner-loop/call-model.d.ts.map +1 -1
- package/esm/inner-loop/call-model.js +2 -1
- package/esm/inner-loop/call-model.js.map +1 -1
- package/esm/lib/async-params.d.ts +9 -1
- package/esm/lib/async-params.d.ts.map +1 -1
- package/esm/lib/async-params.js +1 -0
- package/esm/lib/async-params.js.map +1 -1
- package/esm/lib/conversation-state.d.ts +1 -1
- package/esm/lib/conversation-state.d.ts.map +1 -1
- package/esm/lib/conversation-state.js +48 -13
- package/esm/lib/conversation-state.js.map +1 -1
- package/esm/lib/hooks-schemas.d.ts +3 -7
- package/esm/lib/hooks-schemas.d.ts.map +1 -1
- package/esm/lib/hooks-schemas.js.map +1 -1
- package/esm/lib/model-result.d.ts +29 -1
- package/esm/lib/model-result.d.ts.map +1 -1
- package/esm/lib/model-result.js +400 -108
- package/esm/lib/model-result.js.map +1 -1
- package/esm/lib/reusable-stream.d.ts +30 -3
- package/esm/lib/reusable-stream.d.ts.map +1 -1
- package/esm/lib/reusable-stream.js +127 -32
- package/esm/lib/reusable-stream.js.map +1 -1
- package/esm/lib/tool-event-broadcaster.d.ts +18 -5
- package/esm/lib/tool-event-broadcaster.d.ts.map +1 -1
- package/esm/lib/tool-event-broadcaster.js +90 -19
- package/esm/lib/tool-event-broadcaster.js.map +1 -1
- package/esm/lib/tool-types.d.ts +5 -1
- package/esm/lib/tool-types.d.ts.map +1 -1
- package/esm/lib/tool-types.js.map +1 -1
- package/esm/lib/turn-context.d.ts +1 -1
- package/esm/lib/turn-context.d.ts.map +1 -1
- package/esm/lib/turn-context.js +1 -2
- package/esm/lib/turn-context.js.map +1 -1
- package/esm/mcp/version.d.ts +1 -1
- package/esm/mcp/version.js +1 -1
- package/package.json +5 -1
package/esm/lib/model-result.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
976
|
-
|
|
977
|
-
|
|
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:
|
|
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
|
|
1000
|
-
|
|
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(
|
|
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 =
|
|
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
|
-
|
|
2116
|
-
|
|
2117
|
-
|
|
2118
|
-
|
|
2119
|
-
|
|
2120
|
-
|
|
2121
|
-
|
|
2122
|
-
|
|
2123
|
-
|
|
2124
|
-
|
|
2125
|
-
|
|
2126
|
-
|
|
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
|
-
|
|
2129
|
-
|
|
2130
|
-
|
|
2344
|
+
return {
|
|
2345
|
+
unseenCalls,
|
|
2346
|
+
blockedCalls,
|
|
2131
2347
|
};
|
|
2132
|
-
|
|
2133
|
-
|
|
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 === '
|
|
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
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
|
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
|
|
5971
|
+
await this.emitPendingModelCallOnce(await this.getInitialResponse());
|
|
5680
5972
|
}
|
|
5681
5973
|
}
|
|
5682
5974
|
catch (error) {
|