@openrouter/agent 0.8.0 → 0.9.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 +483 -5
- package/esm/index.d.ts +19 -4
- package/esm/index.d.ts.map +1 -1
- package/esm/index.js +9 -2
- 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 +40 -41
- package/esm/inner-loop/call-model.js.map +1 -1
- package/esm/inner-loop/resume-tool-results.d.ts +128 -0
- package/esm/inner-loop/resume-tool-results.d.ts.map +1 -0
- package/esm/inner-loop/resume-tool-results.js +244 -0
- package/esm/inner-loop/resume-tool-results.js.map +1 -0
- package/esm/lib/agent-tool.d.ts +92 -0
- package/esm/lib/agent-tool.d.ts.map +1 -0
- package/esm/lib/agent-tool.js +211 -0
- package/esm/lib/agent-tool.js.map +1 -0
- package/esm/lib/async-params.d.ts +82 -0
- package/esm/lib/async-params.d.ts.map +1 -1
- package/esm/lib/async-params.js +5 -0
- package/esm/lib/async-params.js.map +1 -1
- package/esm/lib/async-tool-registry.d.ts +134 -0
- package/esm/lib/async-tool-registry.d.ts.map +1 -0
- package/esm/lib/async-tool-registry.js +352 -0
- package/esm/lib/async-tool-registry.js.map +1 -0
- package/esm/lib/async-tools.d.ts +23 -0
- package/esm/lib/async-tools.d.ts.map +1 -0
- package/esm/lib/async-tools.js +19 -0
- package/esm/lib/async-tools.js.map +1 -0
- package/esm/lib/conversation-state.d.ts +1 -0
- package/esm/lib/conversation-state.d.ts.map +1 -1
- package/esm/lib/conversation-state.js +8 -0
- package/esm/lib/conversation-state.js.map +1 -1
- package/esm/lib/doom-loop.d.ts +533 -0
- package/esm/lib/doom-loop.d.ts.map +1 -0
- package/esm/lib/doom-loop.js +1221 -0
- package/esm/lib/doom-loop.js.map +1 -0
- package/esm/lib/hooks-schemas.d.ts +33 -0
- package/esm/lib/hooks-schemas.d.ts.map +1 -1
- package/esm/lib/hooks-schemas.js +54 -0
- package/esm/lib/hooks-schemas.js.map +1 -1
- package/esm/lib/hooks-types.d.ts +6 -2
- package/esm/lib/hooks-types.d.ts.map +1 -1
- package/esm/lib/hooks-types.js.map +1 -1
- package/esm/lib/model-result.d.ts +646 -10
- package/esm/lib/model-result.d.ts.map +1 -1
- package/esm/lib/model-result.js +2749 -153
- package/esm/lib/model-result.js.map +1 -1
- package/esm/lib/reusable-stream.d.ts +9 -0
- package/esm/lib/reusable-stream.d.ts.map +1 -1
- package/esm/lib/reusable-stream.js +17 -0
- package/esm/lib/reusable-stream.js.map +1 -1
- package/esm/lib/stream-transformers.d.ts +20 -0
- package/esm/lib/stream-transformers.d.ts.map +1 -1
- package/esm/lib/stream-transformers.js +45 -0
- package/esm/lib/stream-transformers.js.map +1 -1
- package/esm/lib/tool-check.d.ts +76 -0
- package/esm/lib/tool-check.d.ts.map +1 -0
- package/esm/lib/tool-check.js +241 -0
- package/esm/lib/tool-check.js.map +1 -0
- package/esm/lib/tool-concurrency.d.ts +42 -0
- package/esm/lib/tool-concurrency.d.ts.map +1 -0
- package/esm/lib/tool-concurrency.js +80 -0
- package/esm/lib/tool-concurrency.js.map +1 -0
- package/esm/lib/tool-context.d.ts +45 -1
- package/esm/lib/tool-context.d.ts.map +1 -1
- package/esm/lib/tool-context.js +60 -1
- package/esm/lib/tool-context.js.map +1 -1
- package/esm/lib/tool-executor.d.ts +52 -9
- package/esm/lib/tool-executor.d.ts.map +1 -1
- package/esm/lib/tool-executor.js +253 -21
- package/esm/lib/tool-executor.js.map +1 -1
- package/esm/lib/tool-orchestrator.d.ts.map +1 -1
- package/esm/lib/tool-orchestrator.js +87 -50
- package/esm/lib/tool-orchestrator.js.map +1 -1
- package/esm/lib/tool-task.d.ts +157 -0
- package/esm/lib/tool-task.d.ts.map +1 -0
- package/esm/lib/tool-task.js +206 -0
- package/esm/lib/tool-task.js.map +1 -0
- package/esm/lib/tool-types.d.ts +473 -14
- package/esm/lib/tool-types.d.ts.map +1 -1
- package/esm/lib/tool-types.js +59 -10
- package/esm/lib/tool-types.js.map +1 -1
- package/esm/lib/tool.d.ts +184 -2
- package/esm/lib/tool.d.ts.map +1 -1
- package/esm/lib/tool.js +193 -3
- package/esm/lib/tool.js.map +1 -1
- package/package.json +30 -2
package/esm/lib/model-result.js
CHANGED
|
@@ -1,16 +1,17 @@
|
|
|
1
1
|
import { betaResponsesSend } from '@openrouter/sdk/funcs/betaResponsesSend';
|
|
2
2
|
import { hasAsyncFunctions, resolveAsyncFunctions } from './async-params.js';
|
|
3
|
-
import {
|
|
3
|
+
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 { canonicalizeKeyMaterial, DoomLoopMonitor, resolveDoomLoopOption, resolveLoopKeyMaterial, } from './doom-loop.js';
|
|
4
6
|
import { applyNextTurnParamsToRequest, executeNextTurnParamsFunctions, } from './next-turn-params.js';
|
|
5
7
|
import { ReusableReadableStream } from './reusable-stream.js';
|
|
6
8
|
import { isStopConditionMet } from './stop-conditions.js';
|
|
7
|
-
import { buildItemsStream, buildResponsesMessageStream, buildToolCallStream, consumeStreamForCompletion, extractReasoningDeltas, extractResponsesMessageFromResponse, extractTextDeltas, extractTextFromResponse, extractToolCallsFromResponse, extractToolDeltas, itemsStreamHandlers, streamTerminationEvents, } from './stream-transformers.js';
|
|
9
|
+
import { buildItemsStream, buildResponsesMessageStream, buildToolCallStream, consumeStreamForCompletion, extractCompletionFromBuffer, extractReasoningDeltas, extractResponsesMessageFromResponse, extractTextDeltas, extractTextFromResponse, extractToolCallsFromResponse, extractToolDeltas, itemsStreamHandlers, responseHasToolCalls, streamTerminationEvents, tryExtractCompletionFromBuffer, } from './stream-transformers.js';
|
|
8
10
|
import { hasTypeProperty, isFunctionCallItem, isFunctionCallOutputItem, isOutputTextDeltaEvent, isReasoningDeltaEvent, isResponseCompletedEvent, isResponseFailedEvent, isResponseIncompleteEvent, isServerToolResultItem, } from './stream-type-guards.js';
|
|
9
11
|
import { resolveContext, ToolContextStore } from './tool-context.js';
|
|
10
12
|
import { ToolEventBroadcaster } from './tool-event-broadcaster.js';
|
|
11
|
-
import { applyOnResponseReceivedHooks, executeTool } from './tool-executor.js';
|
|
12
|
-
import { isAutoResolvableTool, isClientTool, isMcpTool, isServerTool, isToolCallOutputEvent, } from './tool-types.js';
|
|
13
|
-
import { normalizeInputToArray } from './turn-context.js';
|
|
13
|
+
import { applyOnResponseReceivedHooks, executeTool, isAsyncToolInvocation, validateToolInput, } from './tool-executor.js';
|
|
14
|
+
import { isAgentTool, isAutoResolvableTool, isClientTool, isLongRunningTool, isMcpTool, isServerTool, isToolCallOutputEvent, isUnifiedTool, } from './tool-types.js';
|
|
14
15
|
/**
|
|
15
16
|
* Default directive appended as a final user message on the forced final
|
|
16
17
|
* turn (`allowFinalResponse` defaulting to on, or explicitly `true`).
|
|
@@ -28,11 +29,93 @@ export const DEFAULT_FINAL_RESPONSE_DIRECTIVE = 'You have reached the tool-use l
|
|
|
28
29
|
function isRecord(value) {
|
|
29
30
|
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
30
31
|
}
|
|
32
|
+
/**
|
|
33
|
+
* Relax a tool choice that *forces* a tool call, for follow-up turns after a
|
|
34
|
+
* tool round has executed (DEV-785).
|
|
35
|
+
*
|
|
36
|
+
* - `'required'` → `'auto'`
|
|
37
|
+
* - a specific-tool object (`{ type: 'function', name }`, `{ type: 'shell' }`,
|
|
38
|
+
* …) → `'auto'`
|
|
39
|
+
* - `allowed_tools` with `mode: 'required'` → same set with `mode: 'auto'`
|
|
40
|
+
* (the constraint on *which* tools may be used still applies; only the
|
|
41
|
+
* force-a-call part is spent)
|
|
42
|
+
* - `'auto'`, `'none'`, `allowed_tools` with `mode: 'auto'`, and `undefined`
|
|
43
|
+
* pass through unchanged.
|
|
44
|
+
*/
|
|
45
|
+
function relaxForcedToolChoice(toolChoice) {
|
|
46
|
+
if (toolChoice === undefined || typeof toolChoice === 'string') {
|
|
47
|
+
return toolChoice === 'required' ? 'auto' : toolChoice;
|
|
48
|
+
}
|
|
49
|
+
if (toolChoice.type === 'allowed_tools') {
|
|
50
|
+
return toolChoice.mode === 'required'
|
|
51
|
+
? {
|
|
52
|
+
...toolChoice,
|
|
53
|
+
mode: 'auto',
|
|
54
|
+
}
|
|
55
|
+
: toolChoice;
|
|
56
|
+
}
|
|
57
|
+
return 'auto';
|
|
58
|
+
}
|
|
59
|
+
/** Whether a caller-configured tool choice requires this turn to call a tool. */
|
|
60
|
+
function isForcedToolChoice(toolChoice) {
|
|
61
|
+
if (toolChoice === undefined || typeof toolChoice === 'string') {
|
|
62
|
+
return toolChoice === 'required';
|
|
63
|
+
}
|
|
64
|
+
return toolChoice.type !== 'allowed_tools' || toolChoice.mode === 'required';
|
|
65
|
+
}
|
|
66
|
+
/** Stable identity for a concrete forced choice; null means tools are not forced. */
|
|
67
|
+
function forcedToolChoiceKey(toolChoice) {
|
|
68
|
+
return isForcedToolChoice(toolChoice) ? canonicalizeKeyMaterial(toolChoice) : null;
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* Extract the call identity echoed on a server-tool output item, for
|
|
72
|
+
* doom-loop fingerprinting. Server tools never pass through the client
|
|
73
|
+
* execution funnel; their output items echo what was asked (a web-search
|
|
74
|
+
* item carries its `action`/`query`, an MCP server-tool item its
|
|
75
|
+
* `name`/`arguments`, etc.). We take every primitive/JSON field except
|
|
76
|
+
* volatile per-invocation ones (ids, status, timing) and the result payload
|
|
77
|
+
* itself, so identical repeated requests fingerprint identically. Returns
|
|
78
|
+
* null when nothing identifying remains (item types with no echoed
|
|
79
|
+
* request identity are skipped rather than collided onto `{}`).
|
|
80
|
+
*/
|
|
81
|
+
function extractServerToolIdentity(item) {
|
|
82
|
+
const record = item;
|
|
83
|
+
// Object.create(null), not `{}`: keys come from a JSON-parsed API response, so
|
|
84
|
+
// a `__proto__` key would hit the prototype setter instead of creating an own
|
|
85
|
+
// property and drop out of the fingerprint. Same reasoning as the
|
|
86
|
+
// Map-backed streak store.
|
|
87
|
+
const identity = Object.create(null);
|
|
88
|
+
for (const [key, value] of Object.entries(record)) {
|
|
89
|
+
if (key === 'id' ||
|
|
90
|
+
key === 'type' ||
|
|
91
|
+
key === 'status' ||
|
|
92
|
+
key === 'output' ||
|
|
93
|
+
key === 'result' ||
|
|
94
|
+
key === 'results' ||
|
|
95
|
+
key === 'error') {
|
|
96
|
+
continue;
|
|
97
|
+
}
|
|
98
|
+
if (value === undefined || typeof value === 'function' || typeof value === 'symbol') {
|
|
99
|
+
continue;
|
|
100
|
+
}
|
|
101
|
+
identity[key] = value;
|
|
102
|
+
}
|
|
103
|
+
return Object.keys(identity).length > 0 ? identity : null;
|
|
104
|
+
}
|
|
31
105
|
// Cap consecutive Stop-hook forceResume overrides so a misbehaving handler
|
|
32
106
|
// cannot spin the loop forever. 3 is a conservative upper bound -- enough to
|
|
33
107
|
// let a hook gather a couple of follow-up actions but small enough that a
|
|
34
108
|
// buggy handler fails fast with a visible warning.
|
|
35
109
|
const MAX_FORCE_RESUME_OVERRIDES = 3;
|
|
110
|
+
/**
|
|
111
|
+
* Sentinel marking a tool-call id that appeared MORE THAN ONCE in one batch.
|
|
112
|
+
* Ids are model-emitted and nothing upstream enforces uniqueness; a colliding
|
|
113
|
+
* id must poison the loop-key cache entry rather than let the last write win,
|
|
114
|
+
* or one call's doom-loop identity aliases onto another's and its repetition
|
|
115
|
+
* becomes invisible to the per-call detector. Colliding calls fall through to
|
|
116
|
+
* per-call resolution at the checkpoint.
|
|
117
|
+
*/
|
|
118
|
+
const DUPLICATE_CALL_ID = Symbol('duplicate-call-id');
|
|
36
119
|
/**
|
|
37
120
|
* Human-readable label for a value that failed the `isRecord` check. Used
|
|
38
121
|
* exclusively to make `toModelOutput` misuse errors specific.
|
|
@@ -198,7 +281,16 @@ export class ModelResult {
|
|
|
198
281
|
this.allToolExecutionRounds = [];
|
|
199
282
|
// Track resolved request after async function resolution
|
|
200
283
|
this.resolvedRequest = null;
|
|
284
|
+
// A forced caller-configured tool choice is one-shot until its resolved
|
|
285
|
+
// semantic value changes. The consumed key survives state-backed pauses;
|
|
286
|
+
// the configured key/value are refreshed whenever async parameters resolve.
|
|
287
|
+
this.activeConsumedForcedToolChoiceKey = null;
|
|
288
|
+
this.configuredForcedToolChoiceKey = null;
|
|
289
|
+
this.preparedToolChoiceCommit = null;
|
|
290
|
+
this.dispatchedToolChoiceCommit = null;
|
|
291
|
+
this.dispatchedCallerToolChoice = false;
|
|
201
292
|
this.resumingFromClientTools = false;
|
|
293
|
+
this.resumingFromAsyncTools = false;
|
|
202
294
|
// State management for multi-turn conversations
|
|
203
295
|
this.stateAccessor = null;
|
|
204
296
|
this.currentState = null;
|
|
@@ -237,8 +329,63 @@ export class ModelResult {
|
|
|
237
329
|
cost: 0,
|
|
238
330
|
hasCost: false,
|
|
239
331
|
};
|
|
332
|
+
// Set when a verdict resolves to 'stop'; checked at the loop boundaries.
|
|
333
|
+
// Persisted inside ConversationState.doomLoop (as `stopVerdict`) so a
|
|
334
|
+
// condemned run stays condemned across decision-only resumes; cleared by
|
|
335
|
+
// a fresh conversational turn (see the initStream restore site).
|
|
336
|
+
this.doomLoopStop = null;
|
|
337
|
+
// Steer messages queued by 'steer' verdicts, injected before the next
|
|
338
|
+
// model request (same mechanism as the Stop hook's appendPrompt).
|
|
339
|
+
// Persisted as `pendingSteer` so guidance queued right before a pause is
|
|
340
|
+
// delivered on resume instead of silently dropped.
|
|
341
|
+
this.pendingDoomLoopSteer = [];
|
|
342
|
+
// Monotonic round counter for doom-loop streaks. Bumped at every
|
|
343
|
+
// execution-batch boundary (tool round, approval auto-approve batch,
|
|
344
|
+
// approved-on-resume batch); identical calls within one batch count as
|
|
345
|
+
// ONE piece of loop evidence (a streak measures the model re-issuing a
|
|
346
|
+
// call after seeing its result, which requires a round trip).
|
|
347
|
+
this.doomLoopRound = 0;
|
|
348
|
+
// Final decision for each (tool, fingerprint) in the current round.
|
|
349
|
+
// Duplicates reuse the decision instead of re-incrementing the streak or
|
|
350
|
+
// re-emitting the DoomLoopDetected hook; `message` carries the verdict's
|
|
351
|
+
// explanation for block outputs.
|
|
352
|
+
this.doomLoopRoundDecisions = new Map();
|
|
353
|
+
// Loop-key resolutions computed while declaring the current round, keyed by
|
|
354
|
+
// tool-call id. A `loopKey` function is user code that may count, log, or
|
|
355
|
+
// return something different each time, so it must run at most once per
|
|
356
|
+
// call: the per-call checkpoint reuses what the declaration resolved instead
|
|
357
|
+
// of resolving again. Cleared with the round. An id seen twice in one batch
|
|
358
|
+
// maps to DUPLICATE_CALL_ID — see the write site.
|
|
359
|
+
this.doomLoopRoundKeyMaterial = new Map();
|
|
360
|
+
// Serialization chain for doom evaluations: parallel tool executions
|
|
361
|
+
// append their evaluation here in call order (the .map() over a round's
|
|
362
|
+
// calls runs synchronously to the first await), so streak recording
|
|
363
|
+
// stays a pure function of the transcript even though hashing is async.
|
|
364
|
+
this.doomLoopChain = Promise.resolve();
|
|
365
|
+
// Armed by an 'escalate' verdict; consumed by the next follow-up request
|
|
366
|
+
// as ONE-TURN overrides (model swap and/or forced openrouter:advisor).
|
|
367
|
+
// Deliberately NOT persisted: an escalation is a reaction to the loop's
|
|
368
|
+
// live state; a resumed run re-detects and re-escalates if still stuck
|
|
369
|
+
// (the CONSUMED budget does persist, via the monitor's escalationsUsed).
|
|
370
|
+
this.pendingDoomLoopEscalation = null;
|
|
371
|
+
// Async tool support (tool.background / tool.deferred). The registry
|
|
372
|
+
// tracks task identity and settled outcomes; created lazily on the first
|
|
373
|
+
// async tool call so runs without async tools carry zero overhead.
|
|
374
|
+
this.asyncToolRegistry = null;
|
|
375
|
+
// Concurrency gates: the round gate bounds simultaneous executions within
|
|
376
|
+
// a tool round; per-tool gates bound one tool across the run; the
|
|
377
|
+
// background pool bounds detached background work.
|
|
378
|
+
this.roundGate = null;
|
|
379
|
+
this.backgroundPool = null;
|
|
380
|
+
this.perToolGates = new Map();
|
|
381
|
+
// Per-call abort controllers for in-flight tool executions, keyed by call
|
|
382
|
+
// id. Aborted on run abort / cancel() so cooperative tool bodies stop.
|
|
383
|
+
this.inflightToolControllers = new Map();
|
|
240
384
|
this.options = options;
|
|
241
385
|
this.hooksManager = options.hooks;
|
|
386
|
+
const doomLoopConfig = resolveDoomLoopOption(options.doomLoop);
|
|
387
|
+
this.doomLoopMonitor = doomLoopConfig ? new DoomLoopMonitor(doomLoopConfig) : null;
|
|
388
|
+
this.doomLoopEscalation = doomLoopConfig?.escalation ?? null;
|
|
242
389
|
// Runtime validation: approval decisions require state
|
|
243
390
|
const hasApprovalDecisions = (options.approveToolCalls && options.approveToolCalls.length > 0) ||
|
|
244
391
|
(options.rejectToolCalls && options.rejectToolCalls.length > 0);
|
|
@@ -251,6 +398,76 @@ export class ModelResult {
|
|
|
251
398
|
this.requireApprovalFn = options.requireApproval ?? null;
|
|
252
399
|
this.approvedToolCalls = options.approveToolCalls ?? [];
|
|
253
400
|
this.rejectedToolCalls = options.rejectToolCalls ?? [];
|
|
401
|
+
// Concurrency gates. Round default stays unbounded (existing behavior);
|
|
402
|
+
// the background pool always has a cap (default 16 — detached work has
|
|
403
|
+
// no round to bound it).
|
|
404
|
+
const concurrency = options.toolConcurrency;
|
|
405
|
+
const roundLimit = typeof concurrency === 'number' ? concurrency : concurrency?.round;
|
|
406
|
+
if (roundLimit !== undefined) {
|
|
407
|
+
this.roundGate = new Semaphore(roundLimit);
|
|
408
|
+
}
|
|
409
|
+
const backgroundLimit = typeof concurrency === 'object' ? (concurrency.background ?? 16) : 16;
|
|
410
|
+
this.backgroundPool = new Semaphore(backgroundLimit);
|
|
411
|
+
}
|
|
412
|
+
/** Lazily create the async tool registry. */
|
|
413
|
+
ensureAsyncToolRegistry() {
|
|
414
|
+
if (!this.asyncToolRegistry) {
|
|
415
|
+
this.asyncToolRegistry = new AsyncToolRegistry();
|
|
416
|
+
}
|
|
417
|
+
return this.asyncToolRegistry;
|
|
418
|
+
}
|
|
419
|
+
/** Per-tool concurrency gate, created on first use. */
|
|
420
|
+
perToolGate(toolName) {
|
|
421
|
+
const tool = this.options.tools?.find((t) => isClientTool(t) && t.function.name === toolName);
|
|
422
|
+
if (!tool || isServerTool(tool)) {
|
|
423
|
+
return undefined;
|
|
424
|
+
}
|
|
425
|
+
const limit = tool.function.maxConcurrency;
|
|
426
|
+
if (limit === undefined) {
|
|
427
|
+
return undefined;
|
|
428
|
+
}
|
|
429
|
+
let gate = this.perToolGates.get(toolName);
|
|
430
|
+
if (!gate) {
|
|
431
|
+
gate = new Semaphore(limit);
|
|
432
|
+
this.perToolGates.set(toolName, gate);
|
|
433
|
+
}
|
|
434
|
+
return gate;
|
|
435
|
+
}
|
|
436
|
+
/**
|
|
437
|
+
* Compose the per-call abort signal for one tool execution: the run
|
|
438
|
+
* signal plus a per-call controller the engine can abort independently
|
|
439
|
+
* (timeout race, cancelTask, cancel()). The tool deadline deliberately
|
|
440
|
+
* does NOT ride the signal here — the race in `executeSingleToolCall`
|
|
441
|
+
* owns exactly one timer (which aborts the controller), so the round's
|
|
442
|
+
* timeout resolution and the body's abort observation cannot disagree.
|
|
443
|
+
*/
|
|
444
|
+
composeToolSignal(toolName, callId) {
|
|
445
|
+
const tool = this.options.tools?.find((t) => isClientTool(t) && t.function.name === toolName);
|
|
446
|
+
const timeoutMs = tool && !isServerTool(tool)
|
|
447
|
+
? (tool.function.timeoutMs ?? this.options.toolTimeoutMs)
|
|
448
|
+
: this.options.toolTimeoutMs;
|
|
449
|
+
const controller = new AbortController();
|
|
450
|
+
this.inflightToolControllers.set(callId, controller);
|
|
451
|
+
const signal = this.options.signal
|
|
452
|
+
? AbortSignal.any([
|
|
453
|
+
controller.signal,
|
|
454
|
+
this.options.signal,
|
|
455
|
+
])
|
|
456
|
+
: controller.signal;
|
|
457
|
+
return {
|
|
458
|
+
controller,
|
|
459
|
+
signal,
|
|
460
|
+
timeoutMs,
|
|
461
|
+
};
|
|
462
|
+
}
|
|
463
|
+
/** Abort every in-flight tool execution and background task. */
|
|
464
|
+
abortAllToolWork(reason) {
|
|
465
|
+
const error = new Error(reason ?? 'Run aborted');
|
|
466
|
+
for (const controller of this.inflightToolControllers.values()) {
|
|
467
|
+
controller.abort(error);
|
|
468
|
+
}
|
|
469
|
+
this.inflightToolControllers.clear();
|
|
470
|
+
this.asyncToolRegistry?.abortAll(reason);
|
|
254
471
|
}
|
|
255
472
|
/**
|
|
256
473
|
* Get or create the unified turn broadcaster (lazy initialization).
|
|
@@ -490,6 +707,13 @@ export class ModelResult {
|
|
|
490
707
|
this.resumingFromClientTools = false;
|
|
491
708
|
stateUpdates.status = 'in_progress';
|
|
492
709
|
}
|
|
710
|
+
if (this.resumingFromAsyncTools) {
|
|
711
|
+
// The resumed request succeeded — the conversation is moving again.
|
|
712
|
+
// Pending tasks that are still unresolved stay on pendingAsyncTools
|
|
713
|
+
// (resumeToolResults maintains that list); only the status advances.
|
|
714
|
+
this.resumingFromAsyncTools = false;
|
|
715
|
+
stateUpdates.status = 'in_progress';
|
|
716
|
+
}
|
|
493
717
|
await this.saveStateSafely(stateUpdates);
|
|
494
718
|
}
|
|
495
719
|
/**
|
|
@@ -497,6 +721,9 @@ export class ModelResult {
|
|
|
497
721
|
* Sets the conversation status to 'complete' indicating no further tool execution is needed.
|
|
498
722
|
*/
|
|
499
723
|
async markStateComplete() {
|
|
724
|
+
// A later conversational turn is a new logical run and may force its
|
|
725
|
+
// first tool call again.
|
|
726
|
+
this.resetForcedToolChoicePolicy();
|
|
500
727
|
await this.saveStateSafely({
|
|
501
728
|
status: 'complete',
|
|
502
729
|
});
|
|
@@ -533,6 +760,7 @@ export class ModelResult {
|
|
|
533
760
|
// Save partial state
|
|
534
761
|
if (this.currentState) {
|
|
535
762
|
const currentToolCalls = extractToolCallsFromResponse(currentResponse);
|
|
763
|
+
this.resetForcedToolChoicePolicy();
|
|
536
764
|
await this.saveStateSafely({
|
|
537
765
|
status: 'interrupted',
|
|
538
766
|
partialResponse: {
|
|
@@ -659,6 +887,10 @@ export class ModelResult {
|
|
|
659
887
|
*/
|
|
660
888
|
hasExecutableToolCalls(toolCalls) {
|
|
661
889
|
return toolCalls.some((toolCall) => {
|
|
890
|
+
// The universal task tool is engine-intercepted — always executable.
|
|
891
|
+
if (toolCall.name === TASK_TOOL_NAME && this.taskToolActive()) {
|
|
892
|
+
return true;
|
|
893
|
+
}
|
|
662
894
|
const tool = this.options.tools?.find((t) => isClientTool(t) && t.function.name === toolCall.name);
|
|
663
895
|
return tool && isAutoResolvableTool(tool);
|
|
664
896
|
});
|
|
@@ -692,7 +924,7 @@ export class ModelResult {
|
|
|
692
924
|
* - `execution`: The tool ran. `result` is the ToolExecutionResult.
|
|
693
925
|
* `effectiveToolCall` reflects any `mutatedInput` piped by PreToolUse.
|
|
694
926
|
*/
|
|
695
|
-
async runToolWithHooks(tool, toolCall, turnContext, onPreliminaryResult) {
|
|
927
|
+
async runToolWithHooks(tool, toolCall, turnContext, onPreliminaryResult, extras) {
|
|
696
928
|
// Reject raw-string arguments before any hook fires. When the model
|
|
697
929
|
// produces invalid JSON, the parser leaves `toolCall.arguments` as the
|
|
698
930
|
// raw string; handing that to PreToolUse would either fail payload
|
|
@@ -702,6 +934,23 @@ export class ModelResult {
|
|
|
702
934
|
// synthetic error without running the tool or firing hooks.
|
|
703
935
|
const rawArgs = toolCall.arguments;
|
|
704
936
|
if (typeof rawArgs === 'string') {
|
|
937
|
+
// Malformed calls are classic doom-loop fuel: a model stuck emitting
|
|
938
|
+
// the same invalid JSON re-triggers this parse error forever. Record
|
|
939
|
+
// the raw string as the call's identity so the streak trips the
|
|
940
|
+
// detector instead of bouncing off the parse error unbounded. The
|
|
941
|
+
// parse-error output below already prevents execution, so 'block'
|
|
942
|
+
// needs no extra handling; 'steer'/'stop' side effects are applied
|
|
943
|
+
// inside the ordered evaluation.
|
|
944
|
+
if (this.doomLoopMonitor) {
|
|
945
|
+
await this.enqueueDoomLoopEvaluation({
|
|
946
|
+
toolName: String(toolCall.name),
|
|
947
|
+
keyMaterial: rawArgs,
|
|
948
|
+
fallbackKeyMaterial: null,
|
|
949
|
+
allowBlock: true,
|
|
950
|
+
detector: 'tool-fingerprint',
|
|
951
|
+
toolCall,
|
|
952
|
+
});
|
|
953
|
+
}
|
|
705
954
|
const errorMessage = `Failed to parse tool call arguments for "${toolCall.name}": The model provided invalid JSON. ` +
|
|
706
955
|
`Raw arguments received: "${rawArgs}". ` +
|
|
707
956
|
'Please provide valid JSON arguments for this tool call.';
|
|
@@ -719,6 +968,29 @@ export class ModelResult {
|
|
|
719
968
|
},
|
|
720
969
|
};
|
|
721
970
|
}
|
|
971
|
+
// Doom-loop checkpoint — BEFORE PreToolUse, on the arguments the MODEL
|
|
972
|
+
// issued (pre-mutation): the loop evidence is the model repeating
|
|
973
|
+
// itself, and a PreToolUse hook that rewrites input each call (e.g.
|
|
974
|
+
// injecting a nonce) must not mask that repetition. A 'block' verdict
|
|
975
|
+
// returns the same `hook_blocked` shape as a PreToolUse block, so every
|
|
976
|
+
// caller handles it identically; PreToolUse deliberately does not fire
|
|
977
|
+
// for doom-blocked calls (nothing will execute — mirrors parse_error).
|
|
978
|
+
const doomOutcome = await this.checkDoomLoopBeforeExecution(tool, toolCall);
|
|
979
|
+
if (doomOutcome.blocked) {
|
|
980
|
+
return {
|
|
981
|
+
type: 'hook_blocked',
|
|
982
|
+
toolCall,
|
|
983
|
+
reason: doomOutcome.reason,
|
|
984
|
+
output: {
|
|
985
|
+
type: 'function_call_output',
|
|
986
|
+
id: `output_${toolCall.id}`,
|
|
987
|
+
callId: toolCall.id,
|
|
988
|
+
output: JSON.stringify({
|
|
989
|
+
error: doomOutcome.reason,
|
|
990
|
+
}),
|
|
991
|
+
},
|
|
992
|
+
};
|
|
993
|
+
}
|
|
722
994
|
let effectiveToolCall = toolCall;
|
|
723
995
|
// Emit PreToolUse hook -- can block or mutate input.
|
|
724
996
|
if (this.hooksManager) {
|
|
@@ -764,13 +1036,18 @@ export class ModelResult {
|
|
|
764
1036
|
// performance.now() gives monotonic, sub-ms precision and is immune to
|
|
765
1037
|
// system clock jumps, unlike Date.now().
|
|
766
1038
|
const startTime = performance.now();
|
|
767
|
-
const result = await executeTool(tool, effectiveToolCall, turnContext, onPreliminaryResult, this.contextStore ?? undefined, this.options.sharedContextSchema);
|
|
1039
|
+
const result = await executeTool(tool, effectiveToolCall, turnContext, onPreliminaryResult, this.contextStore ?? undefined, this.options.sharedContextSchema, extras);
|
|
768
1040
|
const durationMs = performance.now() - startTime;
|
|
769
1041
|
// HITL tools may pause (executeTool returns null). No output was produced
|
|
770
1042
|
// yet, so neither PostToolUse nor PostToolUseFailure fires; they will fire
|
|
771
1043
|
// if/when the tool is resumed and actually executes.
|
|
1044
|
+
// Async invocations (background/deferred) haven't produced their final
|
|
1045
|
+
// output either — Post hooks fire at settlement via
|
|
1046
|
+
// emitAsyncSettledHooks (in-process delivery). Results delivered by
|
|
1047
|
+
// resumeToolResults in ANOTHER process have no hooksManager there;
|
|
1048
|
+
// that path stays uncovered and is documented on resumeToolResults.
|
|
772
1049
|
// Emit PostToolUse or PostToolUseFailure.
|
|
773
|
-
if (this.hooksManager && result !== null) {
|
|
1050
|
+
if (this.hooksManager && result !== null && !isAsyncToolInvocation(result)) {
|
|
774
1051
|
if (result.error) {
|
|
775
1052
|
await this.hooksManager.emit('PostToolUseFailure', {
|
|
776
1053
|
toolName: effectiveToolCall.name,
|
|
@@ -793,6 +1070,557 @@ export class ModelResult {
|
|
|
793
1070
|
result,
|
|
794
1071
|
};
|
|
795
1072
|
}
|
|
1073
|
+
/**
|
|
1074
|
+
* Advance the doom-loop round counter and declare the round's calls. Called
|
|
1075
|
+
* at every execution-batch boundary (main tool round, auto-approve batch
|
|
1076
|
+
* while pausing, approved-on-resume batch). Identical calls WITHIN one round
|
|
1077
|
+
* are duplicates — one piece of loop evidence, one shared decision.
|
|
1078
|
+
*
|
|
1079
|
+
* `batch` is every call the round will make. A round's identity for one tool
|
|
1080
|
+
* is the *set* of fingerprints it was called with, and that set has to be
|
|
1081
|
+
* complete before any of the round's calls is scored: accumulating it call
|
|
1082
|
+
* by call made a round that is a superset of the previous one transiently
|
|
1083
|
+
* match it, blocking calls that represented real progress, with the outcome
|
|
1084
|
+
* depending on emission order. Declaration is best-effort — a call whose key
|
|
1085
|
+
* material is exempt or unhashable is simply left out, and the per-call
|
|
1086
|
+
* fallback chain in `enqueueDoomLoopEvaluation` still governs identity at
|
|
1087
|
+
* record time.
|
|
1088
|
+
*
|
|
1089
|
+
* Only calls that the round will actually CHECK are declared. A `loopKey`
|
|
1090
|
+
* function is user code that may count or log, so it must not run for a call
|
|
1091
|
+
* the detector never evaluates: manual tool calls (no `execute`, no
|
|
1092
|
+
* `onToolCalled`) are handed to the caller and never recorded, and every
|
|
1093
|
+
* execution path skips them with the same `isAutoResolvableTool` predicate
|
|
1094
|
+
* used here. Their absence from the declared set is also correct on its own
|
|
1095
|
+
* terms — they are not evidence, so they are not part of the round.
|
|
1096
|
+
*
|
|
1097
|
+
* INVARIANT — every declared member must eventually be recorded. A call
|
|
1098
|
+
* that is declared but never reaches the doom-loop checkpoint is a phantom
|
|
1099
|
+
* member of the round's identity: the tool's streak silently resets the
|
|
1100
|
+
* moment the phantom stops being emitted (pinned by the "declared-but-
|
|
1101
|
+
* never-recorded member" test in doom-loop-fanout.test.ts). The filters
|
|
1102
|
+
* below therefore MIRROR every path that skips recording — unknown/manual
|
|
1103
|
+
* tools (`isAutoResolvableTool`, also gating executeAutoApproveTools and
|
|
1104
|
+
* the approval-resume loop), `hookDeniedCalls` (consumed before the
|
|
1105
|
+
* checkpoint in executeSingleToolCall), and `loopKey`-exempt calls (early
|
|
1106
|
+
* return in checkDoomLoopBeforeExecution; the same resolution is cached
|
|
1107
|
+
* here so both sides agree). If you add a new short-circuit between
|
|
1108
|
+
* declaration and the checkpoint — a pre-execution gate, a batch filter —
|
|
1109
|
+
* it MUST be reflected here, or fan-out detection degrades silently for
|
|
1110
|
+
* that tool rather than failing loudly.
|
|
1111
|
+
*
|
|
1112
|
+
* KNOWN RESIDUAL (documented, not mirrored): a per-request timeout or
|
|
1113
|
+
* abort can cancel a call AFTER declaration but BEFORE its checkpoint,
|
|
1114
|
+
* leaving a declared phantom for that round. Bounded and fail-safe: the
|
|
1115
|
+
* round-set streak for that tool resets when the phantom stops recurring,
|
|
1116
|
+
* while per-call streaks are unaffected — detection degrades to per-call
|
|
1117
|
+
* for the affected tool rather than being lost. Un-declaring mid-round
|
|
1118
|
+
* would mutate the round's identity while it is being scored, which is the
|
|
1119
|
+
* incremental-set order-dependence this design exists to prevent.
|
|
1120
|
+
*/
|
|
1121
|
+
async beginDoomLoopRound(batch = []) {
|
|
1122
|
+
const monitor = this.doomLoopMonitor;
|
|
1123
|
+
if (!monitor) {
|
|
1124
|
+
return;
|
|
1125
|
+
}
|
|
1126
|
+
this.doomLoopRound++;
|
|
1127
|
+
this.doomLoopRoundDecisions.clear();
|
|
1128
|
+
this.doomLoopRoundKeyMaterial.clear();
|
|
1129
|
+
const declared = [];
|
|
1130
|
+
for (const toolCall of batch) {
|
|
1131
|
+
const tool = this.options.tools?.find((t) => isClientTool(t) && t.function.name === toolCall.name);
|
|
1132
|
+
/*
|
|
1133
|
+
* Never checked => never recorded => must not be declared. Declaring a
|
|
1134
|
+
* call that never arrives inflates the round's identity: a sibling that
|
|
1135
|
+
* IS recorded gets scored against a set containing a phantom member, and
|
|
1136
|
+
* the streak resets spuriously once the phantom stops appearing (e.g. the
|
|
1137
|
+
* model drops a malformed call while still repeating the valid one).
|
|
1138
|
+
* Keeping `loopKey` from running for such a call is the same check.
|
|
1139
|
+
*
|
|
1140
|
+
* This gate deliberately precedes the malformed-arguments branch below:
|
|
1141
|
+
* a raw-string call to an unknown or manual tool is still never recorded.
|
|
1142
|
+
*/
|
|
1143
|
+
if (tool === undefined || !isAutoResolvableTool(tool)) {
|
|
1144
|
+
continue;
|
|
1145
|
+
}
|
|
1146
|
+
/*
|
|
1147
|
+
* Same reasoning for a call the PermissionRequest hook denied without
|
|
1148
|
+
* pausing: `hookDeniedCalls` is populated before the round begins, and
|
|
1149
|
+
* `runToolWithHooks` synthesizes the rejection before reaching the
|
|
1150
|
+
* doom-loop checkpoint, so the call is never recorded either.
|
|
1151
|
+
*/
|
|
1152
|
+
if (this.hookDeniedCalls.has(toolCall.id)) {
|
|
1153
|
+
continue;
|
|
1154
|
+
}
|
|
1155
|
+
const rawArgs = toolCall.arguments;
|
|
1156
|
+
if (typeof rawArgs === 'string') {
|
|
1157
|
+
// Malformed call: its identity is the raw string (see runToolWithHooks).
|
|
1158
|
+
declared.push({
|
|
1159
|
+
toolName: String(toolCall.name),
|
|
1160
|
+
keyMaterial: rawArgs,
|
|
1161
|
+
});
|
|
1162
|
+
continue;
|
|
1163
|
+
}
|
|
1164
|
+
/*
|
|
1165
|
+
* `resolveLoopKeyMaterial` catches a throwing `loopKey`, but not every
|
|
1166
|
+
* throw: the field-list form reads `args[field]`, so a getter on the
|
|
1167
|
+
* arguments object throws out of it uncaught. Declaration runs once for
|
|
1168
|
+
* the whole batch, so letting that escape would fail the entire round —
|
|
1169
|
+
* and the run — over one odd call, breaking the invariant that detection
|
|
1170
|
+
* never affects a run except through its ladder actions. Skip just that
|
|
1171
|
+
* call; the per-call checkpoint hits the same throw inside its own
|
|
1172
|
+
* try/catch and applies the documented fallback chain there.
|
|
1173
|
+
*/
|
|
1174
|
+
let resolution;
|
|
1175
|
+
try {
|
|
1176
|
+
resolution = resolveLoopKeyMaterial(isClientTool(tool) ? tool.function.loopKey : undefined, (toolCall.arguments ?? {}));
|
|
1177
|
+
}
|
|
1178
|
+
catch (error) {
|
|
1179
|
+
console.warn(`[DoomLoop] could not resolve loop identity for "${toolCall.name}" while declaring the round; ` +
|
|
1180
|
+
'excluding it from the round set:', error);
|
|
1181
|
+
continue;
|
|
1182
|
+
}
|
|
1183
|
+
/*
|
|
1184
|
+
* Cache so the per-call checkpoint does not invoke `loopKey` a second
|
|
1185
|
+
* time; keyed by call id. Ids are MODEL-emitted strings and nothing
|
|
1186
|
+
* upstream enforces uniqueness, so a duplicate id must not alias one
|
|
1187
|
+
* call's identity onto another: overwriting here meant the first call's
|
|
1188
|
+
* checkpoint read the second call's key material, its true fingerprint
|
|
1189
|
+
* was never recorded, and a model emitting `(id=X, read a), (id=X,
|
|
1190
|
+
* read b_i)` each round evaded the per-call detector for `a` entirely
|
|
1191
|
+
* (measured: zero detections across four such rounds). On a duplicate
|
|
1192
|
+
* id the cache poisons that id instead: both calls fall through to
|
|
1193
|
+
* per-call resolution at the checkpoint, costing at most a duplicate
|
|
1194
|
+
* `loopKey` invocation for the colliding calls — correctness over the
|
|
1195
|
+
* single-invocation economy, for protocol-malformed input only.
|
|
1196
|
+
*/
|
|
1197
|
+
if (this.doomLoopRoundKeyMaterial.has(toolCall.id)) {
|
|
1198
|
+
this.doomLoopRoundKeyMaterial.set(toolCall.id, DUPLICATE_CALL_ID);
|
|
1199
|
+
}
|
|
1200
|
+
else {
|
|
1201
|
+
this.doomLoopRoundKeyMaterial.set(toolCall.id, resolution);
|
|
1202
|
+
}
|
|
1203
|
+
if (resolution.kind === 'exempt') {
|
|
1204
|
+
continue;
|
|
1205
|
+
}
|
|
1206
|
+
declared.push({
|
|
1207
|
+
toolName: String(toolCall.name),
|
|
1208
|
+
keyMaterial: resolution.keyMaterial,
|
|
1209
|
+
});
|
|
1210
|
+
}
|
|
1211
|
+
await monitor.declareRound(this.doomLoopRound, declared);
|
|
1212
|
+
}
|
|
1213
|
+
/**
|
|
1214
|
+
* Evaluate one call against the doom-loop monitor, serialized on
|
|
1215
|
+
* `doomLoopChain` so parallel executions record in the deterministic
|
|
1216
|
+
* order their evaluations were ENQUEUED (the `.map()` over a round's
|
|
1217
|
+
* calls runs synchronously to its first await, i.e. model-emission
|
|
1218
|
+
* order) — not in hash-completion order.
|
|
1219
|
+
*
|
|
1220
|
+
* Applies the fallback invariant: detection must never take down or
|
|
1221
|
+
* alter a run except via its defined actions. Unhashable key material
|
|
1222
|
+
* (bigint, non-finite numbers, circular/too-deep structures from a
|
|
1223
|
+
* computed loopKey) degrades to `fallbackKeyMaterial`; if THAT is also
|
|
1224
|
+
* unhashable (parse-error path passes null), detection is skipped for
|
|
1225
|
+
* the call and it proceeds normally.
|
|
1226
|
+
*
|
|
1227
|
+
* Returns the final decision for this call (`action: 'proceed'` when no
|
|
1228
|
+
* rung fired or the call is exempt/skipped; `message` carries the
|
|
1229
|
+
* verdict's explanation for block outputs). Side effects (steer queue,
|
|
1230
|
+
* stop arm) are applied here so every caller shares them.
|
|
1231
|
+
*/
|
|
1232
|
+
enqueueDoomLoopEvaluation(evaluation) {
|
|
1233
|
+
const run = async () => {
|
|
1234
|
+
const monitor = this.doomLoopMonitor;
|
|
1235
|
+
if (!monitor) {
|
|
1236
|
+
return {
|
|
1237
|
+
action: 'proceed',
|
|
1238
|
+
};
|
|
1239
|
+
}
|
|
1240
|
+
let record;
|
|
1241
|
+
try {
|
|
1242
|
+
record = await monitor.recordToolCall(evaluation.toolName, evaluation.keyMaterial, this.doomLoopRound, {
|
|
1243
|
+
allowBlock: evaluation.allowBlock,
|
|
1244
|
+
detector: evaluation.detector,
|
|
1245
|
+
});
|
|
1246
|
+
}
|
|
1247
|
+
catch (error) {
|
|
1248
|
+
// Unhashable key material (bigint/NaN/circular/too-deep loopKey
|
|
1249
|
+
// output). Fall back to the raw-arguments identity; if that also
|
|
1250
|
+
// fails, skip detection for this call — never fail the call itself.
|
|
1251
|
+
console.warn(`[DoomLoop] could not fingerprint call to "${evaluation.toolName}"; ` +
|
|
1252
|
+
`${evaluation.fallbackKeyMaterial !== null ? 'falling back to full arguments' : 'skipping detection for this call'}:`, error);
|
|
1253
|
+
if (evaluation.fallbackKeyMaterial === null) {
|
|
1254
|
+
return {
|
|
1255
|
+
action: 'proceed',
|
|
1256
|
+
};
|
|
1257
|
+
}
|
|
1258
|
+
try {
|
|
1259
|
+
record = await monitor.recordToolCall(evaluation.toolName, evaluation.fallbackKeyMaterial, this.doomLoopRound, {
|
|
1260
|
+
allowBlock: evaluation.allowBlock,
|
|
1261
|
+
detector: evaluation.detector,
|
|
1262
|
+
});
|
|
1263
|
+
}
|
|
1264
|
+
catch (fallbackError) {
|
|
1265
|
+
console.warn(`[DoomLoop] fallback identity for "${evaluation.toolName}" also unhashable; skipping detection for this call:`, fallbackError);
|
|
1266
|
+
return {
|
|
1267
|
+
action: 'proceed',
|
|
1268
|
+
};
|
|
1269
|
+
}
|
|
1270
|
+
}
|
|
1271
|
+
// Same (tool, fingerprint) already decided this round: reuse the
|
|
1272
|
+
// decision — no second streak increment, no second hook emission.
|
|
1273
|
+
const decisionKey = `${evaluation.toolName}\n${record.fingerprint}`;
|
|
1274
|
+
if (record.duplicateInRound) {
|
|
1275
|
+
return (this.doomLoopRoundDecisions.get(decisionKey) ?? {
|
|
1276
|
+
action: 'proceed',
|
|
1277
|
+
});
|
|
1278
|
+
}
|
|
1279
|
+
if (!record.verdict) {
|
|
1280
|
+
const decision = {
|
|
1281
|
+
action: 'proceed',
|
|
1282
|
+
};
|
|
1283
|
+
this.doomLoopRoundDecisions.set(decisionKey, decision);
|
|
1284
|
+
return decision;
|
|
1285
|
+
}
|
|
1286
|
+
const action = await this.applyDoomLoopVerdict(record.verdict, evaluation.toolCall);
|
|
1287
|
+
const decision = {
|
|
1288
|
+
action,
|
|
1289
|
+
message: record.verdict.message,
|
|
1290
|
+
};
|
|
1291
|
+
this.doomLoopRoundDecisions.set(decisionKey, decision);
|
|
1292
|
+
this.applyDoomLoopSideEffects(action, record.verdict);
|
|
1293
|
+
return decision;
|
|
1294
|
+
};
|
|
1295
|
+
// Serialize on the chain; keep the chain alive even if an evaluation
|
|
1296
|
+
// throws unexpectedly (it should not — run() catches everything).
|
|
1297
|
+
const evaluationPromise = this.doomLoopChain.then(run, run);
|
|
1298
|
+
this.doomLoopChain = evaluationPromise.catch(() => undefined);
|
|
1299
|
+
return evaluationPromise;
|
|
1300
|
+
}
|
|
1301
|
+
/**
|
|
1302
|
+
* Doom-loop checkpoint for one tool call, run before PreToolUse/execution.
|
|
1303
|
+
*
|
|
1304
|
+
* Resolves the tool's `loopKey` declaration (function |
|
|
1305
|
+
* false | absent — see {@link resolveLoopKeyMaterial}) and evaluates the
|
|
1306
|
+
* call against the monitor. Ladder actions:
|
|
1307
|
+
*
|
|
1308
|
+
* - `observe` — hook already emitted, nothing else.
|
|
1309
|
+
* - `steer` — queue a corrective user message for the next model turn.
|
|
1310
|
+
* - `block` — return `blocked` so the caller synthesizes an error output
|
|
1311
|
+
* without executing. The model sees the explanation in the tool result.
|
|
1312
|
+
* - `stop` — arm `doomLoopStop`; ALSO blocks this call (executing a
|
|
1313
|
+
* call the detector just condemned, then stopping, would be
|
|
1314
|
+
* incoherent). The loop halts before the next model request.
|
|
1315
|
+
*/
|
|
1316
|
+
async checkDoomLoopBeforeExecution(tool, toolCall) {
|
|
1317
|
+
if (!this.doomLoopMonitor) {
|
|
1318
|
+
return {
|
|
1319
|
+
blocked: false,
|
|
1320
|
+
};
|
|
1321
|
+
}
|
|
1322
|
+
// The parse-error guard in runToolWithHooks already rejected raw-string
|
|
1323
|
+
// arguments, so what remains is the parsed record (or null/undefined for
|
|
1324
|
+
// no-args calls — coerced to {} the same way the PreToolUse payload is).
|
|
1325
|
+
const callArguments = (toolCall.arguments ?? {});
|
|
1326
|
+
/*
|
|
1327
|
+
* Reuse what `beginDoomLoopRound` resolved for this call when it declared
|
|
1328
|
+
* the round. `loopKey` is user code — it may count, log, or return a fresh
|
|
1329
|
+
* value each time — so it must run at most once per call. Resolving here
|
|
1330
|
+
* as well would double-invoke it and, for a non-repeatable callback, make
|
|
1331
|
+
* the declared identity and the recorded identity disagree.
|
|
1332
|
+
*/
|
|
1333
|
+
const cached = this.doomLoopRoundKeyMaterial.get(toolCall.id);
|
|
1334
|
+
let resolution;
|
|
1335
|
+
if (cached !== undefined && cached !== DUPLICATE_CALL_ID) {
|
|
1336
|
+
resolution = cached;
|
|
1337
|
+
}
|
|
1338
|
+
else {
|
|
1339
|
+
/*
|
|
1340
|
+
* Not declared (undeclared round, or the declaration skipped this call).
|
|
1341
|
+
* `resolveLoopKeyMaterial` can throw despite catching `loopKey` itself —
|
|
1342
|
+
* the field-list form reads `args[field]`, so a getter on the arguments
|
|
1343
|
+
* throws out of it. Detection must never reject a call or fail a run
|
|
1344
|
+
* except through a ladder action, so skip detection for this one call.
|
|
1345
|
+
*/
|
|
1346
|
+
try {
|
|
1347
|
+
resolution = resolveLoopKeyMaterial(isClientTool(tool) ? tool.function.loopKey : undefined, callArguments);
|
|
1348
|
+
}
|
|
1349
|
+
catch (error) {
|
|
1350
|
+
console.warn(`[DoomLoop] could not resolve loop identity for "${toolCall.name}"; ` +
|
|
1351
|
+
'skipping detection for this call:', error);
|
|
1352
|
+
return {
|
|
1353
|
+
blocked: false,
|
|
1354
|
+
};
|
|
1355
|
+
}
|
|
1356
|
+
}
|
|
1357
|
+
if (resolution.kind === 'exempt') {
|
|
1358
|
+
return {
|
|
1359
|
+
blocked: false,
|
|
1360
|
+
};
|
|
1361
|
+
}
|
|
1362
|
+
if (resolution.kind === 'fallback') {
|
|
1363
|
+
console.warn(`[DoomLoop] tool "${toolCall.name}": ${resolution.warning}`);
|
|
1364
|
+
}
|
|
1365
|
+
const decision = await this.enqueueDoomLoopEvaluation({
|
|
1366
|
+
toolName: String(toolCall.name),
|
|
1367
|
+
keyMaterial: resolution.keyMaterial,
|
|
1368
|
+
fallbackKeyMaterial: resolution.keyMaterial === callArguments ? null : callArguments,
|
|
1369
|
+
allowBlock: true,
|
|
1370
|
+
detector: 'tool-fingerprint',
|
|
1371
|
+
toolCall,
|
|
1372
|
+
});
|
|
1373
|
+
if (decision.action === 'block' || decision.action === 'stop') {
|
|
1374
|
+
return {
|
|
1375
|
+
blocked: true,
|
|
1376
|
+
reason: decision.message ??
|
|
1377
|
+
`Doom loop suspected: tool "${toolCall.name}" is repeating identical calls. ` +
|
|
1378
|
+
'Repeating the call will not change the result. Take a different approach.',
|
|
1379
|
+
};
|
|
1380
|
+
}
|
|
1381
|
+
return {
|
|
1382
|
+
blocked: false,
|
|
1383
|
+
};
|
|
1384
|
+
}
|
|
1385
|
+
/**
|
|
1386
|
+
* Emit the DoomLoopDetected hook for a verdict and resolve the final
|
|
1387
|
+
* action. Handlers may override per event (`overrideAction`), letting
|
|
1388
|
+
* userland escalate or de-escalate without reconfiguring the ladder;
|
|
1389
|
+
* the LAST override in the chain wins (matching PermissionRequest's
|
|
1390
|
+
* last-wins convention). Non-blockable verdicts (text, server tools —
|
|
1391
|
+
* the tokens are already emitted / the tool already ran) downgrade a
|
|
1392
|
+
* 'block' override to 'observe'. An 'escalate' override is honored only
|
|
1393
|
+
* when an escalation mechanism is configured with remaining budget;
|
|
1394
|
+
* otherwise it downgrades to 'observe' (never silently to a stronger
|
|
1395
|
+
* action).
|
|
1396
|
+
*/
|
|
1397
|
+
async applyDoomLoopVerdict(verdict, toolCall) {
|
|
1398
|
+
let action = verdict.action;
|
|
1399
|
+
if (this.hooksManager) {
|
|
1400
|
+
const emit = await this.hooksManager.emit('DoomLoopDetected', {
|
|
1401
|
+
detector: verdict.detector,
|
|
1402
|
+
action: verdict.action,
|
|
1403
|
+
streak: verdict.streak,
|
|
1404
|
+
fingerprint: verdict.fingerprint,
|
|
1405
|
+
...(verdict.toolName !== undefined && {
|
|
1406
|
+
toolName: verdict.toolName,
|
|
1407
|
+
}),
|
|
1408
|
+
...(toolCall !== undefined &&
|
|
1409
|
+
isRecord(toolCall.arguments) && {
|
|
1410
|
+
toolInput: toolCall.arguments,
|
|
1411
|
+
}),
|
|
1412
|
+
message: verdict.message,
|
|
1413
|
+
}, this.hookEmitContext(verdict.toolName));
|
|
1414
|
+
for (const result of emit.results) {
|
|
1415
|
+
if (result?.overrideAction) {
|
|
1416
|
+
action = result.overrideAction;
|
|
1417
|
+
}
|
|
1418
|
+
}
|
|
1419
|
+
}
|
|
1420
|
+
if (verdict.detector !== 'tool-fingerprint' && action === 'block') {
|
|
1421
|
+
// Nothing to block for text/server-tool verdicts; observe instead of
|
|
1422
|
+
// silently acting stronger/weaker than the handler intended.
|
|
1423
|
+
action = 'observe';
|
|
1424
|
+
}
|
|
1425
|
+
if (action === 'escalate' && !(this.doomLoopMonitor?.canEscalate() ?? false)) {
|
|
1426
|
+
// Ladder verdicts are already budget-gated by the monitor; this
|
|
1427
|
+
// catches hook overrides requesting escalation without config/budget.
|
|
1428
|
+
action = 'observe';
|
|
1429
|
+
}
|
|
1430
|
+
return action;
|
|
1431
|
+
}
|
|
1432
|
+
/**
|
|
1433
|
+
* Apply the side effects of a resolved doom-loop action shared by every
|
|
1434
|
+
* checkpoint: queue steer guidance, arm an escalation for the next
|
|
1435
|
+
* request, or arm the stop verdict. 'block' has no shared side effect —
|
|
1436
|
+
* the calling checkpoint synthesizes the blocked output itself.
|
|
1437
|
+
*/
|
|
1438
|
+
applyDoomLoopSideEffects(action, verdict) {
|
|
1439
|
+
if (action === 'steer') {
|
|
1440
|
+
this.queueDoomLoopSteer(verdict.message);
|
|
1441
|
+
}
|
|
1442
|
+
else if (action === 'escalate') {
|
|
1443
|
+
// Latch the FIRST escalation verdict for the next request; a second
|
|
1444
|
+
// verdict in the same window (e.g. tool + text detectors both firing)
|
|
1445
|
+
// must not double-spend the budget.
|
|
1446
|
+
if (this.pendingDoomLoopEscalation === null) {
|
|
1447
|
+
this.pendingDoomLoopEscalation = verdict;
|
|
1448
|
+
// The model should know why its next turn looks different. Rides
|
|
1449
|
+
// the persisted steer path so the transcript stays well-formed.
|
|
1450
|
+
this.queueDoomLoopSteer(`${verdict.message} An escalated turn follows: use the additional guidance to change course.`);
|
|
1451
|
+
}
|
|
1452
|
+
}
|
|
1453
|
+
else if (action === 'stop') {
|
|
1454
|
+
this.doomLoopStop = verdict;
|
|
1455
|
+
}
|
|
1456
|
+
}
|
|
1457
|
+
/**
|
|
1458
|
+
* Consume a pending escalation into ONE-TURN request overrides:
|
|
1459
|
+
*
|
|
1460
|
+
* - `model` — replace the request's model for this dispatch only (the
|
|
1461
|
+
* base `resolvedRequest` is never mutated, so the following turn
|
|
1462
|
+
* reverts automatically).
|
|
1463
|
+
* - `advisor` — append an `openrouter:advisor` server tool (transcript
|
|
1464
|
+
* forwarded, instructions describing the detected loop) and pin
|
|
1465
|
+
* `toolChoice` to it via `allowed_tools`/`required`, so the stuck model
|
|
1466
|
+
* must consult the advisor before doing anything else this turn.
|
|
1467
|
+
*
|
|
1468
|
+
* Burns one unit of the escalation budget (persisted via the monitor) at
|
|
1469
|
+
* APPLICATION time — verdicts the engine never got to apply (run stopped,
|
|
1470
|
+
* paused, or overridden) do not spend.
|
|
1471
|
+
*/
|
|
1472
|
+
takeDoomLoopEscalationOverrides() {
|
|
1473
|
+
const verdict = this.pendingDoomLoopEscalation;
|
|
1474
|
+
const config = this.doomLoopEscalation;
|
|
1475
|
+
if (!verdict || !config || !this.doomLoopMonitor?.canEscalate()) {
|
|
1476
|
+
this.pendingDoomLoopEscalation = null;
|
|
1477
|
+
return null;
|
|
1478
|
+
}
|
|
1479
|
+
this.pendingDoomLoopEscalation = null;
|
|
1480
|
+
this.doomLoopMonitor.consumeEscalation();
|
|
1481
|
+
const overrides = {};
|
|
1482
|
+
if (config.model !== undefined) {
|
|
1483
|
+
overrides['model'] = config.model;
|
|
1484
|
+
// A single-model override must not be shadowed by a fallback list.
|
|
1485
|
+
overrides['models'] = undefined;
|
|
1486
|
+
}
|
|
1487
|
+
if (config.advisor !== undefined && config.advisor !== false) {
|
|
1488
|
+
const advisorParameters = {
|
|
1489
|
+
forwardTranscript: true,
|
|
1490
|
+
instructions: 'You are an escalation advisor. The executing model appears stuck in a loop: ' +
|
|
1491
|
+
`${verdict.message} Diagnose why its approach is failing and give concrete, ` +
|
|
1492
|
+
'specific instructions for a DIFFERENT approach. Do not restate the problem.',
|
|
1493
|
+
...(typeof config.advisor === 'object' ? config.advisor : {}),
|
|
1494
|
+
};
|
|
1495
|
+
const existingTools = Array.isArray(this.resolvedRequest?.['tools'])
|
|
1496
|
+
? this.resolvedRequest['tools']
|
|
1497
|
+
: [];
|
|
1498
|
+
overrides['tools'] = [
|
|
1499
|
+
...existingTools,
|
|
1500
|
+
{
|
|
1501
|
+
type: 'openrouter:advisor',
|
|
1502
|
+
parameters: advisorParameters,
|
|
1503
|
+
},
|
|
1504
|
+
];
|
|
1505
|
+
// Force the consult: constrain this turn's tool surface to the
|
|
1506
|
+
// advisor and require a call.
|
|
1507
|
+
overrides['toolChoice'] = {
|
|
1508
|
+
type: 'allowed_tools',
|
|
1509
|
+
mode: 'required',
|
|
1510
|
+
tools: [
|
|
1511
|
+
{
|
|
1512
|
+
type: 'openrouter:advisor',
|
|
1513
|
+
},
|
|
1514
|
+
],
|
|
1515
|
+
};
|
|
1516
|
+
}
|
|
1517
|
+
return overrides;
|
|
1518
|
+
}
|
|
1519
|
+
/**
|
|
1520
|
+
* Step-level doom-loop checkpoint on a fresh model response:
|
|
1521
|
+
*
|
|
1522
|
+
* 1. Text detectors — within-response token repetition + cross-step
|
|
1523
|
+
* identical-text streak.
|
|
1524
|
+
* 2. Server-tool fingerprints — server tools (web_search_call etc.) never
|
|
1525
|
+
* pass through runToolWithHooks; their repetition is detected here,
|
|
1526
|
+
* post-execution, from the echoed call fields on the output item.
|
|
1527
|
+
* Post-execution ⇒ block is meaningless (allowBlock: false); the
|
|
1528
|
+
* actions are observe/steer/stop.
|
|
1529
|
+
*
|
|
1530
|
+
* Runs where the loop already inspects each fresh response, so scripted
|
|
1531
|
+
* transcripts exercise it deterministically.
|
|
1532
|
+
*/
|
|
1533
|
+
async checkDoomLoopForResponse(response) {
|
|
1534
|
+
if (!this.doomLoopMonitor) {
|
|
1535
|
+
return;
|
|
1536
|
+
}
|
|
1537
|
+
let verdict;
|
|
1538
|
+
try {
|
|
1539
|
+
verdict = await this.doomLoopMonitor.recordAssistantText(extractTextFromResponse(response));
|
|
1540
|
+
}
|
|
1541
|
+
catch (error) {
|
|
1542
|
+
console.warn('[DoomLoop] text checkpoint failed; skipping for this response:', error);
|
|
1543
|
+
verdict = undefined;
|
|
1544
|
+
}
|
|
1545
|
+
if (verdict) {
|
|
1546
|
+
const action = await this.applyDoomLoopVerdict(verdict);
|
|
1547
|
+
this.applyDoomLoopSideEffects(action, verdict);
|
|
1548
|
+
}
|
|
1549
|
+
// Server-tool repetition. The response's server-tool output items echo
|
|
1550
|
+
// their call identity (action/query fields, item type as the tool name).
|
|
1551
|
+
for (const item of response.output) {
|
|
1552
|
+
if (!hasTypeProperty(item) || !isServerToolResultItem(item)) {
|
|
1553
|
+
continue;
|
|
1554
|
+
}
|
|
1555
|
+
const identity = extractServerToolIdentity(item);
|
|
1556
|
+
if (identity === null) {
|
|
1557
|
+
continue;
|
|
1558
|
+
}
|
|
1559
|
+
await this.enqueueDoomLoopEvaluation({
|
|
1560
|
+
toolName: `server:${item.type}`,
|
|
1561
|
+
keyMaterial: identity,
|
|
1562
|
+
fallbackKeyMaterial: null,
|
|
1563
|
+
allowBlock: false,
|
|
1564
|
+
detector: 'server-tool-fingerprint',
|
|
1565
|
+
});
|
|
1566
|
+
}
|
|
1567
|
+
}
|
|
1568
|
+
/**
|
|
1569
|
+
* Queue steer guidance, deduplicating identical messages (one verdict can
|
|
1570
|
+
* repeat across calls in a round; the model needs the guidance once).
|
|
1571
|
+
*/
|
|
1572
|
+
queueDoomLoopSteer(message) {
|
|
1573
|
+
if (!this.pendingDoomLoopSteer.includes(message)) {
|
|
1574
|
+
this.pendingDoomLoopSteer.push(message);
|
|
1575
|
+
}
|
|
1576
|
+
}
|
|
1577
|
+
/**
|
|
1578
|
+
* Inject any queued doom-loop steer messages as a user message before the
|
|
1579
|
+
* next model turn. Reuses the Stop-hook appendPrompt injection path, so
|
|
1580
|
+
* state/messages advance observably. Called in-loop before each follow-up
|
|
1581
|
+
* request AND before every pause-persist, so guidance queued right before
|
|
1582
|
+
* a pause lands in the persisted conversation instead of being dropped.
|
|
1583
|
+
*/
|
|
1584
|
+
async flushDoomLoopSteer() {
|
|
1585
|
+
if (this.pendingDoomLoopSteer.length === 0) {
|
|
1586
|
+
return;
|
|
1587
|
+
}
|
|
1588
|
+
const prompt = this.pendingDoomLoopSteer.join('\n');
|
|
1589
|
+
this.pendingDoomLoopSteer = [];
|
|
1590
|
+
await this.injectAppendPromptMessage(prompt);
|
|
1591
|
+
}
|
|
1592
|
+
/**
|
|
1593
|
+
* Halt the run for an armed doom-loop stop verdict, keeping the persisted
|
|
1594
|
+
* history well-formed: every executable `function_call` in the current
|
|
1595
|
+
* response that has no output yet gets a synthesized halt-error output
|
|
1596
|
+
* (persisted via the normal tool-result path), so a stateful resume never
|
|
1597
|
+
* sends a dangling `function_call` (providers reject those with a 400).
|
|
1598
|
+
*
|
|
1599
|
+
* Used by every stop site: the pre-round break, the post-round break, the
|
|
1600
|
+
* no-tools early return, the allow-final-response gate, and the approval-
|
|
1601
|
+
* resume gate.
|
|
1602
|
+
*
|
|
1603
|
+
* @param resolvedCallIds - call ids that already have outputs this round.
|
|
1604
|
+
*/
|
|
1605
|
+
async sealDoomLoopStop(currentResponse, resolvedCallIds) {
|
|
1606
|
+
const verdict = this.doomLoopStop;
|
|
1607
|
+
const toolCalls = extractToolCallsFromResponse(currentResponse);
|
|
1608
|
+
const unresolved = toolCalls.filter((tc) => !resolvedCallIds?.has(tc.id));
|
|
1609
|
+
if (unresolved.length > 0) {
|
|
1610
|
+
const haltMessage = verdict
|
|
1611
|
+
? `Run halted by doom-loop detection: ${verdict.message}`
|
|
1612
|
+
: 'Run halted by doom-loop detection.';
|
|
1613
|
+
const haltOutputs = unresolved.map((tc) => ({
|
|
1614
|
+
type: 'function_call_output',
|
|
1615
|
+
id: `output_${tc.id}`,
|
|
1616
|
+
callId: tc.id,
|
|
1617
|
+
output: JSON.stringify({
|
|
1618
|
+
error: haltMessage,
|
|
1619
|
+
}),
|
|
1620
|
+
}));
|
|
1621
|
+
await this.saveToolResultsToState(haltOutputs);
|
|
1622
|
+
}
|
|
1623
|
+
}
|
|
796
1624
|
/**
|
|
797
1625
|
* Build the per-emit context for lifecycle hook emits. Threads this run's
|
|
798
1626
|
* session identity into `context.sessionId` on every emit, so a
|
|
@@ -809,6 +1637,40 @@ export class ModelResult {
|
|
|
809
1637
|
sessionId: this.currentState?.id ?? '',
|
|
810
1638
|
};
|
|
811
1639
|
}
|
|
1640
|
+
/**
|
|
1641
|
+
* Emit PostToolUse / PostToolUseFailure for an async task at settlement —
|
|
1642
|
+
* the point its final output actually exists. Round-synchronous tools
|
|
1643
|
+
* emit these in runToolWithHooks; async invocations skip that emission
|
|
1644
|
+
* (no output yet) and land here instead. `durationMs` is the task's
|
|
1645
|
+
* start-to-settle wall clock (includes background-pool queue wait, which
|
|
1646
|
+
* counts against the task's own timeout budget). Observation-only — a
|
|
1647
|
+
* throwing hook must not break delivery.
|
|
1648
|
+
*/
|
|
1649
|
+
async emitAsyncSettledHooks(task) {
|
|
1650
|
+
if (!this.hooksManager) {
|
|
1651
|
+
return;
|
|
1652
|
+
}
|
|
1653
|
+
try {
|
|
1654
|
+
if (task.status === 'completed') {
|
|
1655
|
+
await this.hooksManager.emit('PostToolUse', {
|
|
1656
|
+
toolName: task.name,
|
|
1657
|
+
toolInput: task.input ?? {},
|
|
1658
|
+
toolOutput: task.result,
|
|
1659
|
+
durationMs: task.durationMs,
|
|
1660
|
+
}, this.hookEmitContext(task.name));
|
|
1661
|
+
}
|
|
1662
|
+
else {
|
|
1663
|
+
await this.hooksManager.emit('PostToolUseFailure', {
|
|
1664
|
+
toolName: task.name,
|
|
1665
|
+
toolInput: task.input ?? {},
|
|
1666
|
+
error: new Error(task.error ?? `Task ${task.taskId} ${task.status}`),
|
|
1667
|
+
}, this.hookEmitContext(task.name));
|
|
1668
|
+
}
|
|
1669
|
+
}
|
|
1670
|
+
catch (error) {
|
|
1671
|
+
console.warn('[AsyncTools] PostToolUse hook threw during async settlement:', error);
|
|
1672
|
+
}
|
|
1673
|
+
}
|
|
812
1674
|
/**
|
|
813
1675
|
* Emit SessionEnd exactly once, and only when a matching SessionStart
|
|
814
1676
|
* actually succeeded. Safe to call from multiple teardown paths.
|
|
@@ -821,28 +1683,41 @@ export class ModelResult {
|
|
|
821
1683
|
await this.hooksManager.emit('SessionEnd', {
|
|
822
1684
|
reason,
|
|
823
1685
|
...(this.sessionUsage.modelCalls > 0 && {
|
|
824
|
-
totalUsage:
|
|
825
|
-
modelCalls: this.sessionUsage.modelCalls,
|
|
826
|
-
inputTokens: this.sessionUsage.inputTokens,
|
|
827
|
-
outputTokens: this.sessionUsage.outputTokens,
|
|
828
|
-
totalTokens: this.sessionUsage.totalTokens,
|
|
829
|
-
cachedTokens: this.sessionUsage.cachedTokens,
|
|
830
|
-
reasoningTokens: this.sessionUsage.reasoningTokens,
|
|
831
|
-
...(this.sessionUsage.hasCost && {
|
|
832
|
-
cost: this.sessionUsage.cost,
|
|
833
|
-
}),
|
|
834
|
-
},
|
|
1686
|
+
totalUsage: this.snapshotSessionUsage(),
|
|
835
1687
|
}),
|
|
836
1688
|
}, this.hookEmitContext());
|
|
837
1689
|
}
|
|
1690
|
+
/**
|
|
1691
|
+
* Materialize the running session aggregate as an immutable
|
|
1692
|
+
* `SessionUsageTotals`. `cost` is present only when at least one response
|
|
1693
|
+
* reported it, matching the hook payload's optionality (a summed cost of
|
|
1694
|
+
* `0` from cost-less responses would read as "free" rather than "unknown").
|
|
1695
|
+
*
|
|
1696
|
+
* Single source of truth for both `SessionEnd.totalUsage` and the public
|
|
1697
|
+
* `getUsage()` accessor, so the two can never drift.
|
|
1698
|
+
*/
|
|
1699
|
+
snapshotSessionUsage() {
|
|
1700
|
+
return {
|
|
1701
|
+
modelCalls: this.sessionUsage.modelCalls,
|
|
1702
|
+
inputTokens: this.sessionUsage.inputTokens,
|
|
1703
|
+
outputTokens: this.sessionUsage.outputTokens,
|
|
1704
|
+
totalTokens: this.sessionUsage.totalTokens,
|
|
1705
|
+
cachedTokens: this.sessionUsage.cachedTokens,
|
|
1706
|
+
reasoningTokens: this.sessionUsage.reasoningTokens,
|
|
1707
|
+
...(this.sessionUsage.hasCost && {
|
|
1708
|
+
cost: this.sessionUsage.cost,
|
|
1709
|
+
}),
|
|
1710
|
+
};
|
|
1711
|
+
}
|
|
838
1712
|
/**
|
|
839
1713
|
* Emit PostModelCall for a completed model response and fold its usage
|
|
840
1714
|
* into the session aggregate. One emit per materialized response.
|
|
1715
|
+
*
|
|
1716
|
+
* Accumulation runs unconditionally — before the hooks short-circuit —
|
|
1717
|
+
* because `getUsage()` surfaces these totals to callers who configured no
|
|
1718
|
+
* hooks at all. Only the hook emit is gated on a `HooksManager`.
|
|
841
1719
|
*/
|
|
842
1720
|
async emitPostModelCall(response, startedAt, turnType, turnNumber) {
|
|
843
|
-
if (!this.hooksManager) {
|
|
844
|
-
return;
|
|
845
|
-
}
|
|
846
1721
|
const usage = extractModelCallUsage(response.usage);
|
|
847
1722
|
this.sessionUsage.modelCalls++;
|
|
848
1723
|
if (usage) {
|
|
@@ -856,6 +1731,9 @@ export class ModelResult {
|
|
|
856
1731
|
this.sessionUsage.hasCost = true;
|
|
857
1732
|
}
|
|
858
1733
|
}
|
|
1734
|
+
if (!this.hooksManager) {
|
|
1735
|
+
return;
|
|
1736
|
+
}
|
|
859
1737
|
await this.hooksManager.emit('PostModelCall', {
|
|
860
1738
|
sessionId: this.currentState?.id ?? '',
|
|
861
1739
|
responseId: response.id,
|
|
@@ -927,36 +1805,56 @@ export class ModelResult {
|
|
|
927
1805
|
}
|
|
928
1806
|
/**
|
|
929
1807
|
* Session teardown for the no-tools stream paths, which bypass
|
|
930
|
-
* executeToolsIfNeeded (the normal SessionEnd site).
|
|
931
|
-
*
|
|
932
|
-
* stream's own outcome.
|
|
1808
|
+
* executeToolsIfNeeded (the normal SessionEnd site). Materializes the
|
|
1809
|
+
* parked model-call telemetry, emits SessionEnd once, and drains pending
|
|
1810
|
+
* hook work. Never throws: teardown must not mask the stream's own outcome.
|
|
933
1811
|
*/
|
|
934
1812
|
async finishHooksSessionForStream(reason = 'complete') {
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
1813
|
+
// Materialize the parked initial-call telemetry when the stream fully
|
|
1814
|
+
// completed (the retained buffer replays without touching the source).
|
|
1815
|
+
// A failed/errored stream skips it: no materialized response exists.
|
|
1816
|
+
// (`response.incomplete` responses DO emit — they are materialized, have
|
|
1817
|
+
// a generation id, and consumed tokens.)
|
|
1818
|
+
// Runs even without a HooksManager: this is the only site that folds the
|
|
1819
|
+
// no-tools streaming response into `sessionUsage`, which `getUsage()`
|
|
1820
|
+
// reports to hook-less callers.
|
|
1821
|
+
// Isolated try: a telemetry failure (e.g. a buffer without a completion
|
|
1822
|
+
// event, or a throwing strict-mode handler) must not skip SessionEnd or
|
|
1823
|
+
// the drain below — those are contractual on every exit path.
|
|
938
1824
|
try {
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
1825
|
+
if (this.pendingModelCall) {
|
|
1826
|
+
if (this.finalResponse) {
|
|
1827
|
+
await this.emitPendingModelCallOnce(this.finalResponse);
|
|
1828
|
+
}
|
|
1829
|
+
else if (this.reusableStream?.isComplete) {
|
|
1830
|
+
// Sync backward scan of the retained buffer — not a consumer
|
|
1831
|
+
// replay, which would cost one microtask hop per buffered event
|
|
1832
|
+
// on every hook-less streaming teardown.
|
|
1833
|
+
await this.emitPendingModelCallOnce(extractCompletionFromBuffer(this.reusableStream));
|
|
1834
|
+
}
|
|
1835
|
+
else if (this.reusableStream) {
|
|
1836
|
+
// Consumers stop at the terminal event (streamTerminationEvents),
|
|
1837
|
+
// usually before the pump reads the source close that flips
|
|
1838
|
+
// `isComplete` — with a real network stream the close frame
|
|
1839
|
+
// arrives after `response.completed`. The terminal event is
|
|
1840
|
+
// already buffered in that window, so recover it rather than
|
|
1841
|
+
// dropping the parked telemetry. Stays silent (no emit, no
|
|
1842
|
+
// throw) when nothing terminal was buffered — e.g. an errored
|
|
1843
|
+
// mid-flight stream, where no materialized response exists.
|
|
1844
|
+
const buffered = tryExtractCompletionFromBuffer(this.reusableStream);
|
|
1845
|
+
if (buffered) {
|
|
1846
|
+
await this.emitPendingModelCallOnce(buffered);
|
|
954
1847
|
}
|
|
955
1848
|
}
|
|
956
1849
|
}
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
1850
|
+
}
|
|
1851
|
+
catch (telemetryError) {
|
|
1852
|
+
console.warn('[PostModelCall] error during stream teardown:', telemetryError);
|
|
1853
|
+
}
|
|
1854
|
+
if (!this.hooksManager) {
|
|
1855
|
+
return;
|
|
1856
|
+
}
|
|
1857
|
+
try {
|
|
960
1858
|
await this.emitSessionEndOnce(reason);
|
|
961
1859
|
await this.hooksManager.drain();
|
|
962
1860
|
}
|
|
@@ -1090,13 +1988,75 @@ export class ModelResult {
|
|
|
1090
1988
|
};
|
|
1091
1989
|
}
|
|
1092
1990
|
/**
|
|
1093
|
-
*
|
|
1991
|
+
* Settle an async invocation on the approval/resume path, where results
|
|
1992
|
+
* are accumulated as `UnsentToolResult`s rather than round outputs.
|
|
1094
1993
|
*
|
|
1095
|
-
*
|
|
1096
|
-
*
|
|
1097
|
-
*
|
|
1994
|
+
* - background: degrade to synchronous execution — the approval flow is
|
|
1995
|
+
* already a discrete resume step, so there is no round to unblock; the
|
|
1996
|
+
* work is awaited and its (validated) value becomes the unsent result.
|
|
1997
|
+
* - defer: the external task HAS been started by `start()`; record the
|
|
1998
|
+
* pending placeholder as the unsent result and track the task on
|
|
1999
|
+
* `pendingAsyncTools` so `resumeToolResults` can deliver the real
|
|
2000
|
+
* result later as an envelope.
|
|
1098
2001
|
*/
|
|
1099
|
-
async
|
|
2002
|
+
async settleAsyncInvocationAsUnsent(tc, invocation) {
|
|
2003
|
+
if (invocation.asyncMode === 'background') {
|
|
2004
|
+
try {
|
|
2005
|
+
const result = await invocation.run();
|
|
2006
|
+
return createUnsentResult(tc.id, String(tc.name), result);
|
|
2007
|
+
}
|
|
2008
|
+
catch (error) {
|
|
2009
|
+
return createRejectedResult(tc.id, String(tc.name), error instanceof Error ? error.message : String(error));
|
|
2010
|
+
}
|
|
2011
|
+
}
|
|
2012
|
+
const registry = this.ensureAsyncToolRegistry();
|
|
2013
|
+
const liveTask = registry.trackDeferred({
|
|
2014
|
+
callId: tc.id,
|
|
2015
|
+
taskId: invocation.taskId,
|
|
2016
|
+
name: String(tc.name),
|
|
2017
|
+
...(invocation.pollAfterMs !== undefined && {
|
|
2018
|
+
pollAfterMs: invocation.pollAfterMs,
|
|
2019
|
+
}),
|
|
2020
|
+
...(invocation.expiresAt !== undefined && {
|
|
2021
|
+
expiresAt: invocation.expiresAt,
|
|
2022
|
+
}),
|
|
2023
|
+
});
|
|
2024
|
+
const task = {
|
|
2025
|
+
callId: tc.id,
|
|
2026
|
+
taskId: invocation.taskId,
|
|
2027
|
+
name: String(tc.name),
|
|
2028
|
+
mode: 'defer',
|
|
2029
|
+
status: 'working',
|
|
2030
|
+
startedAt: liveTask.startedAt,
|
|
2031
|
+
...(invocation.pollAfterMs !== undefined && {
|
|
2032
|
+
pollAfterMs: invocation.pollAfterMs,
|
|
2033
|
+
}),
|
|
2034
|
+
...(invocation.expiresAt !== undefined && {
|
|
2035
|
+
expiresAt: invocation.expiresAt,
|
|
2036
|
+
}),
|
|
2037
|
+
};
|
|
2038
|
+
if (this.currentState) {
|
|
2039
|
+
this.currentState = updateState(this.currentState, {
|
|
2040
|
+
pendingAsyncTools: [
|
|
2041
|
+
...(this.currentState.pendingAsyncTools ?? []).filter((t) => t.callId !== tc.id),
|
|
2042
|
+
task,
|
|
2043
|
+
],
|
|
2044
|
+
});
|
|
2045
|
+
}
|
|
2046
|
+
const placeholder = this.buildPendingPlaceholder(tc, invocation.taskId, invocation.ack, invocation.pollAfterMs);
|
|
2047
|
+
return createUnsentResult(tc.id, String(tc.name), typeof placeholder.output === 'string' ? JSON.parse(placeholder.output) : placeholder.output);
|
|
2048
|
+
}
|
|
2049
|
+
/**
|
|
2050
|
+
* Execute tools that can auto-execute (don't require approval) in parallel.
|
|
2051
|
+
*
|
|
2052
|
+
* @param toolCalls - The tool calls to execute
|
|
2053
|
+
* @param turnContext - The current turn context
|
|
2054
|
+
* @returns Array of unsent tool results for later submission
|
|
2055
|
+
*/
|
|
2056
|
+
async executeAutoApproveTools(toolCalls, turnContext) {
|
|
2057
|
+
// Auto-approved batch = one doom-loop round: identical parallel calls
|
|
2058
|
+
// count once (see beginDoomLoopRound).
|
|
2059
|
+
await this.beginDoomLoopRound(toolCalls);
|
|
1100
2060
|
const toolCallPromises = toolCalls.map(async (tc) => {
|
|
1101
2061
|
const tool = this.options.tools?.find((t) => isClientTool(t) && t.function.name === tc.name);
|
|
1102
2062
|
if (!tool || !isAutoResolvableTool(tool)) {
|
|
@@ -1120,6 +2080,9 @@ export class ModelResult {
|
|
|
1120
2080
|
// HITL tool paused — no unsent result for this call in this round
|
|
1121
2081
|
return null;
|
|
1122
2082
|
}
|
|
2083
|
+
if (isAsyncToolInvocation(result)) {
|
|
2084
|
+
return this.settleAsyncInvocationAsUnsent(tc, result);
|
|
2085
|
+
}
|
|
1123
2086
|
if (result.error) {
|
|
1124
2087
|
return createRejectedResult(tc.id, String(tc.name), result.error.message);
|
|
1125
2088
|
}
|
|
@@ -1250,6 +2213,11 @@ export class ModelResult {
|
|
|
1250
2213
|
*/
|
|
1251
2214
|
async persistHitlPause(currentResponse, pausedCalls) {
|
|
1252
2215
|
this.finalResponse = currentResponse;
|
|
2216
|
+
// Queued steer guidance is NOT injected here: the paused calls are
|
|
2217
|
+
// dangling function_calls, and a user message between a function_call
|
|
2218
|
+
// and its eventual output is invalid history. saveStateSafely persists
|
|
2219
|
+
// the queue (doomLoop.pendingSteer); the resumed run delivers it at its
|
|
2220
|
+
// next safe flush point.
|
|
1253
2221
|
if (!this.stateAccessor) {
|
|
1254
2222
|
return;
|
|
1255
2223
|
}
|
|
@@ -1285,6 +2253,7 @@ export class ModelResult {
|
|
|
1285
2253
|
*/
|
|
1286
2254
|
async persistClientToolsPause(currentResponse, unresolvedCalls) {
|
|
1287
2255
|
this.finalResponse = currentResponse;
|
|
2256
|
+
// Steer queue persists via saveStateSafely (see persistHitlPause).
|
|
1288
2257
|
if (!this.stateAccessor || unresolvedCalls.length === 0) {
|
|
1289
2258
|
return;
|
|
1290
2259
|
}
|
|
@@ -1294,6 +2263,111 @@ export class ModelResult {
|
|
|
1294
2263
|
};
|
|
1295
2264
|
await this.saveStateSafely(stateUpdates);
|
|
1296
2265
|
}
|
|
2266
|
+
/**
|
|
2267
|
+
* Persist state when one or more deferred tools (`tool.deferred`) started
|
|
2268
|
+
* durable external tasks this round. The placeholder outputs are already
|
|
2269
|
+
* persisted (the round is fully paired), so — unlike HITL/manual pauses —
|
|
2270
|
+
* nothing dangles; the pause exists purely because the model's next turn
|
|
2271
|
+
* should wait for the real results.
|
|
2272
|
+
*
|
|
2273
|
+
* Mirrors `persistHitlPause` in shape. Status `awaiting_async_tools` is
|
|
2274
|
+
* distinct from `awaiting_client_tools`: the tool DID execute; only its
|
|
2275
|
+
* result is pending. Resumption happens via the deferred tool's
|
|
2276
|
+
* `.resolve()` / `.fail()` / `.cancel()` or `resumeToolResults()`.
|
|
2277
|
+
*/
|
|
2278
|
+
async persistAsyncToolPause(currentResponse, deferredTasks) {
|
|
2279
|
+
this.finalResponse = currentResponse;
|
|
2280
|
+
if (!this.stateAccessor) {
|
|
2281
|
+
return;
|
|
2282
|
+
}
|
|
2283
|
+
const existing = (this.currentState?.pendingAsyncTools ?? []).filter((t) => !deferredTasks.some((d) => d.callId === t.callId));
|
|
2284
|
+
await this.saveStateSafely({
|
|
2285
|
+
pendingAsyncTools: [
|
|
2286
|
+
...existing,
|
|
2287
|
+
...deferredTasks,
|
|
2288
|
+
],
|
|
2289
|
+
status: 'awaiting_async_tools',
|
|
2290
|
+
});
|
|
2291
|
+
}
|
|
2292
|
+
/**
|
|
2293
|
+
* Harvest background tasks that settled since the last flush and inject
|
|
2294
|
+
* their outcomes into the conversation as `tool_task_result` envelope
|
|
2295
|
+
* messages (user role). Runs at the same safe point as
|
|
2296
|
+
* `flushDoomLoopSteer()` — after a fully-paired round, before the next
|
|
2297
|
+
* request — because injecting between a dangling `function_call` and its
|
|
2298
|
+
* future output would be invalid history.
|
|
2299
|
+
*
|
|
2300
|
+
* A delivery is forward progress: the text-repetition streak is reset so a
|
|
2301
|
+
* model saying "still waiting…" between deliveries is not condemned.
|
|
2302
|
+
*/
|
|
2303
|
+
async flushAsyncToolDeliveries() {
|
|
2304
|
+
const registry = this.asyncToolRegistry;
|
|
2305
|
+
if (!registry) {
|
|
2306
|
+
return false;
|
|
2307
|
+
}
|
|
2308
|
+
const settled = registry.takeSettled();
|
|
2309
|
+
if (settled.length === 0) {
|
|
2310
|
+
return false;
|
|
2311
|
+
}
|
|
2312
|
+
const envelopes = [];
|
|
2313
|
+
for (const task of settled) {
|
|
2314
|
+
this.broadcastAsyncSettled(task, 'injected');
|
|
2315
|
+
// Emit the canonical tool.result event with the FINAL value so
|
|
2316
|
+
// consumers watching only tool.result see real results exactly once.
|
|
2317
|
+
if (task.status === 'completed') {
|
|
2318
|
+
this.broadcastToolResult(task.callId, this.toolSourceByName(task.name), task.result);
|
|
2319
|
+
}
|
|
2320
|
+
// PostToolUse/PostToolUseFailure fire at SETTLEMENT for async tools —
|
|
2321
|
+
// the observation-only audit surface (secret scanning, output review)
|
|
2322
|
+
// must cover long-running results, which are exactly the ones that
|
|
2323
|
+
// arrive from external processes.
|
|
2324
|
+
await this.emitAsyncSettledHooks(task);
|
|
2325
|
+
envelopes.push(JSON.stringify({
|
|
2326
|
+
type: 'tool_task_result',
|
|
2327
|
+
tool: task.name,
|
|
2328
|
+
taskId: task.taskId,
|
|
2329
|
+
callId: task.callId,
|
|
2330
|
+
status: task.status,
|
|
2331
|
+
...(task.result !== undefined && {
|
|
2332
|
+
result: task.result,
|
|
2333
|
+
}),
|
|
2334
|
+
...(task.error !== undefined && {
|
|
2335
|
+
error: task.error,
|
|
2336
|
+
}),
|
|
2337
|
+
}));
|
|
2338
|
+
// Record settlement for the at-most-once guard. The pending entry is
|
|
2339
|
+
// KEPT with its terminal status (not removed) — same policy as
|
|
2340
|
+
// resumeToolResults — so a late external resolution by taskId OR
|
|
2341
|
+
// callId resolves to ToolTaskAlreadySettledError, never "not found".
|
|
2342
|
+
if (this.currentState) {
|
|
2343
|
+
const terminalStatus = task.status === 'completed' || task.status === 'cancelled' ? task.status : 'failed';
|
|
2344
|
+
this.currentState = updateState(this.currentState, {
|
|
2345
|
+
settledAsyncCallIds: [
|
|
2346
|
+
...(this.currentState.settledAsyncCallIds ?? []),
|
|
2347
|
+
task.callId,
|
|
2348
|
+
],
|
|
2349
|
+
...(this.currentState.pendingAsyncTools !== undefined && {
|
|
2350
|
+
pendingAsyncTools: this.currentState.pendingAsyncTools.map((t) => t.callId === task.callId
|
|
2351
|
+
? {
|
|
2352
|
+
...t,
|
|
2353
|
+
status: terminalStatus,
|
|
2354
|
+
}
|
|
2355
|
+
: t),
|
|
2356
|
+
}),
|
|
2357
|
+
});
|
|
2358
|
+
}
|
|
2359
|
+
}
|
|
2360
|
+
// Instruction boundary: the injected message rides the user role (the
|
|
2361
|
+
// only channel that can deliver a late result without a duplicate
|
|
2362
|
+
// function_call_output), but its content is tool output — mark it so
|
|
2363
|
+
// attacker-influenced result text does not read as user instructions.
|
|
2364
|
+
await this.injectAppendPromptMessage(`${TASK_RESULT_BOUNDARY}\n${envelopes.join('\n')}`);
|
|
2365
|
+
// Delivery is observable forward progress — clear the text streak so
|
|
2366
|
+
// legitimate "waiting" phrasing between deliveries can't accumulate
|
|
2367
|
+
// into a doom-loop stop.
|
|
2368
|
+
this.doomLoopMonitor?.resetTextStreak();
|
|
2369
|
+
return true;
|
|
2370
|
+
}
|
|
1297
2371
|
/**
|
|
1298
2372
|
* Compute the `output` payload sent to the model for a successfully
|
|
1299
2373
|
* settled tool execution. Routes through `toModelOutput` when the tool
|
|
@@ -1346,6 +2420,19 @@ export class ModelResult {
|
|
|
1346
2420
|
* outcome consumed by `executeToolRound`'s aggregation loop.
|
|
1347
2421
|
*/
|
|
1348
2422
|
async executeSingleToolCall(toolCall, turnContext) {
|
|
2423
|
+
// Universal task-tool dispatch: ONE static tool ("task") handles every
|
|
2424
|
+
// check/steer/result/cancel interaction with running tasks, addressed
|
|
2425
|
+
// by taskId — the wire surface stays constant regardless of how many
|
|
2426
|
+
// async tools are registered, while the handling stays tool-resident
|
|
2427
|
+
// (the owning tool's `check` config). Engine-intercepted: bypasses
|
|
2428
|
+
// gates, deadlines, and Pre/PostToolUse hooks (engine bookkeeping, not
|
|
2429
|
+
// user work) and never reaches the doom-loop checkpoint. A
|
|
2430
|
+
// PermissionRequest denial recorded for the call IS honored — cancel
|
|
2431
|
+
// is destructive and steer reshapes a running task, so a policy layer
|
|
2432
|
+
// that vetoed the call wins over the interception.
|
|
2433
|
+
if (toolCall.name === TASK_TOOL_NAME && this.taskToolActive()) {
|
|
2434
|
+
return this.consumeHookDenial(toolCall.id) ?? this.answerTaskToolCall(toolCall);
|
|
2435
|
+
}
|
|
1349
2436
|
const tool = this.options.tools?.find((t) => isClientTool(t) && t.function.name === toolCall.name);
|
|
1350
2437
|
if (!tool || !isAutoResolvableTool(tool)) {
|
|
1351
2438
|
return null;
|
|
@@ -1353,20 +2440,9 @@ export class ModelResult {
|
|
|
1353
2440
|
// PermissionRequest hook denied this call without pausing: synthesize a
|
|
1354
2441
|
// rejection instead of executing. Consume the entry so a later round
|
|
1355
2442
|
// with a reused id is not affected.
|
|
1356
|
-
const
|
|
1357
|
-
if (
|
|
1358
|
-
|
|
1359
|
-
return {
|
|
1360
|
-
type: 'hook_blocked',
|
|
1361
|
-
output: {
|
|
1362
|
-
type: 'function_call_output',
|
|
1363
|
-
id: `output_${toolCall.id}`,
|
|
1364
|
-
callId: toolCall.id,
|
|
1365
|
-
output: JSON.stringify({
|
|
1366
|
-
error: denialReason,
|
|
1367
|
-
}),
|
|
1368
|
-
},
|
|
1369
|
-
};
|
|
2443
|
+
const hookDenied = this.consumeHookDenial(toolCall.id);
|
|
2444
|
+
if (hookDenied !== null) {
|
|
2445
|
+
return hookDenied;
|
|
1370
2446
|
}
|
|
1371
2447
|
const preliminaryResultsForCall = [];
|
|
1372
2448
|
const hasBroadcaster = this.toolEventBroadcaster || this.turnBroadcaster;
|
|
@@ -1377,41 +2453,198 @@ export class ModelResult {
|
|
|
1377
2453
|
this.broadcastPreliminaryResult(callId, typedResult);
|
|
1378
2454
|
}
|
|
1379
2455
|
: undefined;
|
|
1380
|
-
//
|
|
1381
|
-
//
|
|
1382
|
-
//
|
|
1383
|
-
//
|
|
1384
|
-
|
|
1385
|
-
|
|
1386
|
-
|
|
1387
|
-
|
|
2456
|
+
// Compose the per-call abort signal (run signal + tool/run timeout +
|
|
2457
|
+
// per-call controller) and acquire the concurrency gates in fixed order
|
|
2458
|
+
// (round gate first, then per-tool gate — fixed ordering is what makes
|
|
2459
|
+
// multi-gate acquisition deadlock-free). Gate WAITING happens before the
|
|
2460
|
+
// timeout race below, so queue wait counts against the deadline only
|
|
2461
|
+
// for background tasks (which carry their own timeout in the registry).
|
|
2462
|
+
//
|
|
2463
|
+
// Background lifecycles skip the per-tool gate HERE: their body only
|
|
2464
|
+
// runs later via runBackgroundWork, which acquires that gate for the
|
|
2465
|
+
// body's true duration. Acquiring it on the round path too would let
|
|
2466
|
+
// detached bodies holding all maxConcurrency slots block a later
|
|
2467
|
+
// round's dispatch (thunk creation, no body work) for the full task
|
|
2468
|
+
// duration — while it holds a round-gate slot, head-of-line-blocking
|
|
2469
|
+
// every other call in the round.
|
|
2470
|
+
const { controller, signal, timeoutMs } = this.composeToolSignal(String(toolCall.name), toolCall.id);
|
|
2471
|
+
const isBackgroundLifecycle = isUnifiedTool(tool) && tool.function.lifecycle === 'background';
|
|
2472
|
+
const releaseGates = await acquireAll([
|
|
2473
|
+
this.roundGate ?? undefined,
|
|
2474
|
+
isBackgroundLifecycle ? undefined : this.perToolGate(String(toolCall.name)),
|
|
2475
|
+
]);
|
|
2476
|
+
try {
|
|
2477
|
+
// Run the tool through the full Pre/Post lifecycle hooks. The helper
|
|
2478
|
+
// fails closed on a JSON-parse failure in toolCall.arguments so hooks
|
|
2479
|
+
// never see a malformed payload; the caller handles that case via the
|
|
2480
|
+
// shared `parse_error` / `hook_blocked` branch.
|
|
2481
|
+
//
|
|
2482
|
+
// The whole hooked execution is raced against the tool deadline: on
|
|
2483
|
+
// timeout the round stops waiting (the model gets a `tool_timeout`
|
|
2484
|
+
// error output) and the body's signal is aborted. The abandoned body
|
|
2485
|
+
// keeps running detached; awaiting it would reintroduce the hang
|
|
2486
|
+
// run-cancellation (DEV-658) fixed.
|
|
2487
|
+
// Unified-run affordances. The ToolTask is created later (when the
|
|
2488
|
+
// call escapes the round in handleAsyncInvocation), so the log sink
|
|
2489
|
+
// and inbox registration go through a mutable slot the task binds to
|
|
2490
|
+
// on creation; entries logged before that are buffered.
|
|
2491
|
+
const runBinding = this.createRunBinding(toolCall.id);
|
|
2492
|
+
const executionPromise = this.runToolWithHooks(tool, toolCall, turnContext, onPreliminaryResult, {
|
|
2493
|
+
signal,
|
|
2494
|
+
callId: toolCall.id,
|
|
2495
|
+
...(this.currentState?.id !== undefined && {
|
|
2496
|
+
conversationId: this.currentState.id,
|
|
2497
|
+
}),
|
|
2498
|
+
client: this.options.client,
|
|
2499
|
+
runExtras: buildRunExtras(tool, runBinding),
|
|
1388
2500
|
});
|
|
1389
|
-
|
|
1390
|
-
|
|
1391
|
-
|
|
1392
|
-
|
|
1393
|
-
|
|
1394
|
-
|
|
1395
|
-
|
|
1396
|
-
|
|
2501
|
+
const executed = await this.raceToolDeadline(executionPromise, String(toolCall.name), timeoutMs, controller);
|
|
2502
|
+
if (executed === 'timeout') {
|
|
2503
|
+
return this.buildToolTimeoutOutcome(toolCall, tool, timeoutMs, preliminaryResultsForCall);
|
|
2504
|
+
}
|
|
2505
|
+
if (executed.type === 'parse_error') {
|
|
2506
|
+
this.broadcastToolResult(toolCall.id, isMcpTool(tool) ? 'mcp' : 'client', {
|
|
2507
|
+
error: executed.errorMessage,
|
|
2508
|
+
});
|
|
2509
|
+
return executed;
|
|
2510
|
+
}
|
|
2511
|
+
if (executed.type === 'hook_blocked') {
|
|
2512
|
+
return executed;
|
|
2513
|
+
}
|
|
2514
|
+
const result = executed.result;
|
|
2515
|
+
if (result === null) {
|
|
2516
|
+
// HITL tool paused — surface as manual (no output this round)
|
|
2517
|
+
return {
|
|
2518
|
+
type: 'paused',
|
|
2519
|
+
toolCall,
|
|
2520
|
+
};
|
|
2521
|
+
}
|
|
2522
|
+
if (isAsyncToolInvocation(result)) {
|
|
2523
|
+
// Background/deferred: the round's aggregation loop turns this into
|
|
2524
|
+
// a placeholder output (and, for background, tracks the work in the
|
|
2525
|
+
// registry). The per-call controller stays alive with the task.
|
|
2526
|
+
return {
|
|
2527
|
+
type: 'async',
|
|
2528
|
+
toolCall: executed.effectiveToolCall,
|
|
2529
|
+
tool,
|
|
2530
|
+
invocation: result,
|
|
2531
|
+
controller,
|
|
2532
|
+
timeoutMs,
|
|
2533
|
+
runBinding,
|
|
2534
|
+
preliminaryResultsForCall,
|
|
2535
|
+
};
|
|
2536
|
+
}
|
|
1397
2537
|
return {
|
|
1398
|
-
type: '
|
|
1399
|
-
toolCall,
|
|
2538
|
+
type: 'execution',
|
|
2539
|
+
toolCall: executed.effectiveToolCall,
|
|
2540
|
+
tool,
|
|
2541
|
+
result,
|
|
2542
|
+
preliminaryResultsForCall,
|
|
1400
2543
|
};
|
|
1401
2544
|
}
|
|
2545
|
+
finally {
|
|
2546
|
+
releaseGates();
|
|
2547
|
+
// The inflight map only tracks round-synchronous executions; async
|
|
2548
|
+
// background tasks hand their controller to the registry, which owns
|
|
2549
|
+
// teardown from here (cancelTask / abortAll).
|
|
2550
|
+
this.inflightToolControllers.delete(toolCall.id);
|
|
2551
|
+
}
|
|
2552
|
+
}
|
|
2553
|
+
/**
|
|
2554
|
+
* The `tool_timeout` execution outcome for a call whose deadline fired:
|
|
2555
|
+
* the model receives an error output immediately (the abandoned body
|
|
2556
|
+
* keeps running detached).
|
|
2557
|
+
*/
|
|
2558
|
+
buildToolTimeoutOutcome(toolCall, tool, timeoutMs, preliminaryResultsForCall) {
|
|
2559
|
+
const message = `Tool "${toolCall.name}" timed out after ${timeoutMs}ms`;
|
|
2560
|
+
this.broadcastToolResult(toolCall.id, isMcpTool(tool) ? 'mcp' : 'client', {
|
|
2561
|
+
error: message,
|
|
2562
|
+
});
|
|
1402
2563
|
return {
|
|
1403
2564
|
type: 'execution',
|
|
1404
|
-
toolCall
|
|
2565
|
+
toolCall,
|
|
1405
2566
|
tool,
|
|
1406
|
-
result
|
|
2567
|
+
result: {
|
|
2568
|
+
result: null,
|
|
2569
|
+
error: Object.assign(new Error(message), {
|
|
2570
|
+
code: 'tool_timeout',
|
|
2571
|
+
}),
|
|
2572
|
+
},
|
|
1407
2573
|
preliminaryResultsForCall,
|
|
1408
2574
|
};
|
|
1409
2575
|
}
|
|
2576
|
+
/**
|
|
2577
|
+
* PermissionRequest-hook denial for a call, as a `hook_blocked` outcome —
|
|
2578
|
+
* or null when the call was not denied. Consumes the entry so a later
|
|
2579
|
+
* round with a reused id is not affected.
|
|
2580
|
+
*/
|
|
2581
|
+
consumeHookDenial(callId) {
|
|
2582
|
+
const denialReason = this.hookDeniedCalls.get(callId);
|
|
2583
|
+
if (denialReason === undefined) {
|
|
2584
|
+
return null;
|
|
2585
|
+
}
|
|
2586
|
+
this.hookDeniedCalls.delete(callId);
|
|
2587
|
+
return {
|
|
2588
|
+
type: 'hook_blocked',
|
|
2589
|
+
output: {
|
|
2590
|
+
type: 'function_call_output',
|
|
2591
|
+
id: `output_${callId}`,
|
|
2592
|
+
callId,
|
|
2593
|
+
output: JSON.stringify({
|
|
2594
|
+
error: denialReason,
|
|
2595
|
+
}),
|
|
2596
|
+
},
|
|
2597
|
+
};
|
|
2598
|
+
}
|
|
2599
|
+
/**
|
|
2600
|
+
* Race a tool execution against its deadline. Returns `'timeout'` when the
|
|
2601
|
+
* deadline fires first — the caller synthesizes the `tool_timeout` output —
|
|
2602
|
+
* and aborts the body's controller so cooperative tools stop working. The
|
|
2603
|
+
* timer is cleared on settle either way.
|
|
2604
|
+
*/
|
|
2605
|
+
raceToolDeadline(execution, toolName, timeoutMs, controller) {
|
|
2606
|
+
if (timeoutMs === undefined || timeoutMs <= 0) {
|
|
2607
|
+
return execution;
|
|
2608
|
+
}
|
|
2609
|
+
return new Promise((resolve, reject) => {
|
|
2610
|
+
const timer = setTimeout(() => {
|
|
2611
|
+
controller.abort(new Error(`Tool "${toolName}" timed out after ${timeoutMs}ms`));
|
|
2612
|
+
resolve('timeout');
|
|
2613
|
+
}, timeoutMs);
|
|
2614
|
+
if (typeof timer === 'object' && 'unref' in timer && typeof timer.unref === 'function') {
|
|
2615
|
+
timer.unref();
|
|
2616
|
+
}
|
|
2617
|
+
execution.then((value) => {
|
|
2618
|
+
clearTimeout(timer);
|
|
2619
|
+
resolve(value);
|
|
2620
|
+
}, (error) => {
|
|
2621
|
+
clearTimeout(timer);
|
|
2622
|
+
reject(error instanceof Error ? error : new Error(String(error)));
|
|
2623
|
+
});
|
|
2624
|
+
});
|
|
2625
|
+
}
|
|
1410
2626
|
async executeToolRound(toolCalls, turnContext) {
|
|
2627
|
+
// One executed batch = one doom-loop round: identical parallel calls in
|
|
2628
|
+
// this batch count as ONE piece of loop evidence and share a decision.
|
|
2629
|
+
await this.beginDoomLoopRound(toolCalls);
|
|
1411
2630
|
const toolCallPromises = toolCalls.map((toolCall) => this.executeSingleToolCall(toolCall, turnContext));
|
|
1412
2631
|
const settledResults = await Promise.allSettled(toolCallPromises);
|
|
1413
2632
|
const toolResults = [];
|
|
1414
2633
|
const pausedCalls = [];
|
|
2634
|
+
const deferredTasks = [];
|
|
2635
|
+
// Start ALL async invocations before consuming any outcome: the work
|
|
2636
|
+
// (and its grace window) begins in handleAsyncInvocation, so awaiting
|
|
2637
|
+
// it inside the ordered loop below would serialize N background calls
|
|
2638
|
+
// into (N-1)×graceMs of stagger. Kicked off here in parallel; the
|
|
2639
|
+
// ordered loop awaits the per-call promise, so OUTPUT order stays call
|
|
2640
|
+
// order (prompt-cache stability).
|
|
2641
|
+
const asyncOutcomes = new Map();
|
|
2642
|
+
for (let i = 0; i < settledResults.length; i++) {
|
|
2643
|
+
const settled = settledResults[i];
|
|
2644
|
+
if (settled?.status === 'fulfilled' && settled.value?.type === 'async') {
|
|
2645
|
+
asyncOutcomes.set(i, this.handleAsyncInvocation(settled.value));
|
|
2646
|
+
}
|
|
2647
|
+
}
|
|
1415
2648
|
for (let i = 0; i < settledResults.length; i++) {
|
|
1416
2649
|
const settled = settledResults[i];
|
|
1417
2650
|
const originalToolCall = toolCalls[i];
|
|
@@ -1461,29 +2694,601 @@ export class ModelResult {
|
|
|
1461
2694
|
pausedCalls.push(value.toolCall);
|
|
1462
2695
|
continue;
|
|
1463
2696
|
}
|
|
1464
|
-
|
|
1465
|
-
|
|
1466
|
-
|
|
1467
|
-
|
|
1468
|
-
|
|
1469
|
-
|
|
1470
|
-
|
|
1471
|
-
|
|
1472
|
-
|
|
1473
|
-
|
|
1474
|
-
|
|
1475
|
-
|
|
2697
|
+
if (value.type === 'async') {
|
|
2698
|
+
// Background / deferred: the call escapes the round. Its handling
|
|
2699
|
+
// (grace-window race, registry tracking, placeholder synthesis)
|
|
2700
|
+
// was started above in parallel with the round's other async
|
|
2701
|
+
// calls; awaiting here keeps outputs in call order.
|
|
2702
|
+
const asyncOutcome = await (asyncOutcomes.get(i) ?? this.handleAsyncInvocation(value));
|
|
2703
|
+
toolResults.push(asyncOutcome.output);
|
|
2704
|
+
this.turnBroadcaster?.push({
|
|
2705
|
+
type: 'tool.call_output',
|
|
2706
|
+
output: asyncOutcome.output,
|
|
2707
|
+
timestamp: Date.now(),
|
|
2708
|
+
});
|
|
2709
|
+
if (asyncOutcome.deferredTask) {
|
|
2710
|
+
deferredTasks.push(asyncOutcome.deferredTask);
|
|
2711
|
+
}
|
|
2712
|
+
continue;
|
|
2713
|
+
}
|
|
2714
|
+
const toolResult = (value.result.error
|
|
2715
|
+
? {
|
|
2716
|
+
error: value.result.error.message,
|
|
2717
|
+
}
|
|
2718
|
+
: value.result.result);
|
|
2719
|
+
this.broadcastToolResult(value.toolCall.id, isMcpTool(value.tool) ? 'mcp' : 'client', toolResult, value.preliminaryResultsForCall.length > 0 ? value.preliminaryResultsForCall : undefined);
|
|
2720
|
+
const outputForModel = await this.computeToolOutputForModel(value);
|
|
2721
|
+
const executedOutput = {
|
|
2722
|
+
type: 'function_call_output',
|
|
2723
|
+
id: `output_${value.toolCall.id}`,
|
|
2724
|
+
callId: value.toolCall.id,
|
|
2725
|
+
output: outputForModel,
|
|
2726
|
+
};
|
|
2727
|
+
toolResults.push(executedOutput);
|
|
2728
|
+
this.turnBroadcaster?.push({
|
|
2729
|
+
type: 'tool.call_output',
|
|
2730
|
+
output: executedOutput,
|
|
2731
|
+
timestamp: Date.now(),
|
|
2732
|
+
});
|
|
2733
|
+
}
|
|
2734
|
+
return {
|
|
2735
|
+
toolResults,
|
|
2736
|
+
pausedCalls,
|
|
2737
|
+
deferredTasks,
|
|
2738
|
+
};
|
|
2739
|
+
}
|
|
2740
|
+
/**
|
|
2741
|
+
* Handle one async invocation from a round.
|
|
2742
|
+
*
|
|
2743
|
+
* Background: start the work under the background pool, race it against
|
|
2744
|
+
* the grace window — settle in time and the call yields a plain
|
|
2745
|
+
* synchronous output (no async machinery visible); otherwise emit the
|
|
2746
|
+
* pending placeholder and let the registry track the task.
|
|
2747
|
+
*
|
|
2748
|
+
* Deferred: emit the placeholder and report the task for the pause path.
|
|
2749
|
+
*/
|
|
2750
|
+
async handleAsyncInvocation(value) {
|
|
2751
|
+
const { toolCall, tool, invocation, controller, runBinding } = value;
|
|
2752
|
+
const registry = this.ensureAsyncToolRegistry();
|
|
2753
|
+
const source = isMcpTool(tool) ? 'mcp' : 'client';
|
|
2754
|
+
const logLimits = isUnifiedTool(tool) ? tool.function.logLimits : undefined;
|
|
2755
|
+
if (invocation.asyncMode === 'defer') {
|
|
2756
|
+
const collision = this.rejectDuplicateTaskId(toolCall, invocation.taskId, source);
|
|
2757
|
+
if (collision) {
|
|
2758
|
+
return collision;
|
|
2759
|
+
}
|
|
2760
|
+
const liveTask = registry.trackDeferred({
|
|
2761
|
+
callId: toolCall.id,
|
|
2762
|
+
taskId: invocation.taskId,
|
|
2763
|
+
name: String(toolCall.name),
|
|
2764
|
+
...(invocation.expiresAt !== undefined && {
|
|
2765
|
+
expiresAt: invocation.expiresAt,
|
|
2766
|
+
}),
|
|
2767
|
+
...(invocation.pollAfterMs !== undefined && {
|
|
2768
|
+
pollAfterMs: invocation.pollAfterMs,
|
|
2769
|
+
}),
|
|
2770
|
+
});
|
|
2771
|
+
runBinding.bind(liveTask);
|
|
2772
|
+
const task = {
|
|
2773
|
+
callId: toolCall.id,
|
|
2774
|
+
taskId: invocation.taskId,
|
|
2775
|
+
name: String(toolCall.name),
|
|
2776
|
+
mode: 'defer',
|
|
2777
|
+
status: 'working',
|
|
2778
|
+
startedAt: liveTask.startedAt,
|
|
2779
|
+
...(invocation.pollAfterMs !== undefined && {
|
|
2780
|
+
pollAfterMs: invocation.pollAfterMs,
|
|
2781
|
+
}),
|
|
2782
|
+
...(invocation.expiresAt !== undefined && {
|
|
2783
|
+
expiresAt: invocation.expiresAt,
|
|
2784
|
+
}),
|
|
2785
|
+
};
|
|
2786
|
+
this.broadcastAsyncStarted(toolCall, 'defer', invocation.taskId, invocation.ack);
|
|
2787
|
+
return {
|
|
2788
|
+
output: this.buildPendingPlaceholder(toolCall, invocation.taskId, invocation.ack, invocation.pollAfterMs),
|
|
2789
|
+
deferredTask: task,
|
|
2790
|
+
};
|
|
2791
|
+
}
|
|
2792
|
+
// Background: start the work under the background pool. The pool slot is
|
|
2793
|
+
// held for the duration of the work (queue wait counts against the
|
|
2794
|
+
// task's own timeout tracked by the registry).
|
|
2795
|
+
const taskId = registry.generateTaskId();
|
|
2796
|
+
const liveTask = new ToolTask({
|
|
2797
|
+
taskId,
|
|
2798
|
+
callId: toolCall.id,
|
|
2799
|
+
toolName: String(toolCall.name),
|
|
2800
|
+
mode: isAgentTool(tool) ? 'agent' : 'background',
|
|
2801
|
+
controller,
|
|
2802
|
+
...(logLimits !== undefined && {
|
|
2803
|
+
limits: logLimits,
|
|
2804
|
+
}),
|
|
2805
|
+
// Carried so PostToolUse/PostToolUseFailure can fire at settlement
|
|
2806
|
+
// with the same payload shape as round-synchronous executions.
|
|
2807
|
+
input: (toolCall.arguments ?? {}),
|
|
2808
|
+
});
|
|
2809
|
+
runBinding.bind(liveTask);
|
|
2810
|
+
// Visible to steering / cancel / snapshots from the start — a large
|
|
2811
|
+
// graceMs must not create a window where the running task cannot be
|
|
2812
|
+
// reached (sendToTask queues into the inbox; cancelTask aborts the
|
|
2813
|
+
// controller, which the grace race observes as an error settlement).
|
|
2814
|
+
registry.register(liveTask);
|
|
2815
|
+
const work = this.runBackgroundWork(String(toolCall.name), invocation.run, controller);
|
|
2816
|
+
// Surface unhandled rejections nowhere — the registry's .then() below
|
|
2817
|
+
// (or the grace race) is the sole consumer.
|
|
2818
|
+
work.catch(() => undefined);
|
|
2819
|
+
// Grace window: work settling in time yields a plain synchronous output.
|
|
2820
|
+
const graceMs = invocation.graceMs;
|
|
2821
|
+
if (graceMs > 0) {
|
|
2822
|
+
// The timer is cleared when the work settles first (same discipline
|
|
2823
|
+
// as raceToolDeadline) — it's unref'd, but a leaked timer is still a
|
|
2824
|
+
// leaked timer.
|
|
2825
|
+
let graceTimer;
|
|
2826
|
+
const settled = await Promise.race([
|
|
2827
|
+
work.then((result) => ({
|
|
2828
|
+
outcome: 'ok',
|
|
2829
|
+
result,
|
|
2830
|
+
}), (error) => ({
|
|
2831
|
+
outcome: 'error',
|
|
2832
|
+
error,
|
|
2833
|
+
})),
|
|
2834
|
+
new Promise((resolve) => {
|
|
2835
|
+
graceTimer = setTimeout(() => resolve('pending'), graceMs);
|
|
2836
|
+
if (typeof graceTimer === 'object' &&
|
|
2837
|
+
'unref' in graceTimer &&
|
|
2838
|
+
typeof graceTimer.unref === 'function') {
|
|
2839
|
+
graceTimer.unref();
|
|
2840
|
+
}
|
|
2841
|
+
}),
|
|
2842
|
+
]);
|
|
2843
|
+
if (graceTimer !== undefined) {
|
|
2844
|
+
clearTimeout(graceTimer);
|
|
2845
|
+
}
|
|
2846
|
+
if (settled !== 'pending') {
|
|
2847
|
+
// Settled in-window: behave exactly like a regular tool. The call
|
|
2848
|
+
// yields a synchronous output, so the task leaves the registry —
|
|
2849
|
+
// including any settlement a racing cancelTask queued for it (the
|
|
2850
|
+
// sync output already reports the outcome; an envelope on top
|
|
2851
|
+
// would be a double delivery).
|
|
2852
|
+
registry.untrack(toolCall.id);
|
|
2853
|
+
if (settled.outcome === 'ok') {
|
|
2854
|
+
this.broadcastToolResult(toolCall.id, source, settled.result);
|
|
2855
|
+
const outputForModel = await this.computeToolOutputForModel({
|
|
2856
|
+
toolCall,
|
|
2857
|
+
tool,
|
|
2858
|
+
result: {
|
|
2859
|
+
result: settled.result,
|
|
2860
|
+
},
|
|
2861
|
+
});
|
|
2862
|
+
return {
|
|
2863
|
+
output: {
|
|
2864
|
+
type: 'function_call_output',
|
|
2865
|
+
id: `output_${toolCall.id}`,
|
|
2866
|
+
callId: toolCall.id,
|
|
2867
|
+
output: outputForModel,
|
|
2868
|
+
},
|
|
2869
|
+
};
|
|
2870
|
+
}
|
|
2871
|
+
const message = settled.error instanceof Error ? settled.error.message : String(settled.error);
|
|
2872
|
+
this.broadcastToolResult(toolCall.id, source, {
|
|
2873
|
+
error: message,
|
|
2874
|
+
});
|
|
2875
|
+
return {
|
|
2876
|
+
output: {
|
|
2877
|
+
type: 'function_call_output',
|
|
2878
|
+
id: `output_${toolCall.id}`,
|
|
2879
|
+
callId: toolCall.id,
|
|
2880
|
+
output: JSON.stringify({
|
|
2881
|
+
error: message,
|
|
2882
|
+
}),
|
|
2883
|
+
},
|
|
2884
|
+
};
|
|
2885
|
+
}
|
|
2886
|
+
}
|
|
2887
|
+
// Outlived the grace window: placeholder now, deliver later. The
|
|
2888
|
+
// registry owns the timeout from here (the per-call deadline raced in
|
|
2889
|
+
// executeSingleToolCall only bounds the ROUND's wait; a background task
|
|
2890
|
+
// gets its full budget).
|
|
2891
|
+
registry.trackBackground(liveTask, work, value.timeoutMs !== undefined
|
|
2892
|
+
? {
|
|
2893
|
+
timeoutMs: value.timeoutMs,
|
|
2894
|
+
}
|
|
2895
|
+
: undefined);
|
|
2896
|
+
this.broadcastAsyncStarted(toolCall, liveTask.mode, taskId, invocation.ack);
|
|
2897
|
+
return {
|
|
2898
|
+
output: this.buildPendingPlaceholder(toolCall, taskId, invocation.ack),
|
|
2899
|
+
};
|
|
2900
|
+
}
|
|
2901
|
+
/**
|
|
2902
|
+
* Reject a `ctx.defer()` taskId already claimed by ANOTHER pending task.
|
|
2903
|
+
* Task ids are the resolution key (resumeToolResults matches on them), so
|
|
2904
|
+
* a duplicate would make one of the two calls unresolvable — the
|
|
2905
|
+
* conversation would wait forever. Caller-supplied ids
|
|
2906
|
+
* (`ctx.defer(ticket.id)`) carry no uniqueness guarantee; reject the
|
|
2907
|
+
* collision at start time, when the tool can still see it.
|
|
2908
|
+
* @returns the error output for the colliding call, or null when unique.
|
|
2909
|
+
*/
|
|
2910
|
+
rejectDuplicateTaskId(toolCall, taskId, source) {
|
|
2911
|
+
const duplicate = this.asyncToolRegistry?.getTask(taskId) ??
|
|
2912
|
+
this.currentState?.pendingAsyncTools?.find((t) => t.taskId === taskId);
|
|
2913
|
+
if (!duplicate || duplicate.callId === toolCall.id) {
|
|
2914
|
+
return null;
|
|
2915
|
+
}
|
|
2916
|
+
const message = `Tool "${toolCall.name}": ctx.defer() taskId "${taskId}" is already in use by another pending task in this conversation (call ${duplicate.callId}). Task ids must be unique per conversation — include a per-call component (e.g. the ticket id plus your callId).`;
|
|
2917
|
+
this.broadcastToolResult(toolCall.id, source, {
|
|
2918
|
+
error: message,
|
|
2919
|
+
});
|
|
2920
|
+
return {
|
|
2921
|
+
output: {
|
|
2922
|
+
type: 'function_call_output',
|
|
2923
|
+
id: `output_${toolCall.id}`,
|
|
2924
|
+
callId: toolCall.id,
|
|
2925
|
+
output: JSON.stringify({
|
|
2926
|
+
error: message,
|
|
2927
|
+
}),
|
|
2928
|
+
},
|
|
2929
|
+
};
|
|
2930
|
+
}
|
|
2931
|
+
/**
|
|
2932
|
+
* A mutable binding between a tool call's run context and its (created
|
|
2933
|
+
* later) ToolTask. `ctx.log()` entries before the task exists are
|
|
2934
|
+
* buffered; `ctx.onMessage()` registrations are forwarded on bind.
|
|
2935
|
+
*/
|
|
2936
|
+
createRunBinding(_callId) {
|
|
2937
|
+
let task = null;
|
|
2938
|
+
const bufferedLogs = [];
|
|
2939
|
+
let pendingHandler = null;
|
|
2940
|
+
let pendingTranscriptSource = null;
|
|
2941
|
+
return {
|
|
2942
|
+
log: (entry) => {
|
|
2943
|
+
if (task) {
|
|
2944
|
+
task.appendLog(entry, typeof entry === 'string' ? 'text' : 'event');
|
|
2945
|
+
}
|
|
2946
|
+
else {
|
|
2947
|
+
bufferedLogs.push(entry);
|
|
2948
|
+
}
|
|
2949
|
+
},
|
|
2950
|
+
onMessage: (handler) => {
|
|
2951
|
+
if (task) {
|
|
2952
|
+
task.onMessage(handler);
|
|
2953
|
+
}
|
|
2954
|
+
else {
|
|
2955
|
+
pendingHandler = handler;
|
|
2956
|
+
}
|
|
2957
|
+
},
|
|
2958
|
+
setTranscriptSource: (source) => {
|
|
2959
|
+
if (task) {
|
|
2960
|
+
task.transcriptSource = source;
|
|
2961
|
+
}
|
|
2962
|
+
else {
|
|
2963
|
+
pendingTranscriptSource = source;
|
|
2964
|
+
}
|
|
2965
|
+
},
|
|
2966
|
+
bind: (bound) => {
|
|
2967
|
+
task = bound;
|
|
2968
|
+
for (const entry of bufferedLogs) {
|
|
2969
|
+
bound.appendLog(entry, typeof entry === 'string' ? 'text' : 'event');
|
|
2970
|
+
}
|
|
2971
|
+
bufferedLogs.length = 0;
|
|
2972
|
+
if (pendingHandler) {
|
|
2973
|
+
bound.onMessage(pendingHandler);
|
|
2974
|
+
pendingHandler = null;
|
|
2975
|
+
}
|
|
2976
|
+
if (pendingTranscriptSource) {
|
|
2977
|
+
bound.transcriptSource = pendingTranscriptSource;
|
|
2978
|
+
pendingTranscriptSource = null;
|
|
2979
|
+
}
|
|
2980
|
+
},
|
|
2981
|
+
task: () => task,
|
|
2982
|
+
};
|
|
2983
|
+
}
|
|
2984
|
+
/**
|
|
2985
|
+
* Run a background tool's work under the tool's own concurrency gate AND
|
|
2986
|
+
* the background pool. The round path does not touch the per-tool gate
|
|
2987
|
+
* for background lifecycles (the thunk had not run yet), so the gate is
|
|
2988
|
+
* acquired here for the body's lifetime — `maxConcurrency` bounds
|
|
2989
|
+
* executions of the BODY, wherever they run.
|
|
2990
|
+
*
|
|
2991
|
+
* Acquisition order: per-tool gate FIRST, then the pool. The narrow gate
|
|
2992
|
+
* is where a saturated tool's excess calls queue — queuing them on the
|
|
2993
|
+
* shared pool instead would let 16 waiters on one maxConcurrency:2 tool
|
|
2994
|
+
* hold every global slot while only two bodies run, starving every other
|
|
2995
|
+
* background tool. Order is deadlock-safe: only background bodies take
|
|
2996
|
+
* the per-tool gate (the round path skips it for them), and every body
|
|
2997
|
+
* acquires in this same fixed order.
|
|
2998
|
+
*
|
|
2999
|
+
* Queue wait counts against the task's own timeout (tracked by the
|
|
3000
|
+
* registry); an abort observed before the slots free up short-circuits
|
|
3001
|
+
* without running the body.
|
|
3002
|
+
*/
|
|
3003
|
+
async runBackgroundWork(toolName, run, controller) {
|
|
3004
|
+
const gate = this.perToolGate(toolName);
|
|
3005
|
+
const releaseGate = gate ? await gate.acquire() : undefined;
|
|
3006
|
+
const pool = this.backgroundPool;
|
|
3007
|
+
const releasePool = pool ? await pool.acquire() : undefined;
|
|
3008
|
+
try {
|
|
3009
|
+
if (controller.signal.aborted) {
|
|
3010
|
+
throw controller.signal.reason instanceof Error
|
|
3011
|
+
? controller.signal.reason
|
|
3012
|
+
: new Error(String(controller.signal.reason ?? 'aborted'));
|
|
3013
|
+
}
|
|
3014
|
+
return await run();
|
|
3015
|
+
}
|
|
3016
|
+
finally {
|
|
3017
|
+
releasePool?.();
|
|
3018
|
+
releaseGate?.();
|
|
3019
|
+
}
|
|
3020
|
+
}
|
|
3021
|
+
/** Broadcast a `tool.async_started` event on the turn broadcaster. */
|
|
3022
|
+
broadcastAsyncStarted(toolCall, mode, taskId, ack) {
|
|
3023
|
+
this.turnBroadcaster?.push({
|
|
3024
|
+
type: 'tool.async_started',
|
|
3025
|
+
toolCallId: toolCall.id,
|
|
3026
|
+
toolName: String(toolCall.name),
|
|
3027
|
+
taskId,
|
|
3028
|
+
mode,
|
|
3029
|
+
...(ack !== undefined && {
|
|
3030
|
+
ack,
|
|
3031
|
+
}),
|
|
3032
|
+
timestamp: Date.now(),
|
|
3033
|
+
});
|
|
3034
|
+
}
|
|
3035
|
+
/** Broadcast a `tool.async_settled` event on the turn broadcaster. */
|
|
3036
|
+
broadcastAsyncSettled(settled, delivery) {
|
|
3037
|
+
this.turnBroadcaster?.push({
|
|
3038
|
+
type: 'tool.async_settled',
|
|
3039
|
+
toolCallId: settled.callId,
|
|
3040
|
+
taskId: settled.taskId,
|
|
3041
|
+
status: settled.status,
|
|
3042
|
+
...(settled.result !== undefined && {
|
|
3043
|
+
result: settled.result,
|
|
3044
|
+
}),
|
|
3045
|
+
...(settled.error !== undefined && {
|
|
3046
|
+
error: settled.error,
|
|
3047
|
+
}),
|
|
3048
|
+
delivery,
|
|
3049
|
+
timestamp: Date.now(),
|
|
3050
|
+
});
|
|
3051
|
+
}
|
|
3052
|
+
/**
|
|
3053
|
+
* Build the pending placeholder `function_call_output` for an async tool
|
|
3054
|
+
* call. Pairs the `function_call` immediately (providers 400 on unpaired
|
|
3055
|
+
* calls in follow-up history); the real result arrives later as a
|
|
3056
|
+
* `tool_task_result` envelope. The note steers the model: with check-ins
|
|
3057
|
+
* enabled it points at the universal `task` tool; with check-ins
|
|
3058
|
+
* disabled it forbids re-calling entirely.
|
|
3059
|
+
*/
|
|
3060
|
+
buildPendingPlaceholder(toolCall, taskId, ack, pollAfterMs) {
|
|
3061
|
+
const checkinsEnabled = this.options.asyncTools?.checkins !== false;
|
|
3062
|
+
const payload = {
|
|
3063
|
+
status: 'pending',
|
|
3064
|
+
taskId,
|
|
3065
|
+
...(pollAfterMs !== undefined && {
|
|
3066
|
+
pollAfterMs,
|
|
3067
|
+
}),
|
|
3068
|
+
...(checkinsEnabled && {
|
|
3069
|
+
check: true,
|
|
3070
|
+
}),
|
|
3071
|
+
};
|
|
3072
|
+
const directive = checkinsEnabled
|
|
3073
|
+
? `The result will be delivered to you automatically when ready. To check progress, steer, or cancel, call the task tool with { "taskId": "${taskId}" }. Keep working on other steps meanwhile.`
|
|
3074
|
+
: 'The result will be delivered to you automatically when ready — do not call this tool again to check on it.';
|
|
3075
|
+
if (typeof ack === 'string') {
|
|
3076
|
+
payload['note'] = `${ack} ${directive}`;
|
|
3077
|
+
}
|
|
3078
|
+
else {
|
|
3079
|
+
if (ack !== undefined && typeof ack === 'object') {
|
|
3080
|
+
Object.assign(payload, ack);
|
|
3081
|
+
}
|
|
3082
|
+
payload['note'] = `Started. ${directive}`;
|
|
3083
|
+
}
|
|
3084
|
+
return {
|
|
3085
|
+
type: 'function_call_output',
|
|
3086
|
+
id: `output_${toolCall.id}`,
|
|
3087
|
+
callId: toolCall.id,
|
|
3088
|
+
output: JSON.stringify(payload),
|
|
3089
|
+
};
|
|
3090
|
+
}
|
|
3091
|
+
/**
|
|
3092
|
+
* True when the built-in task tool is active for this run. Mirrors
|
|
3093
|
+
* `needsTaskTool` exactly: when a user tool claims the reserved name the
|
|
3094
|
+
* built-in is not registered, so calls named "task" must NOT be
|
|
3095
|
+
* intercepted — they belong to the user's tool.
|
|
3096
|
+
*/
|
|
3097
|
+
taskToolActive() {
|
|
3098
|
+
if (this.options.asyncTools?.checkins === false) {
|
|
3099
|
+
return false;
|
|
3100
|
+
}
|
|
3101
|
+
const tools = this.options.tools ?? [];
|
|
3102
|
+
if (hasTaskToolNameCollision(tools)) {
|
|
3103
|
+
return false;
|
|
3104
|
+
}
|
|
3105
|
+
return tools.some((t) => isLongRunningTool(t));
|
|
3106
|
+
}
|
|
3107
|
+
/**
|
|
3108
|
+
* Answer a call to the universal `task` tool: resolve the taskId to its
|
|
3109
|
+
* live registry task (or persisted pendingAsyncTools entry, post-restart)
|
|
3110
|
+
* and its OWNING tool, then dispatch by action:
|
|
3111
|
+
*
|
|
3112
|
+
* - `check` (default): the owning tool's `check.execute` when declared
|
|
3113
|
+
* (custom `params` validated against `check.schema`), else the SDK
|
|
3114
|
+
* default status / logs / transcript views.
|
|
3115
|
+
* - `steer`: deliver `message` to the run body's inbox.
|
|
3116
|
+
* - `result`: the final result if settled, else the status view.
|
|
3117
|
+
* - `cancel`: cancel the task.
|
|
3118
|
+
*/
|
|
3119
|
+
async answerTaskToolCall(toolCall) {
|
|
3120
|
+
const taskTool = buildTaskToolStub();
|
|
3121
|
+
const answer = (result, error) => {
|
|
3122
|
+
if (error === undefined) {
|
|
3123
|
+
this.broadcastToolResult(toolCall.id, 'client', result);
|
|
3124
|
+
}
|
|
3125
|
+
return {
|
|
3126
|
+
type: 'execution',
|
|
3127
|
+
toolCall,
|
|
3128
|
+
tool: taskTool,
|
|
3129
|
+
result: error
|
|
3130
|
+
? {
|
|
3131
|
+
result: null,
|
|
3132
|
+
error,
|
|
3133
|
+
}
|
|
3134
|
+
: {
|
|
3135
|
+
result,
|
|
3136
|
+
},
|
|
3137
|
+
preliminaryResultsForCall: [],
|
|
3138
|
+
};
|
|
3139
|
+
};
|
|
3140
|
+
let input;
|
|
3141
|
+
try {
|
|
3142
|
+
input = validateToolInput(TaskToolInputSchema, toolCall.arguments ?? {});
|
|
3143
|
+
}
|
|
3144
|
+
catch (error) {
|
|
3145
|
+
return answer(null, error instanceof Error ? error : new Error(String(error)));
|
|
3146
|
+
}
|
|
3147
|
+
const liveTask = this.asyncToolRegistry?.getTask(input.taskId);
|
|
3148
|
+
const persisted = this.currentState?.pendingAsyncTools?.find((t) => t.taskId === input.taskId);
|
|
3149
|
+
if (!liveTask && !persisted) {
|
|
3150
|
+
return answer({
|
|
3151
|
+
error: 'unknown_task',
|
|
3152
|
+
taskId: input.taskId,
|
|
3153
|
+
hint: 'No task with this id exists in this conversation. It may belong to another conversation, or its record was dropped.',
|
|
3154
|
+
});
|
|
3155
|
+
}
|
|
3156
|
+
try {
|
|
3157
|
+
switch (input.action ?? 'check') {
|
|
3158
|
+
case 'cancel':
|
|
3159
|
+
return answer(this.taskToolCancel(input, liveTask));
|
|
3160
|
+
case 'steer':
|
|
3161
|
+
return answer(...this.taskToolSteer(input, liveTask));
|
|
3162
|
+
case 'result': {
|
|
3163
|
+
const settled = taskToolResultIfSettled(input, liveTask);
|
|
3164
|
+
if (settled !== null) {
|
|
3165
|
+
return answer(settled);
|
|
3166
|
+
}
|
|
3167
|
+
// Not settled: fall through to the status view.
|
|
3168
|
+
return answer(await this.taskToolCheck(input, liveTask, persisted));
|
|
3169
|
+
}
|
|
3170
|
+
default:
|
|
3171
|
+
return answer(await this.taskToolCheck(input, liveTask, persisted));
|
|
3172
|
+
}
|
|
3173
|
+
}
|
|
3174
|
+
catch (error) {
|
|
3175
|
+
return answer(null, error instanceof Error ? error : new Error(String(error)));
|
|
3176
|
+
}
|
|
3177
|
+
}
|
|
3178
|
+
/** `task` tool, action=cancel. */
|
|
3179
|
+
taskToolCancel(input, liveTask) {
|
|
3180
|
+
const cancelled = liveTask
|
|
3181
|
+
? (this.asyncToolRegistry?.cancelTask(input.taskId, input.reason) ?? false)
|
|
3182
|
+
: false;
|
|
3183
|
+
return cancelled
|
|
3184
|
+
? {
|
|
3185
|
+
taskId: input.taskId,
|
|
3186
|
+
status: 'cancelled',
|
|
3187
|
+
}
|
|
3188
|
+
: {
|
|
3189
|
+
taskId: input.taskId,
|
|
3190
|
+
error: 'not_cancellable',
|
|
3191
|
+
hint: liveTask
|
|
3192
|
+
? 'The task has already settled.'
|
|
3193
|
+
: 'This task is owned by an external system — cancel it there (or via the tool’s .cancel() method).',
|
|
1476
3194
|
};
|
|
1477
|
-
|
|
1478
|
-
|
|
1479
|
-
|
|
1480
|
-
|
|
1481
|
-
|
|
1482
|
-
|
|
3195
|
+
}
|
|
3196
|
+
/** `task` tool, action=steer. Returns [result, error?] for `answer`. */
|
|
3197
|
+
taskToolSteer(input, liveTask) {
|
|
3198
|
+
if (typeof input.message !== 'string' || input.message.length === 0) {
|
|
3199
|
+
return [
|
|
3200
|
+
null,
|
|
3201
|
+
new Error("action 'steer' requires a non-empty `message`"),
|
|
3202
|
+
];
|
|
3203
|
+
}
|
|
3204
|
+
if (!liveTask || liveTask.mode === 'defer') {
|
|
3205
|
+
return [
|
|
3206
|
+
{
|
|
3207
|
+
taskId: input.taskId,
|
|
3208
|
+
error: 'not_steerable',
|
|
3209
|
+
hint: 'This task runs in an external system — steer it there.',
|
|
3210
|
+
},
|
|
3211
|
+
];
|
|
3212
|
+
}
|
|
3213
|
+
liveTask.send(input.message);
|
|
3214
|
+
return [
|
|
3215
|
+
{
|
|
3216
|
+
taskId: input.taskId,
|
|
3217
|
+
steered: true,
|
|
3218
|
+
},
|
|
3219
|
+
];
|
|
3220
|
+
}
|
|
3221
|
+
/**
|
|
3222
|
+
* `task` tool, action=check (and the status-view fallback for `result`
|
|
3223
|
+
* on an unsettled task): the owning tool's `check.execute` when
|
|
3224
|
+
* declared, else the SDK default views — live-task-backed when the task
|
|
3225
|
+
* is in this process, persisted-state-backed post-restart.
|
|
3226
|
+
*/
|
|
3227
|
+
async taskToolCheck(input, liveTask, persisted) {
|
|
3228
|
+
const owningToolName = liveTask?.toolName ?? persisted?.name ?? '';
|
|
3229
|
+
const owningTool = this.options.tools?.find((t) => isClientTool(t) && t.function.name === owningToolName);
|
|
3230
|
+
const config = owningTool && isUnifiedTool(owningTool) ? owningTool.function.check : undefined;
|
|
3231
|
+
const { schema, execute } = resolveCheckConfig(config);
|
|
3232
|
+
const maxTranscriptChars = this.options.asyncTools?.maxTranscriptChars ?? 20000;
|
|
3233
|
+
// Custom params for the owning tool's handler are validated against
|
|
3234
|
+
// its check.schema when both are present.
|
|
3235
|
+
let customParams = input.params ?? {};
|
|
3236
|
+
if (schema && input.params !== undefined) {
|
|
3237
|
+
customParams = validateToolInput(schema, input.params);
|
|
3238
|
+
}
|
|
3239
|
+
if (liveTask) {
|
|
3240
|
+
const handle = this.buildTaskHandle(liveTask, maxTranscriptChars);
|
|
3241
|
+
const checkTurnContext = {
|
|
3242
|
+
numberOfTurns: this.allToolExecutionRounds.length + 1,
|
|
3243
|
+
toolCallStatus: liveTask.status,
|
|
3244
|
+
accumulatedYieldedEvents: liveTask.accumulatedYieldedEvents,
|
|
3245
|
+
task: handle,
|
|
3246
|
+
};
|
|
3247
|
+
return execute
|
|
3248
|
+
? await Promise.resolve(execute(customParams, checkTurnContext))
|
|
3249
|
+
: defaultCheckResult({
|
|
3250
|
+
view: input.view,
|
|
3251
|
+
tail: input.tail,
|
|
3252
|
+
}, checkTurnContext, {
|
|
3253
|
+
maxTranscriptChars,
|
|
3254
|
+
});
|
|
1483
3255
|
}
|
|
3256
|
+
// Cross-process / post-restart: only persisted status survives. A
|
|
3257
|
+
// custom check.execute still runs, with a state-backed context (no
|
|
3258
|
+
// live task handle — `turnContext.task` is absent here). Handlers
|
|
3259
|
+
// written against `task.statusView()` with optional chaining return
|
|
3260
|
+
// undefined in this situation; fall back to the persisted status view
|
|
3261
|
+
// rather than answering the model with nothing.
|
|
3262
|
+
const checkTurnContext = {
|
|
3263
|
+
numberOfTurns: this.allToolExecutionRounds.length + 1,
|
|
3264
|
+
toolCallStatus: persisted.status,
|
|
3265
|
+
accumulatedYieldedEvents: persisted.lastLog
|
|
3266
|
+
? [
|
|
3267
|
+
persisted.lastLog.text,
|
|
3268
|
+
]
|
|
3269
|
+
: [],
|
|
3270
|
+
};
|
|
3271
|
+
const customResult = execute
|
|
3272
|
+
? await Promise.resolve(execute(customParams, checkTurnContext))
|
|
3273
|
+
: undefined;
|
|
3274
|
+
return (customResult ??
|
|
3275
|
+
persistedTaskCheckResult({
|
|
3276
|
+
view: input.view,
|
|
3277
|
+
}, persisted));
|
|
3278
|
+
}
|
|
3279
|
+
/** Build the narrow ToolTaskHandle façade for check.execute handlers. */
|
|
3280
|
+
buildTaskHandle(task, maxTranscriptChars) {
|
|
3281
|
+
const registry = this.asyncToolRegistry;
|
|
1484
3282
|
return {
|
|
1485
|
-
|
|
1486
|
-
|
|
3283
|
+
taskId: task.taskId,
|
|
3284
|
+
toolName: task.toolName,
|
|
3285
|
+
mode: task.mode,
|
|
3286
|
+
status: () => task.status,
|
|
3287
|
+
statusView: () => task.toStatusView(),
|
|
3288
|
+
tailLogs: (n) => task.tailLogs(n),
|
|
3289
|
+
transcript: (maxChars) => task.renderTranscript(maxChars ?? maxTranscriptChars),
|
|
3290
|
+
send: (message) => task.send(message),
|
|
3291
|
+
cancel: (reason) => registry?.cancelTask(task.taskId, reason) ?? false,
|
|
1487
3292
|
};
|
|
1488
3293
|
}
|
|
1489
3294
|
/**
|
|
@@ -1494,7 +3299,7 @@ export class ModelResult {
|
|
|
1494
3299
|
*/
|
|
1495
3300
|
async resolveAsyncFunctionsForTurn(turnContext) {
|
|
1496
3301
|
if (hasAsyncFunctions(this.options.request)) {
|
|
1497
|
-
const resolved = await
|
|
3302
|
+
const resolved = await this.resolveRequestForContext(turnContext);
|
|
1498
3303
|
// Preserve accumulated input from previous turns
|
|
1499
3304
|
const preservedInput = this.resolvedRequest?.input;
|
|
1500
3305
|
const preservedStream = this.resolvedRequest?.stream;
|
|
@@ -1550,26 +3355,231 @@ export class ModelResult {
|
|
|
1550
3355
|
if (!this.resolvedRequest) {
|
|
1551
3356
|
throw new Error('Request not initialized');
|
|
1552
3357
|
}
|
|
1553
|
-
// Update resolvedRequest.input with accumulated conversation for next turn
|
|
3358
|
+
// Update resolvedRequest.input with accumulated conversation for next turn.
|
|
1554
3359
|
this.resolvedRequest = {
|
|
1555
3360
|
...this.resolvedRequest,
|
|
1556
3361
|
input: newInput,
|
|
1557
3362
|
};
|
|
3363
|
+
// A forced tool choice (`required` or a specific tool) has served its
|
|
3364
|
+
// purpose once a tool round has executed; keeping it on follow-up turns
|
|
3365
|
+
// would forbid the model from ever answering in text, looping it through
|
|
3366
|
+
// tool calls until the step budget runs out (DEV-785). Relax it to `auto`
|
|
3367
|
+
// so the model can either call another tool or synthesize its answer.
|
|
3368
|
+
this.resolvedRequest = this.applyForcedToolChoicePolicy(this.resolvedRequest);
|
|
3369
|
+
// Escalation recovery: one-turn overrides (model swap / forced advisor
|
|
3370
|
+
// consult) applied to THIS dispatch only — `resolvedRequest` above keeps
|
|
3371
|
+
// the configured model/tools, so the next turn reverts automatically.
|
|
3372
|
+
const escalationOverrides = this.takeDoomLoopEscalationOverrides();
|
|
1558
3373
|
const newRequest = {
|
|
1559
3374
|
...this.resolvedRequest,
|
|
3375
|
+
...(escalationOverrides ?? {}),
|
|
1560
3376
|
stream: true,
|
|
1561
3377
|
};
|
|
3378
|
+
this.beginToolChoiceDispatch(newRequest.toolChoice);
|
|
1562
3379
|
const startedAt = performance.now();
|
|
1563
3380
|
const newResult = await betaResponsesSend(this.options.client, {
|
|
1564
3381
|
responsesRequest: newRequest,
|
|
1565
|
-
}, this.
|
|
3382
|
+
}, this.dispatchRequestOptions());
|
|
1566
3383
|
if (!newResult.ok) {
|
|
1567
3384
|
throw newResult.error;
|
|
1568
3385
|
}
|
|
1569
3386
|
const response = await this.materializeResponse(newResult.value, turnNumber);
|
|
3387
|
+
this.commitToolChoiceDispatch(responseHasToolCalls(response));
|
|
1570
3388
|
await this.emitPostModelCall(response, startedAt, 'tool_round', turnNumber);
|
|
1571
3389
|
return response;
|
|
1572
3390
|
}
|
|
3391
|
+
/**
|
|
3392
|
+
* Append a response's output items to the accumulated request input,
|
|
3393
|
+
* exactly as `makeFollowupRequest` does before adding tool results. Used
|
|
3394
|
+
* by the end-of-run drain, where envelope messages must land AFTER the
|
|
3395
|
+
* final assistant output in the request.
|
|
3396
|
+
*/
|
|
3397
|
+
accumulateResponseIntoInput(response) {
|
|
3398
|
+
if (!this.resolvedRequest) {
|
|
3399
|
+
return;
|
|
3400
|
+
}
|
|
3401
|
+
const originalInput = this.resolvedRequest.input;
|
|
3402
|
+
const normalizedOriginalInput = Array.isArray(originalInput)
|
|
3403
|
+
? originalInput
|
|
3404
|
+
: originalInput
|
|
3405
|
+
? [
|
|
3406
|
+
{
|
|
3407
|
+
role: 'user',
|
|
3408
|
+
content: originalInput,
|
|
3409
|
+
},
|
|
3410
|
+
]
|
|
3411
|
+
: [];
|
|
3412
|
+
this.resolvedRequest = {
|
|
3413
|
+
...this.resolvedRequest,
|
|
3414
|
+
input: [
|
|
3415
|
+
...normalizedOriginalInput,
|
|
3416
|
+
...(Array.isArray(response.output)
|
|
3417
|
+
? response.output
|
|
3418
|
+
: [
|
|
3419
|
+
response.output,
|
|
3420
|
+
]),
|
|
3421
|
+
],
|
|
3422
|
+
};
|
|
3423
|
+
}
|
|
3424
|
+
/**
|
|
3425
|
+
* End-of-run handling for async background tasks still in flight (or
|
|
3426
|
+
* settled but unharvested) when the loop is about to finalize.
|
|
3427
|
+
*
|
|
3428
|
+
* - `'drain'` (default): wait for tasks (bounded by `drainTimeoutMs`),
|
|
3429
|
+
* inject their results, and give the model up to `maxDrainTurns` extra
|
|
3430
|
+
* `toolChoice: 'none'` turns so the final answer incorporates them.
|
|
3431
|
+
* - `'detach'`: return immediately; still-working tasks are marked
|
|
3432
|
+
* `orphaned` on persisted state and their eventual results are dropped.
|
|
3433
|
+
* - `'cancel'`: abort in-flight tasks and finish.
|
|
3434
|
+
*
|
|
3435
|
+
* @returns the (possibly replaced) final response.
|
|
3436
|
+
*/
|
|
3437
|
+
async handleRunEndAsyncTasks(currentResponse) {
|
|
3438
|
+
const registry = this.asyncToolRegistry;
|
|
3439
|
+
if (!registry || (!registry.hasInFlight() && !registry.hasUnharvestedSettled())) {
|
|
3440
|
+
return currentResponse;
|
|
3441
|
+
}
|
|
3442
|
+
const config = this.options.asyncTools;
|
|
3443
|
+
const mode = config?.onRunEnd ?? 'drain';
|
|
3444
|
+
if (mode === 'cancel') {
|
|
3445
|
+
registry.abortAll('Run ended (onRunEnd: cancel)');
|
|
3446
|
+
await this.dropSettledTasks();
|
|
3447
|
+
return currentResponse;
|
|
3448
|
+
}
|
|
3449
|
+
if (mode === 'detach') {
|
|
3450
|
+
await this.detachWorkingTasks();
|
|
3451
|
+
return currentResponse;
|
|
3452
|
+
}
|
|
3453
|
+
// 'drain': deliver late results and let the model incorporate them.
|
|
3454
|
+
// Drain turns use the retry path (toolChoice forced to 'none' when tools
|
|
3455
|
+
// are present) so a drain turn cannot open a fresh tool round — it
|
|
3456
|
+
// exists purely to fold the delivered results into the final answer.
|
|
3457
|
+
// ONE deadline spans the whole drain phase: drainTimeoutMs bounds total
|
|
3458
|
+
// WAITING (documented contract), not per-turn waiting — maxDrainTurns
|
|
3459
|
+
// bounds the extra model turns, not the wait.
|
|
3460
|
+
const drainTimeoutMs = config?.drainTimeoutMs ?? 30000;
|
|
3461
|
+
const maxDrainTurns = config?.maxDrainTurns ?? 2;
|
|
3462
|
+
const drainDeadline = Date.now() + drainTimeoutMs;
|
|
3463
|
+
let response = currentResponse;
|
|
3464
|
+
for (let drainTurn = 0; drainTurn < maxDrainTurns; drainTurn++) {
|
|
3465
|
+
if (!registry.hasUnharvestedSettled() && registry.hasInFlight()) {
|
|
3466
|
+
const remaining = drainDeadline - Date.now();
|
|
3467
|
+
if (remaining <= 0) {
|
|
3468
|
+
break;
|
|
3469
|
+
}
|
|
3470
|
+
await registry.drain(remaining);
|
|
3471
|
+
}
|
|
3472
|
+
// Accumulate the current final output BEFORE injecting so the
|
|
3473
|
+
// envelope lands after the assistant's last message in the request.
|
|
3474
|
+
this.accumulateResponseIntoInput(response);
|
|
3475
|
+
// Queued steering (queueUserMessage / sendToTask) flushes here too —
|
|
3476
|
+
// a drain turn is a model dispatch, and guidance queued while the
|
|
3477
|
+
// last tool round was already finishing would otherwise be dropped.
|
|
3478
|
+
await this.flushDoomLoopSteer();
|
|
3479
|
+
const delivered = await this.flushAsyncToolDeliveries();
|
|
3480
|
+
if (!delivered) {
|
|
3481
|
+
// Drain timed out with nothing new settled — stop waiting.
|
|
3482
|
+
break;
|
|
3483
|
+
}
|
|
3484
|
+
const turnNumber = this.allToolExecutionRounds.length + 1 + drainTurn;
|
|
3485
|
+
response = await this.retryCurrentRequest(turnNumber);
|
|
3486
|
+
await this.saveResponseToState(response);
|
|
3487
|
+
if (!registry.hasInFlight() && !registry.hasUnharvestedSettled()) {
|
|
3488
|
+
break;
|
|
3489
|
+
}
|
|
3490
|
+
}
|
|
3491
|
+
// Drain budget exhausted with work still in flight: cut it loose.
|
|
3492
|
+
if (registry.hasInFlight()) {
|
|
3493
|
+
registry.abortAll('Async tool drain budget exhausted at run end');
|
|
3494
|
+
await this.dropSettledTasks();
|
|
3495
|
+
}
|
|
3496
|
+
return response;
|
|
3497
|
+
}
|
|
3498
|
+
/**
|
|
3499
|
+
* Broadcast every unharvested settlement as dropped (no in-run delivery),
|
|
3500
|
+
* mirror the terminal status onto persisted `pendingAsyncTools`, and
|
|
3501
|
+
* append a terminal `tool_task_result` envelope to persisted history.
|
|
3502
|
+
*
|
|
3503
|
+
* The status mirror prevents a stuck resume: an entry persisted as
|
|
3504
|
+
* `working` for a task that only ever settles in-memory (abortAll at
|
|
3505
|
+
* drain exhaustion / run cancel) would pin a later cross-process resume
|
|
3506
|
+
* to `awaiting_async_tools` forever. The envelope keeps the pending
|
|
3507
|
+
* placeholder's "the result will be delivered" promise honest for
|
|
3508
|
+
* PERSISTED conversations: the run is over (no more model dispatch), but
|
|
3509
|
+
* the terminal outcome rides along on the next `callModel({ state })`
|
|
3510
|
+
* instead of vanishing.
|
|
3511
|
+
*/
|
|
3512
|
+
async dropSettledTasks() {
|
|
3513
|
+
const registry = this.asyncToolRegistry;
|
|
3514
|
+
if (!registry) {
|
|
3515
|
+
return;
|
|
3516
|
+
}
|
|
3517
|
+
const settled = registry.takeSettled();
|
|
3518
|
+
for (const task of settled) {
|
|
3519
|
+
this.broadcastAsyncSettled(task, 'dropped');
|
|
3520
|
+
}
|
|
3521
|
+
if (settled.length === 0 || !this.stateAccessor || !this.currentState) {
|
|
3522
|
+
return;
|
|
3523
|
+
}
|
|
3524
|
+
const terminalByCallId = new Map(settled.map((t) => [
|
|
3525
|
+
t.callId,
|
|
3526
|
+
t.status === 'completed' || t.status === 'cancelled' ? t.status : 'failed',
|
|
3527
|
+
]));
|
|
3528
|
+
const envelopes = settled.map((t) => JSON.stringify({
|
|
3529
|
+
type: 'tool_task_result',
|
|
3530
|
+
tool: t.name,
|
|
3531
|
+
taskId: t.taskId,
|
|
3532
|
+
callId: t.callId,
|
|
3533
|
+
status: t.status,
|
|
3534
|
+
...(t.result !== undefined && {
|
|
3535
|
+
result: t.result,
|
|
3536
|
+
}),
|
|
3537
|
+
...(t.error !== undefined && {
|
|
3538
|
+
error: t.error,
|
|
3539
|
+
}),
|
|
3540
|
+
}));
|
|
3541
|
+
await this.saveStateSafely({
|
|
3542
|
+
messages: appendToMessages(this.currentState.messages, [
|
|
3543
|
+
{
|
|
3544
|
+
role: 'user',
|
|
3545
|
+
content: `${TASK_RESULT_BOUNDARY}\n${envelopes.join('\n')}`,
|
|
3546
|
+
},
|
|
3547
|
+
]),
|
|
3548
|
+
...(this.currentState.pendingAsyncTools?.length && {
|
|
3549
|
+
pendingAsyncTools: this.currentState.pendingAsyncTools.map((entry) => {
|
|
3550
|
+
const terminal = terminalByCallId.get(entry.callId);
|
|
3551
|
+
return terminal !== undefined
|
|
3552
|
+
? {
|
|
3553
|
+
...entry,
|
|
3554
|
+
status: terminal,
|
|
3555
|
+
}
|
|
3556
|
+
: entry;
|
|
3557
|
+
}),
|
|
3558
|
+
}),
|
|
3559
|
+
});
|
|
3560
|
+
}
|
|
3561
|
+
/**
|
|
3562
|
+
* `onRunEnd: 'detach'`: mark still-working tasks orphaned on persisted
|
|
3563
|
+
* state (their eventual results are dropped) and drop unharvested
|
|
3564
|
+
* settlements.
|
|
3565
|
+
*/
|
|
3566
|
+
async detachWorkingTasks() {
|
|
3567
|
+
const registry = this.asyncToolRegistry;
|
|
3568
|
+
if (!registry) {
|
|
3569
|
+
return;
|
|
3570
|
+
}
|
|
3571
|
+
const orphaned = registry.markWorkingAsOrphaned();
|
|
3572
|
+
await this.dropSettledTasks();
|
|
3573
|
+
if (orphaned.length > 0 && this.stateAccessor && this.currentState) {
|
|
3574
|
+
const existing = (this.currentState.pendingAsyncTools ?? []).filter((t) => !orphaned.some((o) => o.callId === t.callId));
|
|
3575
|
+
await this.saveStateSafely({
|
|
3576
|
+
pendingAsyncTools: [
|
|
3577
|
+
...existing,
|
|
3578
|
+
...orphaned,
|
|
3579
|
+
],
|
|
3580
|
+
});
|
|
3581
|
+
}
|
|
3582
|
+
}
|
|
1573
3583
|
/**
|
|
1574
3584
|
* Make a final no-tool-calls request to coerce a text response after the
|
|
1575
3585
|
* loop was halted by `stopWhen` mid-tool-call. Reuses the resolved request
|
|
@@ -1632,7 +3642,7 @@ export class ModelResult {
|
|
|
1632
3642
|
const startedAt = performance.now();
|
|
1633
3643
|
const result = await betaResponsesSend(this.options.client, {
|
|
1634
3644
|
responsesRequest: finalRequest,
|
|
1635
|
-
}, this.
|
|
3645
|
+
}, this.dispatchRequestOptions());
|
|
1636
3646
|
if (!result.ok) {
|
|
1637
3647
|
throw result.error;
|
|
1638
3648
|
}
|
|
@@ -1643,19 +3653,40 @@ export class ModelResult {
|
|
|
1643
3653
|
/**
|
|
1644
3654
|
* Validate the final response has required fields.
|
|
1645
3655
|
*
|
|
3656
|
+
* Message prefixes (`Invalid final response: missing required fields`,
|
|
3657
|
+
* `Invalid final response: empty or invalid output`) are load-bearing —
|
|
3658
|
+
* callers and tests match on them. Detail is appended after an em dash;
|
|
3659
|
+
* never reword the prefixes.
|
|
3660
|
+
*
|
|
3661
|
+
* The detail exists because the bare "empty or invalid output" message was
|
|
3662
|
+
* routinely misread as "the model returned a tool call and validation
|
|
3663
|
+
* rejected it" (issue #45). It does not: this is a pure array-length check,
|
|
3664
|
+
* so a `function_call`-only output passes. Naming the actual defect —
|
|
3665
|
+
* empty array vs. wrong type — stops that misdiagnosis at the error string.
|
|
3666
|
+
*
|
|
1646
3667
|
* @param response - The response to validate
|
|
1647
3668
|
* @param allowEmptyOutput - When true, tolerate an empty (but present) output array
|
|
1648
3669
|
* @throws Error if response is missing required fields or has invalid output
|
|
1649
3670
|
*/
|
|
1650
3671
|
validateFinalResponse(response, allowEmptyOutput = false) {
|
|
1651
3672
|
if (!response?.id || !response?.output) {
|
|
1652
|
-
|
|
3673
|
+
const missing = [];
|
|
3674
|
+
if (!response?.id) {
|
|
3675
|
+
missing.push('id');
|
|
3676
|
+
}
|
|
3677
|
+
if (!response?.output) {
|
|
3678
|
+
missing.push('output');
|
|
3679
|
+
}
|
|
3680
|
+
throw new Error(`Invalid final response: missing required fields: ${missing.join(', ')}`);
|
|
1653
3681
|
}
|
|
1654
3682
|
if (!Array.isArray(response.output) || response.output.length === 0) {
|
|
1655
3683
|
if (allowEmptyOutput) {
|
|
1656
3684
|
return;
|
|
1657
3685
|
}
|
|
1658
|
-
|
|
3686
|
+
const detail = Array.isArray(response.output)
|
|
3687
|
+
? `output array is empty (length 0) for response "${response.id}". The model returned no output items. This can happen when the provider returns an empty final turn; see the strictFinalResponse/allowFinalResponse options`
|
|
3688
|
+
: `output is not an array (got ${describeNonRecord(response.output)}) for response "${response.id}"`;
|
|
3689
|
+
throw new Error(`Invalid final response: empty or invalid output — ${detail}`);
|
|
1659
3690
|
}
|
|
1660
3691
|
}
|
|
1661
3692
|
/**
|
|
@@ -1684,7 +3715,7 @@ export class ModelResult {
|
|
|
1684
3715
|
const startedAt = performance.now();
|
|
1685
3716
|
const newResult = await betaResponsesSend(this.options.client, {
|
|
1686
3717
|
responsesRequest: newRequest,
|
|
1687
|
-
}, this.
|
|
3718
|
+
}, this.dispatchRequestOptions());
|
|
1688
3719
|
if (!newResult.ok) {
|
|
1689
3720
|
throw newResult.error;
|
|
1690
3721
|
}
|
|
@@ -1692,6 +3723,47 @@ export class ModelResult {
|
|
|
1692
3723
|
await this.emitPostModelCall(response, startedAt, 'retry', turnNumber);
|
|
1693
3724
|
return response;
|
|
1694
3725
|
}
|
|
3726
|
+
/**
|
|
3727
|
+
* Build the RequestOptions for one API dispatch, composing the run-level
|
|
3728
|
+
* cancellation signal (`options.signal`) with the caller's RequestOptions.
|
|
3729
|
+
*
|
|
3730
|
+
* The SDK only auto-wires `timeoutMs` into an abort signal when NO signal
|
|
3731
|
+
* is present on the options — a supplied signal silently disables the
|
|
3732
|
+
* timeout. Composing here keeps both: each request is bounded by
|
|
3733
|
+
* whichever of {run signal, caller signal, per-request timeout} fires
|
|
3734
|
+
* first. MUST be called once per dispatch — `AbortSignal.timeout()`
|
|
3735
|
+
* starts counting at creation, so a cached composite would burn its
|
|
3736
|
+
* budget across turns instead of per request.
|
|
3737
|
+
*
|
|
3738
|
+
* Also the per-request fail-fast checkpoint: an already-aborted run
|
|
3739
|
+
* signal throws its abort reason here, before any network I/O.
|
|
3740
|
+
*/
|
|
3741
|
+
dispatchRequestOptions() {
|
|
3742
|
+
const base = this.options.options;
|
|
3743
|
+
const runSignal = this.options.signal;
|
|
3744
|
+
if (!runSignal) {
|
|
3745
|
+
return base;
|
|
3746
|
+
}
|
|
3747
|
+
runSignal.throwIfAborted();
|
|
3748
|
+
const signals = [
|
|
3749
|
+
runSignal,
|
|
3750
|
+
];
|
|
3751
|
+
const callerSignal = base?.signal ?? base?.fetchOptions?.signal;
|
|
3752
|
+
if (callerSignal) {
|
|
3753
|
+
signals.push(callerSignal);
|
|
3754
|
+
}
|
|
3755
|
+
// Re-create the timeout the SDK would have wired had no signal been
|
|
3756
|
+
// present. Per-request option wins over the client-level default,
|
|
3757
|
+
// mirroring betaResponsesSend's own precedence.
|
|
3758
|
+
const timeoutMs = base?.timeoutMs ?? this.options.client._options.timeoutMs;
|
|
3759
|
+
if (typeof timeoutMs === 'number' && timeoutMs > 0) {
|
|
3760
|
+
signals.push(AbortSignal.timeout(timeoutMs));
|
|
3761
|
+
}
|
|
3762
|
+
return {
|
|
3763
|
+
...base,
|
|
3764
|
+
signal: signals.length === 1 ? runSignal : AbortSignal.any(signals),
|
|
3765
|
+
};
|
|
3766
|
+
}
|
|
1695
3767
|
/**
|
|
1696
3768
|
* Resolve async functions in the request for a given turn context.
|
|
1697
3769
|
* Extracts non-function fields and resolves any async parameter functions.
|
|
@@ -1701,15 +3773,106 @@ export class ModelResult {
|
|
|
1701
3773
|
*/
|
|
1702
3774
|
async resolveRequestForContext(context) {
|
|
1703
3775
|
if (hasAsyncFunctions(this.options.request)) {
|
|
1704
|
-
|
|
3776
|
+
const resolved = await resolveAsyncFunctions(this.options.request, context);
|
|
3777
|
+
return this.applyResolvedForcedToolChoicePolicy(resolved);
|
|
1705
3778
|
}
|
|
1706
3779
|
// Already resolved, extract non-function fields.
|
|
1707
3780
|
// Strip ALL client-only fields — keep this list in sync with
|
|
1708
3781
|
// `clientOnlyFields` in async-params.ts, which handles the async path.
|
|
1709
3782
|
// (`sharedContextSchema` is absent here: call-model.ts destructures it
|
|
1710
3783
|
// before the request reaches ModelResult.)
|
|
1711
|
-
const { stopWhen: _, state: _s, requireApproval: _r, approveToolCalls: _a, rejectToolCalls: _rj, context: _c, onTurnStart: _ots, onTurnEnd: _ote, allowFinalResponse: _afr, strictFinalResponse: _sfr, hooks: _h, ...rest } = this.options.request;
|
|
1712
|
-
return rest;
|
|
3784
|
+
const { stopWhen: _, state: _s, requireApproval: _r, approveToolCalls: _a, rejectToolCalls: _rj, context: _c, onTurnStart: _ots, onTurnEnd: _ote, allowFinalResponse: _afr, strictFinalResponse: _sfr, hooks: _h, doomLoop: _dl, signal: _sig, toolTimeoutMs: _ttm, toolConcurrency: _tc, asyncTools: _at, ...rest } = this.options.request;
|
|
3785
|
+
return this.applyResolvedForcedToolChoicePolicy(rest);
|
|
3786
|
+
}
|
|
3787
|
+
/**
|
|
3788
|
+
* Record the freshly-resolved caller choice before deriving its wire value.
|
|
3789
|
+
* An unforced turn clears the consumed key, re-arming the same forced value
|
|
3790
|
+
* if it appears again later. A different forced key re-arms immediately.
|
|
3791
|
+
*/
|
|
3792
|
+
applyResolvedForcedToolChoicePolicy(request) {
|
|
3793
|
+
this.configuredToolChoice = request.toolChoice;
|
|
3794
|
+
this.configuredForcedToolChoiceKey = forcedToolChoiceKey(request.toolChoice);
|
|
3795
|
+
if (this.configuredForcedToolChoiceKey === null) {
|
|
3796
|
+
this.preparedToolChoiceCommit =
|
|
3797
|
+
this.activeConsumedForcedToolChoiceKey === null
|
|
3798
|
+
? null
|
|
3799
|
+
: {
|
|
3800
|
+
type: 'clear',
|
|
3801
|
+
};
|
|
3802
|
+
}
|
|
3803
|
+
else {
|
|
3804
|
+
this.preparedToolChoiceCommit =
|
|
3805
|
+
this.configuredForcedToolChoiceKey === this.activeConsumedForcedToolChoiceKey
|
|
3806
|
+
? null
|
|
3807
|
+
: {
|
|
3808
|
+
type: 'consume',
|
|
3809
|
+
key: this.configuredForcedToolChoiceKey,
|
|
3810
|
+
};
|
|
3811
|
+
}
|
|
3812
|
+
return this.applyForcedToolChoicePolicy(request);
|
|
3813
|
+
}
|
|
3814
|
+
/**
|
|
3815
|
+
* Derive the caller-configured tool choice for the active logical run.
|
|
3816
|
+
* Engine-owned one-turn overrides (`none` for finalization, or a pinned
|
|
3817
|
+
* doom-loop advisor) are applied after this helper and therefore still win.
|
|
3818
|
+
*/
|
|
3819
|
+
applyForcedToolChoicePolicy(request) {
|
|
3820
|
+
const toolChoice = this.configuredForcedToolChoiceKey !== null &&
|
|
3821
|
+
this.configuredForcedToolChoiceKey === this.activeConsumedForcedToolChoiceKey
|
|
3822
|
+
? relaxForcedToolChoice(this.configuredToolChoice)
|
|
3823
|
+
: this.configuredToolChoice;
|
|
3824
|
+
this.preparedEffectiveToolChoice = toolChoice;
|
|
3825
|
+
return toolChoice === request.toolChoice
|
|
3826
|
+
? request
|
|
3827
|
+
: {
|
|
3828
|
+
...request,
|
|
3829
|
+
toolChoice,
|
|
3830
|
+
};
|
|
3831
|
+
}
|
|
3832
|
+
/**
|
|
3833
|
+
* Bind the prepared caller-policy transition to the actual wire request.
|
|
3834
|
+
* Engine-owned overrides (for example a forced advisor consult) deliberately
|
|
3835
|
+
* produce a different semantic key and therefore cannot commit caller state.
|
|
3836
|
+
*/
|
|
3837
|
+
beginToolChoiceDispatch(actualToolChoice) {
|
|
3838
|
+
this.dispatchedCallerToolChoice =
|
|
3839
|
+
canonicalizeKeyMaterial(actualToolChoice ?? null) ===
|
|
3840
|
+
canonicalizeKeyMaterial(this.preparedEffectiveToolChoice ?? null);
|
|
3841
|
+
this.dispatchedToolChoiceCommit = this.dispatchedCallerToolChoice
|
|
3842
|
+
? this.preparedToolChoiceCommit
|
|
3843
|
+
: null;
|
|
3844
|
+
}
|
|
3845
|
+
/**
|
|
3846
|
+
* Commit only after the prepared caller choice reached the wire and a
|
|
3847
|
+
* response was materialized. A pause before dispatch leaves persisted state
|
|
3848
|
+
* untouched; a forced choice is consumed only when that response called a
|
|
3849
|
+
* tool.
|
|
3850
|
+
*/
|
|
3851
|
+
commitToolChoiceDispatch(hasToolCalls) {
|
|
3852
|
+
const callerChoiceDispatched = this.dispatchedCallerToolChoice;
|
|
3853
|
+
const commit = this.dispatchedToolChoiceCommit;
|
|
3854
|
+
this.dispatchedCallerToolChoice = false;
|
|
3855
|
+
this.dispatchedToolChoiceCommit = null;
|
|
3856
|
+
if (!callerChoiceDispatched) {
|
|
3857
|
+
return;
|
|
3858
|
+
}
|
|
3859
|
+
this.preparedToolChoiceCommit = null;
|
|
3860
|
+
if (commit?.type === 'clear') {
|
|
3861
|
+
this.activeConsumedForcedToolChoiceKey = null;
|
|
3862
|
+
}
|
|
3863
|
+
else if (commit?.type === 'consume' && hasToolCalls) {
|
|
3864
|
+
this.activeConsumedForcedToolChoiceKey = commit.key;
|
|
3865
|
+
}
|
|
3866
|
+
}
|
|
3867
|
+
/** Clear all runtime and persisted policy state for a terminal run exit. */
|
|
3868
|
+
resetForcedToolChoicePolicy() {
|
|
3869
|
+
this.activeConsumedForcedToolChoiceKey = null;
|
|
3870
|
+
this.configuredForcedToolChoiceKey = null;
|
|
3871
|
+
this.configuredToolChoice = undefined;
|
|
3872
|
+
this.preparedEffectiveToolChoice = undefined;
|
|
3873
|
+
this.preparedToolChoiceCommit = null;
|
|
3874
|
+
this.dispatchedToolChoiceCommit = null;
|
|
3875
|
+
this.dispatchedCallerToolChoice = false;
|
|
1713
3876
|
}
|
|
1714
3877
|
/**
|
|
1715
3878
|
* Apply `onResponseReceived` hooks to the freshly-supplied input items
|
|
@@ -1791,6 +3954,72 @@ export class ModelResult {
|
|
|
1791
3954
|
if (updates) {
|
|
1792
3955
|
this.currentState = updateState(this.currentState, updates);
|
|
1793
3956
|
}
|
|
3957
|
+
// Persist only the consumed semantic key, never the callback or a copied
|
|
3958
|
+
// effective toolChoice. Dynamic values are re-resolved on every turn.
|
|
3959
|
+
if (this.activeConsumedForcedToolChoiceKey !== null) {
|
|
3960
|
+
this.currentState = {
|
|
3961
|
+
...this.currentState,
|
|
3962
|
+
consumedForcedToolChoiceKey: this.activeConsumedForcedToolChoiceKey,
|
|
3963
|
+
};
|
|
3964
|
+
}
|
|
3965
|
+
else if (this.currentState.consumedForcedToolChoiceKey !== undefined) {
|
|
3966
|
+
const nextState = {
|
|
3967
|
+
...this.currentState,
|
|
3968
|
+
};
|
|
3969
|
+
delete nextState.consumedForcedToolChoiceKey;
|
|
3970
|
+
this.currentState = nextState;
|
|
3971
|
+
}
|
|
3972
|
+
// Piggyback the doom-loop detector snapshot on every persist so loop
|
|
3973
|
+
// memory survives serialize → resume (a resumed doom loop is still a
|
|
3974
|
+
// doom loop — provided the resuming call passes `doomLoop` again).
|
|
3975
|
+
// Plain bounded JSON; absent when detection is off. The engine-owned
|
|
3976
|
+
// fields ride along: `stopVerdict` keeps a condemned run condemned
|
|
3977
|
+
// across decision-only resumes; `pendingSteer` carries queued guidance
|
|
3978
|
+
// that had no flush opportunity before a pause.
|
|
3979
|
+
if (this.doomLoopMonitor) {
|
|
3980
|
+
const doomLoopState = {
|
|
3981
|
+
...this.doomLoopMonitor.getState(),
|
|
3982
|
+
...(this.doomLoopStop !== null && {
|
|
3983
|
+
stopVerdict: this.doomLoopStop,
|
|
3984
|
+
}),
|
|
3985
|
+
...(this.pendingDoomLoopSteer.length > 0 && {
|
|
3986
|
+
pendingSteer: [
|
|
3987
|
+
...this.pendingDoomLoopSteer,
|
|
3988
|
+
],
|
|
3989
|
+
}),
|
|
3990
|
+
};
|
|
3991
|
+
this.currentState = {
|
|
3992
|
+
...this.currentState,
|
|
3993
|
+
doomLoop: doomLoopState,
|
|
3994
|
+
};
|
|
3995
|
+
}
|
|
3996
|
+
// Mirror each live task's lastLog onto its persisted pendingAsyncTools
|
|
3997
|
+
// entry (bounded ~200 chars) — the one piece of progress that survives
|
|
3998
|
+
// a restart, surfaced by post-restart check calls. Debounced naturally:
|
|
3999
|
+
// this runs only when state is being saved anyway, never per log entry.
|
|
4000
|
+
if (this.asyncToolRegistry && this.currentState.pendingAsyncTools?.length) {
|
|
4001
|
+
const registry = this.asyncToolRegistry;
|
|
4002
|
+
this.currentState = {
|
|
4003
|
+
...this.currentState,
|
|
4004
|
+
pendingAsyncTools: this.currentState.pendingAsyncTools.map((entry) => {
|
|
4005
|
+
const live = registry.findByCallId(entry.callId);
|
|
4006
|
+
const lastLog = live?.lastLog;
|
|
4007
|
+
if (!lastLog) {
|
|
4008
|
+
return entry;
|
|
4009
|
+
}
|
|
4010
|
+
const text = typeof lastLog.data === 'string'
|
|
4011
|
+
? lastLog.data
|
|
4012
|
+
: (JSON.stringify(lastLog.data) ?? String(lastLog.data));
|
|
4013
|
+
return {
|
|
4014
|
+
...entry,
|
|
4015
|
+
lastLog: {
|
|
4016
|
+
at: lastLog.at,
|
|
4017
|
+
text: text.length > 200 ? `${text.slice(0, 200)}…` : text,
|
|
4018
|
+
},
|
|
4019
|
+
};
|
|
4020
|
+
}),
|
|
4021
|
+
};
|
|
4022
|
+
}
|
|
1794
4023
|
try {
|
|
1795
4024
|
await this.stateAccessor.save(this.currentState);
|
|
1796
4025
|
}
|
|
@@ -1831,6 +4060,34 @@ export class ModelResult {
|
|
|
1831
4060
|
const loadedState = await this.stateAccessor.load();
|
|
1832
4061
|
if (loadedState) {
|
|
1833
4062
|
this.currentState = loadedState;
|
|
4063
|
+
this.activeConsumedForcedToolChoiceKey = loadedState.consumedForcedToolChoiceKey ?? null;
|
|
4064
|
+
// Rehydrate doom-loop state so a resumed run continues where it
|
|
4065
|
+
// left off: streak counters always; the queued steer guidance;
|
|
4066
|
+
// and the stop verdict under the condemnation rule below.
|
|
4067
|
+
if (this.doomLoopMonitor && loadedState.doomLoop !== undefined) {
|
|
4068
|
+
this.doomLoopMonitor.restore(loadedState.doomLoop);
|
|
4069
|
+
const persisted = loadedState.doomLoop;
|
|
4070
|
+
if (Array.isArray(persisted.pendingSteer)) {
|
|
4071
|
+
for (const message of persisted.pendingSteer) {
|
|
4072
|
+
if (typeof message === 'string') {
|
|
4073
|
+
this.queueDoomLoopSteer(message);
|
|
4074
|
+
}
|
|
4075
|
+
}
|
|
4076
|
+
}
|
|
4077
|
+
// Condemnation rule: a doom-stopped conversation STAYS stopped
|
|
4078
|
+
// across decision-only resumes (approveToolCalls /
|
|
4079
|
+
// rejectToolCalls — approving a call is not new conversation).
|
|
4080
|
+
// A fresh conversational turn (a plain callModel on the same
|
|
4081
|
+
// state, no decisions) clears the verdict: operator input is
|
|
4082
|
+
// new information. Streaks are kept either way, so renewed
|
|
4083
|
+
// repetition re-condemns quickly.
|
|
4084
|
+
if (persisted.stopVerdict) {
|
|
4085
|
+
const isDecisionResume = this.approvedToolCalls.length > 0 || this.rejectedToolCalls.length > 0;
|
|
4086
|
+
if (isDecisionResume) {
|
|
4087
|
+
this.doomLoopStop = persisted.stopVerdict;
|
|
4088
|
+
}
|
|
4089
|
+
}
|
|
4090
|
+
}
|
|
1834
4091
|
// Check if we're resuming from awaiting_approval or awaiting_hitl
|
|
1835
4092
|
// with decisions. `awaiting_hitl` reuses `processApprovalDecisions`
|
|
1836
4093
|
// because the resume mechanism is identical — the caller supplies
|
|
@@ -1871,13 +4128,18 @@ export class ModelResult {
|
|
|
1871
4128
|
}
|
|
1872
4129
|
// Keep manual calls durable until the resumed request produces a
|
|
1873
4130
|
// response. Clearing before the API call would lose the only copy if
|
|
1874
|
-
// that request failed.
|
|
4131
|
+
// that request failed. `awaiting_async_tools` resumes the same way:
|
|
4132
|
+
// the pending-task bookkeeping was already updated by
|
|
4133
|
+
// resumeToolResults (or remains pending for tasks not yet resolved),
|
|
4134
|
+
// and the placeholder outputs are in history, so a fresh request is
|
|
4135
|
+
// valid.
|
|
1875
4136
|
this.resumingFromClientTools = loadedState.status === 'awaiting_client_tools';
|
|
4137
|
+
this.resumingFromAsyncTools = loadedState.status === 'awaiting_async_tools';
|
|
1876
4138
|
}
|
|
1877
4139
|
else {
|
|
1878
4140
|
this.currentState = createInitialState();
|
|
1879
4141
|
}
|
|
1880
|
-
if (!this.resumingFromClientTools) {
|
|
4142
|
+
if (!this.resumingFromClientTools && !this.resumingFromAsyncTools) {
|
|
1881
4143
|
await this.saveStateSafely({
|
|
1882
4144
|
status: 'in_progress',
|
|
1883
4145
|
});
|
|
@@ -2003,9 +4265,10 @@ export class ModelResult {
|
|
|
2003
4265
|
turnNumber: 0,
|
|
2004
4266
|
};
|
|
2005
4267
|
// Make the API request
|
|
4268
|
+
this.beginToolChoiceDispatch(request.toolChoice);
|
|
2006
4269
|
const apiResult = await betaResponsesSend(this.options.client, {
|
|
2007
4270
|
responsesRequest: request,
|
|
2008
|
-
}, this.
|
|
4271
|
+
}, this.dispatchRequestOptions());
|
|
2009
4272
|
if (!apiResult.ok) {
|
|
2010
4273
|
throw apiResult.error;
|
|
2011
4274
|
}
|
|
@@ -2051,6 +4314,16 @@ export class ModelResult {
|
|
|
2051
4314
|
// Track approved HITL calls that paused (onToolCalled returned null) —
|
|
2052
4315
|
// these stay in pendingToolCalls so the caller can resume them later.
|
|
2053
4316
|
const hitlPausedIds = new Set();
|
|
4317
|
+
// The approved batch is one doom-loop round: N approved duplicates of
|
|
4318
|
+
// the same call count once (the sequential loop below still evaluates
|
|
4319
|
+
// in order; restored streaks from the persisted state carry forward).
|
|
4320
|
+
// Declared from the approved calls only — a pending call the user did not
|
|
4321
|
+
// approve is not part of this round.
|
|
4322
|
+
await this.beginDoomLoopRound([
|
|
4323
|
+
...this.approvedToolCalls,
|
|
4324
|
+
]
|
|
4325
|
+
.map((callId) => pendingCalls.find((tc) => tc.id === callId))
|
|
4326
|
+
.filter((tc) => tc !== undefined));
|
|
2054
4327
|
// Process approvals - execute the approved tools. Route through
|
|
2055
4328
|
// runToolWithHooks so PreToolUse/PostToolUse fire even on this path.
|
|
2056
4329
|
for (const callId of this.approvedToolCalls) {
|
|
@@ -2083,6 +4356,10 @@ export class ModelResult {
|
|
|
2083
4356
|
hitlPausedIds.add(callId);
|
|
2084
4357
|
continue;
|
|
2085
4358
|
}
|
|
4359
|
+
if (isAsyncToolInvocation(result)) {
|
|
4360
|
+
unsentResults.push(await this.settleAsyncInvocationAsUnsent(toolCall, result));
|
|
4361
|
+
continue;
|
|
4362
|
+
}
|
|
2086
4363
|
if (result.error) {
|
|
2087
4364
|
unsentResults.push(createRejectedResult(callId, String(toolCall.name), result.error.message));
|
|
2088
4365
|
}
|
|
@@ -2131,6 +4408,11 @@ export class ModelResult {
|
|
|
2131
4408
|
if (unsentResults.length > 0) {
|
|
2132
4409
|
stateUpdates.unsentToolResults = unsentResults;
|
|
2133
4410
|
}
|
|
4411
|
+
if (nextStatus === 'in_progress' && this.doomLoopStop) {
|
|
4412
|
+
// This approval continuation is terminal: no model request follows, so
|
|
4413
|
+
// a later fresh run must not inherit its consumed caller choice.
|
|
4414
|
+
this.resetForcedToolChoicePolicy();
|
|
4415
|
+
}
|
|
2134
4416
|
await this.saveStateSafely(stateUpdates);
|
|
2135
4417
|
// Clear optional properties if they should be empty
|
|
2136
4418
|
const propsToClear = [];
|
|
@@ -2144,10 +4426,22 @@ export class ModelResult {
|
|
|
2144
4426
|
this.clearOptionalStateProperties(propsToClear);
|
|
2145
4427
|
await this.saveStateSafely();
|
|
2146
4428
|
}
|
|
2147
|
-
// If we are paused (for approval or for HITL), stop here
|
|
4429
|
+
// If we are paused (for approval or for HITL), stop here. Queued steer
|
|
4430
|
+
// guidance rides doomLoop.pendingSteer in the state persisted above;
|
|
4431
|
+
// the resumed run delivers it at its next safe flush point (injecting a
|
|
4432
|
+
// user message between dangling function_calls and their future
|
|
4433
|
+
// outputs would be invalid history).
|
|
2148
4434
|
if (nextStatus !== 'in_progress') {
|
|
2149
4435
|
return;
|
|
2150
4436
|
}
|
|
4437
|
+
// A doom-stop armed while executing the approved calls (the restored
|
|
4438
|
+
// streak crossed the stop rung): halt WITHOUT the unsent-results model
|
|
4439
|
+
// request. The executed outputs are already persisted as unsent; the
|
|
4440
|
+
// condemned call got a blocked output. Session teardown reports the
|
|
4441
|
+
// doom reason via the armed verdict (see executeToolsIfNeeded).
|
|
4442
|
+
if (this.doomLoopStop) {
|
|
4443
|
+
return;
|
|
4444
|
+
}
|
|
2151
4445
|
// Otherwise, continue with tool execution using unsent results
|
|
2152
4446
|
await this.continueWithUnsentResults();
|
|
2153
4447
|
}
|
|
@@ -2203,9 +4497,10 @@ export class ModelResult {
|
|
|
2203
4497
|
turnNumber: turnContext.numberOfTurns,
|
|
2204
4498
|
};
|
|
2205
4499
|
// Make the API request
|
|
4500
|
+
this.beginToolChoiceDispatch(request.toolChoice);
|
|
2206
4501
|
const apiResult = await betaResponsesSend(this.options.client, {
|
|
2207
4502
|
responsesRequest: request,
|
|
2208
|
-
}, this.
|
|
4503
|
+
}, this.dispatchRequestOptions());
|
|
2209
4504
|
if (!apiResult.ok) {
|
|
2210
4505
|
throw apiResult.error;
|
|
2211
4506
|
}
|
|
@@ -2247,13 +4542,35 @@ export class ModelResult {
|
|
|
2247
4542
|
this.currentState?.status === 'awaiting_hitl')) {
|
|
2248
4543
|
return;
|
|
2249
4544
|
}
|
|
4545
|
+
// A doom-stop armed while executing approved calls on resume:
|
|
4546
|
+
// processApprovalDecisions already skipped the unsent-results model
|
|
4547
|
+
// request; the executed outputs are persisted as unsent for a later
|
|
4548
|
+
// (fresh-input) resume. Halt here — like a pause, no stream exists.
|
|
4549
|
+
if (this.isResumingFromApproval && this.doomLoopStop) {
|
|
4550
|
+
sessionEndReason = 'doom_loop';
|
|
4551
|
+
this.resetForcedToolChoicePolicy();
|
|
4552
|
+
await this.saveStateSafely();
|
|
4553
|
+
return;
|
|
4554
|
+
}
|
|
2250
4555
|
// Get initial response
|
|
2251
4556
|
let currentResponse = await this.getInitialResponse();
|
|
4557
|
+
// toolChoice constrains model output, so a forced requirement is
|
|
4558
|
+
// satisfied as soon as this turn emits a tool call. Record it before
|
|
4559
|
+
// any approval/HITL/client/async pause can persist the run.
|
|
4560
|
+
const hasToolCalls = responseHasToolCalls(currentResponse);
|
|
4561
|
+
this.commitToolChoiceDispatch(hasToolCalls);
|
|
2252
4562
|
// Save initial response to state
|
|
2253
4563
|
await this.saveResponseToState(currentResponse);
|
|
4564
|
+
// Doom-loop text checkpoint for the initial response (within-response
|
|
4565
|
+
// token repetition + cross-step streak seed).
|
|
4566
|
+
await this.checkDoomLoopForResponse(currentResponse);
|
|
2254
4567
|
// Check if tools should be executed
|
|
2255
|
-
const hasToolCalls = currentResponse.output.some((item) => hasTypeProperty(item) && item.type === 'function_call');
|
|
2256
4568
|
if (!this.options.tools?.length || !hasToolCalls) {
|
|
4569
|
+
// A text verdict on the initial response may have armed a stop —
|
|
4570
|
+
// report the run as doom-stopped, not as a normal completion.
|
|
4571
|
+
if (this.doomLoopStop) {
|
|
4572
|
+
sessionEndReason = 'doom_loop';
|
|
4573
|
+
}
|
|
2257
4574
|
// No tool work: keep hard throw on empty/invalid final output.
|
|
2258
4575
|
this.validateFinalResponse(currentResponse);
|
|
2259
4576
|
this.finalResponse = currentResponse;
|
|
@@ -2282,11 +4599,27 @@ export class ModelResult {
|
|
|
2282
4599
|
// runStopHook for the cap.
|
|
2283
4600
|
let forceResumeCount = 0;
|
|
2284
4601
|
while (true) {
|
|
4602
|
+
// Run-level cancellation: fail the loop at the turn boundary with
|
|
4603
|
+
// the abort reason. In-flight requests are aborted independently
|
|
4604
|
+
// by the composed dispatch signal (dispatchRequestOptions); this
|
|
4605
|
+
// check covers cancellation between requests (during tool
|
|
4606
|
+
// execution or hook work).
|
|
4607
|
+
this.options.signal?.throwIfAborted();
|
|
2285
4608
|
// Check for external interruption
|
|
2286
4609
|
if (await this.checkForInterruption(currentResponse)) {
|
|
2287
4610
|
sessionEndReason = 'user';
|
|
2288
4611
|
return;
|
|
2289
4612
|
}
|
|
4613
|
+
// A doom-loop 'stop' verdict from the previous response's text
|
|
4614
|
+
// halts the run before any further spend. Seal first: this break
|
|
4615
|
+
// fires BEFORE this response's tool calls execute, so they need
|
|
4616
|
+
// synthesized halt outputs or the persisted history would carry
|
|
4617
|
+
// dangling function_calls (providers 400 on resume).
|
|
4618
|
+
if (this.doomLoopStop) {
|
|
4619
|
+
await this.sealDoomLoopStop(currentResponse);
|
|
4620
|
+
sessionEndReason = 'doom_loop';
|
|
4621
|
+
break;
|
|
4622
|
+
}
|
|
2290
4623
|
// Check stop conditions
|
|
2291
4624
|
if (await this.shouldStopExecution()) {
|
|
2292
4625
|
// Emit the Stop hook -- handlers can force resume or inject a
|
|
@@ -2331,7 +4664,7 @@ export class ModelResult {
|
|
|
2331
4664
|
// Resolve async functions for this turn
|
|
2332
4665
|
await this.resolveAsyncFunctionsForTurn(turnContext);
|
|
2333
4666
|
// Execute tools
|
|
2334
|
-
const { toolResults, pausedCalls } = await this.executeToolRound(currentToolCalls, turnContext);
|
|
4667
|
+
const { toolResults, pausedCalls, deferredTasks = [], } = await this.executeToolRound(currentToolCalls, turnContext);
|
|
2335
4668
|
// A tool round with observable progress resets the consecutive
|
|
2336
4669
|
// forceResume counter so a legitimate override earlier in the run
|
|
2337
4670
|
// does not count against a later, independent one.
|
|
@@ -2384,6 +4717,15 @@ export class ModelResult {
|
|
|
2384
4717
|
await this.persistHitlPause(currentResponse, pausedCalls);
|
|
2385
4718
|
return;
|
|
2386
4719
|
}
|
|
4720
|
+
// Deferred tools started durable external tasks this round: pause.
|
|
4721
|
+
// Unlike HITL, the round is fully paired (placeholder outputs were
|
|
4722
|
+
// persisted above), so the resumed conversation history is valid;
|
|
4723
|
+
// the pause exists so the model's next turn can incorporate the
|
|
4724
|
+
// real results supplied via .resolve()/resumeToolResults().
|
|
4725
|
+
if (deferredTasks.length > 0) {
|
|
4726
|
+
await this.persistAsyncToolPause(currentResponse, deferredTasks);
|
|
4727
|
+
return;
|
|
4728
|
+
}
|
|
2387
4729
|
// Manual (client-executed) tools produce no output this round —
|
|
2388
4730
|
// `executeToolRound` returns nothing for them — so a mixed round of
|
|
2389
4731
|
// auto-executed and manual calls would otherwise send a follow-up
|
|
@@ -2403,6 +4745,27 @@ export class ModelResult {
|
|
|
2403
4745
|
await this.persistClientToolsPause(currentResponse, unresolvedToolCalls);
|
|
2404
4746
|
return;
|
|
2405
4747
|
}
|
|
4748
|
+
// A doom-loop 'stop' verdict armed during this round halts the run
|
|
4749
|
+
// before the next model request. Seal defensively: the condemned
|
|
4750
|
+
// call already got a blocked error output, but a TEXT stop armed
|
|
4751
|
+
// by a concurrent checkpoint could leave other calls unresolved.
|
|
4752
|
+
// No final text-coercion request is made: the point of 'stop' is
|
|
4753
|
+
// to cut spend on a run that is demonstrably not progressing.
|
|
4754
|
+
if (this.doomLoopStop) {
|
|
4755
|
+
await this.sealDoomLoopStop(currentResponse, new Set(toolResults.map((r) => r.callId)));
|
|
4756
|
+
sessionEndReason = 'doom_loop';
|
|
4757
|
+
break;
|
|
4758
|
+
}
|
|
4759
|
+
// Inject any queued doom-loop steer guidance so the very next
|
|
4760
|
+
// request carries it. It lands in the input before this round's
|
|
4761
|
+
// call/output items (the injection point predates the follow-up
|
|
4762
|
+
// accumulation) — semantically fine, the guidance names the
|
|
4763
|
+
// repeated pattern explicitly.
|
|
4764
|
+
await this.flushDoomLoopSteer();
|
|
4765
|
+
// Inject any background-tool results that settled since the last
|
|
4766
|
+
// flush. Same safe point as the steer flush: the round is fully
|
|
4767
|
+
// paired, so a user-role envelope here is valid history.
|
|
4768
|
+
await this.flushAsyncToolDeliveries();
|
|
2406
4769
|
// Apply nextTurnParams
|
|
2407
4770
|
await this.applyNextTurnParams(currentToolCalls);
|
|
2408
4771
|
currentResponse = await this.makeFollowupRequest(currentResponse, toolResults, turnNumber);
|
|
@@ -2412,6 +4775,8 @@ export class ModelResult {
|
|
|
2412
4775
|
await this.options.onTurnEnd?.(turnContext, currentResponse);
|
|
2413
4776
|
// Save new response to state
|
|
2414
4777
|
await this.saveResponseToState(currentResponse);
|
|
4778
|
+
// Doom-loop text checkpoint for the fresh response.
|
|
4779
|
+
await this.checkDoomLoopForResponse(currentResponse);
|
|
2415
4780
|
currentRound++;
|
|
2416
4781
|
}
|
|
2417
4782
|
// If stopWhen broke the loop while the model was still emitting tool
|
|
@@ -2434,7 +4799,7 @@ export class ModelResult {
|
|
|
2434
4799
|
};
|
|
2435
4800
|
await this.options.onTurnStart?.(turnContext);
|
|
2436
4801
|
await this.resolveAsyncFunctionsForTurn(turnContext);
|
|
2437
|
-
const { toolResults, pausedCalls } = await this.executeToolRound(pendingToolCalls, turnContext);
|
|
4802
|
+
const { toolResults, pausedCalls, deferredTasks: finalDeferredTasks = [], } = await this.executeToolRound(pendingToolCalls, turnContext);
|
|
2438
4803
|
// Track the executed round and persist real outputs BEFORE the HITL
|
|
2439
4804
|
// pause check — mirrors the in-loop ordering at executeToolsIfNeeded
|
|
2440
4805
|
// so a partial batch (HITL + regular tools) doesn't drop the regular
|
|
@@ -2455,30 +4820,49 @@ export class ModelResult {
|
|
|
2455
4820
|
await this.persistHitlPause(currentResponse, pausedCalls);
|
|
2456
4821
|
return;
|
|
2457
4822
|
}
|
|
2458
|
-
|
|
2459
|
-
|
|
2460
|
-
|
|
2461
|
-
|
|
2462
|
-
|
|
2463
|
-
|
|
2464
|
-
|
|
2465
|
-
//
|
|
2466
|
-
|
|
2467
|
-
|
|
2468
|
-
|
|
2469
|
-
.map((
|
|
2470
|
-
|
|
2471
|
-
|
|
2472
|
-
|
|
2473
|
-
|
|
2474
|
-
|
|
2475
|
-
|
|
2476
|
-
|
|
2477
|
-
|
|
2478
|
-
|
|
2479
|
-
|
|
2480
|
-
|
|
4823
|
+
if (finalDeferredTasks.length > 0) {
|
|
4824
|
+
// Deferred tasks started on the halted turn — persist the pause
|
|
4825
|
+
// and skip the final text-coercion request; the resumed run
|
|
4826
|
+
// produces the final answer with the real results in hand.
|
|
4827
|
+
await this.persistAsyncToolPause(currentResponse, finalDeferredTasks);
|
|
4828
|
+
return;
|
|
4829
|
+
}
|
|
4830
|
+
// A doom-stop armed while executing the halted turn's pending
|
|
4831
|
+
// calls: seal and skip the final text-coercion request — the
|
|
4832
|
+
// design contract is "no further model requests after stop".
|
|
4833
|
+
if (this.doomLoopStop) {
|
|
4834
|
+
await this.sealDoomLoopStop(currentResponse, new Set(toolResults.map((r) => r.callId)));
|
|
4835
|
+
sessionEndReason = 'doom_loop';
|
|
4836
|
+
}
|
|
4837
|
+
else {
|
|
4838
|
+
// Apply any nextTurnParams from the executed tools so they affect
|
|
4839
|
+
// the final text-coercion request (mirrors the in-loop behavior).
|
|
4840
|
+
await this.applyNextTurnParams(pendingToolCalls);
|
|
4841
|
+
// Pair any manual tool calls (no execute fn) with stub outputs so
|
|
4842
|
+
// every function_call in the *request* has a matching output.
|
|
4843
|
+
// Stubs are NOT persisted to state — only real tool outputs are —
|
|
4844
|
+
// so a resumed conversation doesn't see "Tool execution skipped"
|
|
4845
|
+
// as if it were a real result.
|
|
4846
|
+
const executedCallIds = new Set(toolResults.map((r) => r.callId));
|
|
4847
|
+
const stubOutputs = pendingToolCalls
|
|
4848
|
+
.filter((tc) => !executedCallIds.has(tc.id))
|
|
4849
|
+
.map((tc) => ({
|
|
4850
|
+
type: 'function_call_output',
|
|
4851
|
+
callId: tc.id,
|
|
4852
|
+
output: 'Tool execution skipped: step limit reached.',
|
|
4853
|
+
}));
|
|
4854
|
+
const requestOutputs = [
|
|
4855
|
+
...toolResults,
|
|
4856
|
+
...stubOutputs,
|
|
4857
|
+
];
|
|
4858
|
+
currentResponse = await this.makeFinalResponseRequest(currentResponse, requestOutputs, allowFinalResponse, turnNumber);
|
|
4859
|
+
await this.options.onTurnEnd?.(turnContext, currentResponse);
|
|
4860
|
+
await this.saveResponseToState(currentResponse);
|
|
4861
|
+
}
|
|
2481
4862
|
}
|
|
4863
|
+
// Background tasks still in flight (or settled but undelivered) as
|
|
4864
|
+
// the loop finalizes: drain / detach / cancel per asyncTools config.
|
|
4865
|
+
currentResponse = await this.handleRunEndAsyncTasks(currentResponse);
|
|
2482
4866
|
// Validate and finalize. Mini-class models intermittently return an
|
|
2483
4867
|
// empty final turn after a successful tool round (the tool call was
|
|
2484
4868
|
// the answer). Retry once, then tolerate empty output so a completed
|
|
@@ -2502,6 +4886,9 @@ export class ModelResult {
|
|
|
2502
4886
|
}
|
|
2503
4887
|
catch (error) {
|
|
2504
4888
|
sessionEndReason = 'error';
|
|
4889
|
+
// A failing (or aborted) run must not leave tool bodies or
|
|
4890
|
+
// background tasks running against a dead conversation.
|
|
4891
|
+
this.abortAllToolWork(error instanceof Error ? error.message : String(error));
|
|
2505
4892
|
throw error;
|
|
2506
4893
|
}
|
|
2507
4894
|
finally {
|
|
@@ -2552,6 +4939,10 @@ export class ModelResult {
|
|
|
2552
4939
|
* Get the complete response object including usage information.
|
|
2553
4940
|
* This will consume the stream until completion and execute any tools.
|
|
2554
4941
|
* Returns the full OpenResponsesResult with usage data (inputTokens, outputTokens, cachedTokens, etc.)
|
|
4942
|
+
*
|
|
4943
|
+
* Note: in a multi-round tool loop this is the **final** round's response
|
|
4944
|
+
* only — its `usage` block covers that one generation, not the run. For
|
|
4945
|
+
* aggregate totals across every model call, use `getUsage()`.
|
|
2555
4946
|
*/
|
|
2556
4947
|
async getResponse() {
|
|
2557
4948
|
await this.executeToolsIfNeeded();
|
|
@@ -2640,6 +5031,17 @@ export class ModelResult {
|
|
|
2640
5031
|
* - file_search_call: File search operations
|
|
2641
5032
|
* - image_generation_call: Image generation operations
|
|
2642
5033
|
* - function_call_output: Results from executed tools
|
|
5034
|
+
*
|
|
5035
|
+
* This stream carries **output items only** — no usage or response-level
|
|
5036
|
+
* metadata. The `response.completed` events (which hold each round's usage
|
|
5037
|
+
* block) are not surfaced here, and `getResponse()` afterwards returns only
|
|
5038
|
+
* the *final* round's response, so a multi-round tool loop's `tool_calls`
|
|
5039
|
+
* generations are not accounted for by either. For usage, reach for:
|
|
5040
|
+
* - `getUsage()` — aggregate token/cost totals across every round
|
|
5041
|
+
* - the `PostModelCall` hook — one emit per model call, with
|
|
5042
|
+
* `turnType`/`turnNumber` and that call's own `usage`
|
|
5043
|
+
* - `getFullResponsesStream()` — raw events including each round's
|
|
5044
|
+
* `response.completed`, and therefore its per-round usage block
|
|
2643
5045
|
*/
|
|
2644
5046
|
getItemsStream() {
|
|
2645
5047
|
// Build the allowed-item-type scope from the tools actually passed to
|
|
@@ -3062,23 +5464,82 @@ export class ModelResult {
|
|
|
3062
5464
|
}
|
|
3063
5465
|
}
|
|
3064
5466
|
/**
|
|
3065
|
-
* Cancel the underlying stream and all consumers
|
|
5467
|
+
* Cancel the underlying stream and all consumers, and abort in-flight
|
|
5468
|
+
* tool executions and background tasks (their `ctx.signal` fires).
|
|
5469
|
+
*
|
|
5470
|
+
* Note: prior releases only cancelled the stream; aborting tool work is
|
|
5471
|
+
* new with async tool support.
|
|
3066
5472
|
*/
|
|
3067
5473
|
async cancel() {
|
|
5474
|
+
this.abortAllToolWork('Cancelled via ModelResult.cancel()');
|
|
3068
5475
|
if (this.reusableStream) {
|
|
3069
5476
|
await this.reusableStream.cancel();
|
|
3070
5477
|
}
|
|
3071
5478
|
}
|
|
5479
|
+
/**
|
|
5480
|
+
* Snapshot of every async tool task tracked by this run (background and
|
|
5481
|
+
* deferred), including settled ones.
|
|
5482
|
+
*/
|
|
5483
|
+
getAsyncTasks() {
|
|
5484
|
+
return this.asyncToolRegistry?.snapshot() ?? [];
|
|
5485
|
+
}
|
|
5486
|
+
/**
|
|
5487
|
+
* Cancel one in-process async task by its task id. Background tasks abort
|
|
5488
|
+
* their `ctx.signal`; the model receives a `status: 'cancelled'` envelope
|
|
5489
|
+
* at the next delivery point so it is not left waiting forever.
|
|
5490
|
+
*
|
|
5491
|
+
* For deferred tasks in a paused (possibly other-process) conversation,
|
|
5492
|
+
* use the deferred tool's `.cancel()` method instead.
|
|
5493
|
+
*
|
|
5494
|
+
* @returns true when a working task was found and cancelled.
|
|
5495
|
+
*/
|
|
5496
|
+
cancelTask(taskId, reason) {
|
|
5497
|
+
return this.asyncToolRegistry?.cancelTask(taskId, reason) ?? false;
|
|
5498
|
+
}
|
|
5499
|
+
/**
|
|
5500
|
+
* Send a steering message to a running in-process task. Delivered to the
|
|
5501
|
+
* run body's `ctx.onMessage` handler (queued until one registers). Agent
|
|
5502
|
+
* tools (`tool.agent()`) auto-forward messages into the child
|
|
5503
|
+
* conversation as user messages at the child's next turn boundary.
|
|
5504
|
+
*
|
|
5505
|
+
* Throws for deferred tasks — their work runs in an external system;
|
|
5506
|
+
* steer it there.
|
|
5507
|
+
*
|
|
5508
|
+
* @returns true when a working task received (or queued) the message.
|
|
5509
|
+
*/
|
|
5510
|
+
sendToTask(taskId, message) {
|
|
5511
|
+
return this.asyncToolRegistry?.sendToTask(taskId, message) ?? false;
|
|
5512
|
+
}
|
|
5513
|
+
/**
|
|
5514
|
+
* Queue a user-role message for injection into this run's conversation at
|
|
5515
|
+
* the next safe turn boundary (the same mechanism doom-loop steer
|
|
5516
|
+
* guidance uses — never between a dangling `function_call` and its
|
|
5517
|
+
* output). The primary consumer is `tool.agent()`, which forwards
|
|
5518
|
+
* steering messages into child conversations through this.
|
|
5519
|
+
*
|
|
5520
|
+
* Delivery requires a next dispatch: boundaries are post-tool-round
|
|
5521
|
+
* follow-ups, pause-persists, and end-of-run drain turns. A message
|
|
5522
|
+
* queued while the run is composing its FINAL text response (no further
|
|
5523
|
+
* dispatch of any kind) has no boundary left and is not delivered —
|
|
5524
|
+
* steer while the run still has work in flight.
|
|
5525
|
+
*/
|
|
5526
|
+
queueUserMessage(text) {
|
|
5527
|
+
this.queueDoomLoopSteer(text);
|
|
5528
|
+
}
|
|
3072
5529
|
// =========================================================================
|
|
3073
5530
|
// Multi-Turn Conversation State Methods
|
|
3074
5531
|
// =========================================================================
|
|
3075
5532
|
/**
|
|
3076
5533
|
* Check if the conversation requires human/client input to continue.
|
|
3077
|
-
* Returns true when the conversation is paused waiting
|
|
3078
|
-
*
|
|
3079
|
-
* (`awaiting_hitl`),
|
|
3080
|
-
*
|
|
3081
|
-
*
|
|
5534
|
+
* Returns true when the conversation is paused waiting on the CALLER —
|
|
5535
|
+
* approval/rejection (`awaiting_approval`), HITL tool resume
|
|
5536
|
+
* (`awaiting_hitl`), client-executed manual tools
|
|
5537
|
+
* (`awaiting_client_tools`), or an external async task resolution
|
|
5538
|
+
* (`awaiting_async_tools`, cleared via the deferred tool's `.resolve()` /
|
|
5539
|
+
* `.fail()` / `.cancel()` or `resumeToolResults()` — NOT via
|
|
5540
|
+
* `approveToolCalls`). Also returns true whenever `pendingToolCalls` is
|
|
5541
|
+
* populated regardless of status. To branch on the pause KIND, read
|
|
5542
|
+
* `(await getState()).status` instead.
|
|
3082
5543
|
*/
|
|
3083
5544
|
async requiresApproval() {
|
|
3084
5545
|
await this.initStreamGuarded({
|
|
@@ -3087,7 +5548,8 @@ export class ModelResult {
|
|
|
3087
5548
|
const status = this.currentState?.status;
|
|
3088
5549
|
if (status === 'awaiting_approval' ||
|
|
3089
5550
|
status === 'awaiting_hitl' ||
|
|
3090
|
-
status === 'awaiting_client_tools'
|
|
5551
|
+
status === 'awaiting_client_tools' ||
|
|
5552
|
+
status === 'awaiting_async_tools') {
|
|
3091
5553
|
return true;
|
|
3092
5554
|
}
|
|
3093
5555
|
// Also check if pendingToolCalls is populated
|
|
@@ -3126,5 +5588,139 @@ export class ModelResult {
|
|
|
3126
5588
|
}
|
|
3127
5589
|
return this.currentState;
|
|
3128
5590
|
}
|
|
5591
|
+
/**
|
|
5592
|
+
* The doom-loop verdict that stopped this run, or null when the run was
|
|
5593
|
+
* not stopped by doom-loop detection (including when detection is off).
|
|
5594
|
+
* Resolves after the tool loop settles. For per-event observation use the
|
|
5595
|
+
* `DoomLoopDetected` hook.
|
|
5596
|
+
*/
|
|
5597
|
+
async getDoomLoopVerdict() {
|
|
5598
|
+
await this.initStreamGuarded({
|
|
5599
|
+
requireStream: false,
|
|
5600
|
+
});
|
|
5601
|
+
if (!this.isResumingFromApproval) {
|
|
5602
|
+
await this.executeToolsIfNeeded();
|
|
5603
|
+
}
|
|
5604
|
+
return this.doomLoopStop;
|
|
5605
|
+
}
|
|
5606
|
+
/**
|
|
5607
|
+
* Aggregate token/cost usage across **every** model call this run made —
|
|
5608
|
+
* the initial request, each tool-round follow-up, the empty-final retry,
|
|
5609
|
+
* the `allowFinalResponse` final turn, and approval-resume requests.
|
|
5610
|
+
*
|
|
5611
|
+
* This is the pull-based counterpart to the `SessionEnd.totalUsage` hook
|
|
5612
|
+
* payload (same `SessionUsageTotals` shape, same numbers) and exists
|
|
5613
|
+
* because `getResponse()` returns only the **final** round's response: in a
|
|
5614
|
+
* multi-round tool loop the `tool_calls` generations' tokens are otherwise
|
|
5615
|
+
* unreachable, and `getItemsStream()` carries output items only — no
|
|
5616
|
+
* `response.completed` usage block.
|
|
5617
|
+
*
|
|
5618
|
+
* Gates on run completion the same way `getResponse()` does, so totals are
|
|
5619
|
+
* final whether you await it directly, after `getResponse()`, or after
|
|
5620
|
+
* consuming any of the streaming getters — except on an approval-resumed
|
|
5621
|
+
* run, where reading usage never advances the tool loop: the resume
|
|
5622
|
+
* request's own generation is awaited and counted, but no further rounds
|
|
5623
|
+
* are driven, so totals can be mid-run. Await `getResponse()`/`getText()`
|
|
5624
|
+
* first for final totals there. On a run that paused (approval / HITL) or
|
|
5625
|
+
* failed, it returns the totals accrued so far — `modelCalls: 0` with
|
|
5626
|
+
* zeroed tokens when no model call ever completed.
|
|
5627
|
+
*
|
|
5628
|
+
* Unlike `getResponse()`, this never rejects: a failed run still consumed
|
|
5629
|
+
* tokens, and cost accounting typically runs in a `finally`/`catch` where a
|
|
5630
|
+
* second throw would mask the run's original error. Await the run itself
|
|
5631
|
+
* (e.g. `getResponse()`) if you need to observe the failure.
|
|
5632
|
+
*
|
|
5633
|
+
* `cost` is present only when the server reported cost accounting for at
|
|
5634
|
+
* least one call; a `0` would be indistinguishable from "free".
|
|
5635
|
+
*
|
|
5636
|
+
* For per-call granularity use the `PostModelCall` hook (one emit per model
|
|
5637
|
+
* call, with `turnType`/`turnNumber`) or `getFullResponsesStream()`, whose
|
|
5638
|
+
* `response.completed` events carry each round's own usage block.
|
|
5639
|
+
*/
|
|
5640
|
+
async getUsage() {
|
|
5641
|
+
try {
|
|
5642
|
+
await this.initStreamGuarded({
|
|
5643
|
+
requireStream: false,
|
|
5644
|
+
});
|
|
5645
|
+
if (!this.isResumingFromApproval) {
|
|
5646
|
+
await this.executeToolsIfNeeded();
|
|
5647
|
+
}
|
|
5648
|
+
else if (this.pendingModelCall && this.reusableStream) {
|
|
5649
|
+
// The resume dispatch returned a live event stream whose telemetry
|
|
5650
|
+
// is still parked (the non-streaming branch folds usage in
|
|
5651
|
+
// immediately — see continueWithUnsentResults). Consuming the
|
|
5652
|
+
// reusable stream is a passive observation: it buffers events
|
|
5653
|
+
// without executing tools or mutating conversation state, so the
|
|
5654
|
+
// resume generation is counted without advancing the loop.
|
|
5655
|
+
await this.emitPendingModelCallOnce(await consumeStreamForCompletion(this.reusableStream));
|
|
5656
|
+
}
|
|
5657
|
+
}
|
|
5658
|
+
catch (error) {
|
|
5659
|
+
// Intentionally swallowed — see the "never rejects" note above. The
|
|
5660
|
+
// aggregate below still reports whatever calls completed before the
|
|
5661
|
+
// failure; the warn leaves a diagnostic thread for the swallowed cause.
|
|
5662
|
+
console.warn('[getUsage] run failed; reporting totals accrued so far:', error);
|
|
5663
|
+
}
|
|
5664
|
+
return this.snapshotSessionUsage();
|
|
5665
|
+
}
|
|
5666
|
+
}
|
|
5667
|
+
/**
|
|
5668
|
+
* The unified-run affordances (`ctx.defer` for deferred tools, `ctx.log`,
|
|
5669
|
+
* `ctx.onMessage`, the transcript-source slot) threaded into a tool's run
|
|
5670
|
+
* context via `runExtras`.
|
|
5671
|
+
*/
|
|
5672
|
+
function buildRunExtras(tool, runBinding) {
|
|
5673
|
+
return {
|
|
5674
|
+
...(isUnifiedTool(tool) &&
|
|
5675
|
+
tool.function.lifecycle === 'deferred' && {
|
|
5676
|
+
defer: (taskId, options) => ({
|
|
5677
|
+
__deferred: true,
|
|
5678
|
+
taskId,
|
|
5679
|
+
...(options ?? {}),
|
|
5680
|
+
}),
|
|
5681
|
+
}),
|
|
5682
|
+
log: runBinding.log,
|
|
5683
|
+
onMessage: runBinding.onMessage,
|
|
5684
|
+
// Live getter through the binding — the ToolTask (and its id) is only
|
|
5685
|
+
// created when the call escapes the round, after this object is built.
|
|
5686
|
+
get taskId() {
|
|
5687
|
+
return runBinding.task()?.taskId;
|
|
5688
|
+
},
|
|
5689
|
+
// Transcript slot only — deliberately NOT `task`: TurnContext.task is
|
|
5690
|
+
// the ToolTaskHandle facade (check calls only) and a run body must not
|
|
5691
|
+
// see a transcript-only object under that name.
|
|
5692
|
+
taskTranscript: {
|
|
5693
|
+
set transcriptSource(source) {
|
|
5694
|
+
runBinding.setTranscriptSource(source);
|
|
5695
|
+
},
|
|
5696
|
+
get transcriptSource() {
|
|
5697
|
+
return runBinding.task()?.transcriptSource;
|
|
5698
|
+
},
|
|
5699
|
+
},
|
|
5700
|
+
};
|
|
5701
|
+
}
|
|
5702
|
+
/**
|
|
5703
|
+
* `task` tool, action=result on a SETTLED live task: the final result
|
|
5704
|
+
* (completed) or terminal status + error. Null when the task is not
|
|
5705
|
+
* settled in-process — the caller falls back to the status view.
|
|
5706
|
+
*/
|
|
5707
|
+
function taskToolResultIfSettled(input, liveTask) {
|
|
5708
|
+
if (liveTask?.status === 'completed') {
|
|
5709
|
+
return {
|
|
5710
|
+
taskId: input.taskId,
|
|
5711
|
+
status: 'completed',
|
|
5712
|
+
result: liveTask.result,
|
|
5713
|
+
};
|
|
5714
|
+
}
|
|
5715
|
+
if (liveTask && liveTask.status !== 'working') {
|
|
5716
|
+
return {
|
|
5717
|
+
taskId: input.taskId,
|
|
5718
|
+
status: liveTask.status,
|
|
5719
|
+
...(liveTask.error !== undefined && {
|
|
5720
|
+
error: liveTask.error,
|
|
5721
|
+
}),
|
|
5722
|
+
};
|
|
5723
|
+
}
|
|
5724
|
+
return null;
|
|
3129
5725
|
}
|
|
3130
5726
|
//# sourceMappingURL=model-result.js.map
|