@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.
- package/README.md +44 -7
- package/esm/index.d.ts +5 -2
- 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/anthropic-compat.d.ts +4 -1
- package/esm/lib/anthropic-compat.d.ts.map +1 -1
- package/esm/lib/anthropic-compat.js +11 -10
- package/esm/lib/anthropic-compat.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/chat-compat.d.ts +4 -1
- package/esm/lib/chat-compat.d.ts.map +1 -1
- package/esm/lib/chat-compat.js +53 -18
- package/esm/lib/chat-compat.js.map +1 -1
- package/esm/lib/chat-compat.test.js +205 -0
- package/esm/lib/chat-compat.test.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/item-types.d.ts +16 -1
- package/esm/lib/item-types.d.ts.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 +405 -126
- 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/stream-transformers.d.ts +12 -1
- package/esm/lib/stream-transformers.d.ts.map +1 -1
- package/esm/lib/stream-transformers.js +31 -1
- package/esm/lib/stream-transformers.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-executor.d.ts.map +1 -1
- package/esm/lib/tool-executor.js +4 -5
- package/esm/lib/tool-executor.js.map +1 -1
- package/esm/lib/tool-orchestrator.d.ts +0 -1
- package/esm/lib/tool-orchestrator.d.ts.map +1 -1
- package/esm/lib/tool-orchestrator.js +1 -4
- package/esm/lib/tool-orchestrator.js.map +1 -1
- package/esm/lib/tool-types.d.ts +15 -1
- package/esm/lib/tool-types.d.ts.map +1 -1
- package/esm/lib/tool-types.js.map +1 -1
- package/esm/lib/tool.d.ts +10 -2
- package/esm/lib/tool.d.ts.map +1 -1
- package/esm/lib/tool.js +3 -0
- package/esm/lib/tool.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/lib/user-agent.d.ts +3 -0
- package/esm/lib/user-agent.d.ts.map +1 -0
- package/esm/lib/user-agent.js +5 -0
- package/esm/lib/user-agent.js.map +1 -0
- package/esm/mcp/create-mcp-tools.d.ts +3 -2
- package/esm/mcp/create-mcp-tools.d.ts.map +1 -1
- package/esm/mcp/create-mcp-tools.js +11 -3
- package/esm/mcp/create-mcp-tools.js.map +1 -1
- package/esm/mcp/handle.d.ts +8 -0
- package/esm/mcp/handle.d.ts.map +1 -1
- package/esm/mcp/handle.js +14 -0
- package/esm/mcp/handle.js.map +1 -1
- package/esm/mcp/rehydrate.d.ts.map +1 -1
- package/esm/mcp/rehydrate.js +9 -1
- package/esm/mcp/rehydrate.js.map +1 -1
- package/esm/mcp/types.d.ts +8 -1
- package/esm/mcp/types.d.ts.map +1 -1
- package/esm/mcp/version.d.ts +1 -1
- package/esm/mcp/version.d.ts.map +1 -1
- package/esm/mcp/version.js +1 -1
- package/esm/mcp/version.js.map +1 -1
- package/esm/openrouter.d.ts.map +1 -1
- package/esm/openrouter.js +10 -5
- package/esm/openrouter.js.map +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
|
/**
|
|
@@ -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
|
|
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
|
-
|
|
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) {
|
|
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:
|
|
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
|
|
1000
|
-
|
|
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(
|
|
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 =
|
|
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
|
-
|
|
2116
|
-
|
|
2117
|
-
|
|
2118
|
-
|
|
2119
|
-
|
|
2120
|
-
|
|
2121
|
-
|
|
2122
|
-
|
|
2123
|
-
|
|
2124
|
-
|
|
2125
|
-
|
|
2126
|
-
|
|
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
|
-
|
|
2129
|
-
|
|
2130
|
-
|
|
2338
|
+
return {
|
|
2339
|
+
unseenCalls,
|
|
2340
|
+
blockedCalls,
|
|
2131
2341
|
};
|
|
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.
|
|
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 === '
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
|
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
|
|
5958
|
+
await this.emitPendingModelCallOnce(await this.getInitialResponse());
|
|
5680
5959
|
}
|
|
5681
5960
|
}
|
|
5682
5961
|
catch (error) {
|