@openrouter/agent 0.7.1 → 0.8.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 +248 -17
- package/esm/index.d.ts +8 -5
- package/esm/index.d.ts.map +1 -1
- package/esm/index.js +12 -4
- package/esm/index.js.map +1 -1
- package/esm/inner-loop/call-model.d.ts +8 -5
- package/esm/inner-loop/call-model.d.ts.map +1 -1
- package/esm/inner-loop/call-model.js +16 -6
- package/esm/inner-loop/call-model.js.map +1 -1
- package/esm/lib/async-params.d.ts +25 -6
- package/esm/lib/async-params.d.ts.map +1 -1
- package/esm/lib/async-params.js +3 -1
- package/esm/lib/async-params.js.map +1 -1
- package/esm/lib/conversation-state.d.ts +44 -1
- package/esm/lib/conversation-state.d.ts.map +1 -1
- package/esm/lib/conversation-state.js +111 -0
- package/esm/lib/conversation-state.js.map +1 -1
- package/esm/lib/hooks-emit.d.ts +50 -0
- package/esm/lib/hooks-emit.d.ts.map +1 -0
- package/esm/lib/hooks-emit.js +307 -0
- package/esm/lib/hooks-emit.js.map +1 -0
- package/esm/lib/hooks-manager.d.ts +141 -0
- package/esm/lib/hooks-manager.d.ts.map +1 -0
- package/esm/lib/hooks-manager.js +282 -0
- package/esm/lib/hooks-manager.js.map +1 -0
- package/esm/lib/hooks-matchers.d.ts +12 -0
- package/esm/lib/hooks-matchers.d.ts.map +1 -0
- package/esm/lib/hooks-matchers.js +26 -0
- package/esm/lib/hooks-matchers.js.map +1 -0
- package/esm/lib/hooks-resolve.d.ts +17 -0
- package/esm/lib/hooks-resolve.d.ts.map +1 -0
- package/esm/lib/hooks-resolve.js +42 -0
- package/esm/lib/hooks-resolve.js.map +1 -0
- package/esm/lib/hooks-schemas.d.ts +188 -0
- package/esm/lib/hooks-schemas.d.ts.map +1 -0
- package/esm/lib/hooks-schemas.js +184 -0
- package/esm/lib/hooks-schemas.js.map +1 -0
- package/esm/lib/hooks-types.d.ts +174 -0
- package/esm/lib/hooks-types.d.ts.map +1 -0
- package/esm/lib/hooks-types.js +45 -0
- package/esm/lib/hooks-types.js.map +1 -0
- package/esm/lib/model-result.d.ts +209 -9
- package/esm/lib/model-result.d.ts.map +1 -1
- package/esm/lib/model-result.js +1397 -348
- package/esm/lib/model-result.js.map +1 -1
- package/esm/lib/reusable-stream.d.ts +6 -0
- package/esm/lib/reusable-stream.d.ts.map +1 -1
- package/esm/lib/reusable-stream.js +8 -0
- package/esm/lib/reusable-stream.js.map +1 -1
- package/esm/lib/tool-executor.d.ts.map +1 -1
- package/esm/lib/tool-executor.js +11 -1
- 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 +4 -1
- package/esm/lib/tool-orchestrator.js.map +1 -1
- package/esm/lib/tool-types.d.ts +137 -45
- package/esm/lib/tool-types.d.ts.map +1 -1
- package/esm/lib/tool-types.js +13 -0
- package/esm/lib/tool-types.js.map +1 -1
- package/esm/lib/tool.d.ts +35 -25
- package/esm/lib/tool.d.ts.map +1 -1
- package/esm/lib/tool.js +19 -0
- package/esm/lib/tool.js.map +1 -1
- package/package.json +7 -3
package/esm/lib/model-result.js
CHANGED
|
@@ -9,14 +9,30 @@ import { hasTypeProperty, isFunctionCallItem, isFunctionCallOutputItem, isOutput
|
|
|
9
9
|
import { resolveContext, ToolContextStore } from './tool-context.js';
|
|
10
10
|
import { ToolEventBroadcaster } from './tool-event-broadcaster.js';
|
|
11
11
|
import { applyOnResponseReceivedHooks, executeTool } from './tool-executor.js';
|
|
12
|
-
import { isAutoResolvableTool, isClientTool, isServerTool, isToolCallOutputEvent, } from './tool-types.js';
|
|
12
|
+
import { isAutoResolvableTool, isClientTool, isMcpTool, isServerTool, isToolCallOutputEvent, } from './tool-types.js';
|
|
13
13
|
import { normalizeInputToArray } from './turn-context.js';
|
|
14
|
+
/**
|
|
15
|
+
* Default directive appended as a final user message on the forced final
|
|
16
|
+
* turn (`allowFinalResponse` defaulting to on, or explicitly `true`).
|
|
17
|
+
* Forbidding tools via `toolChoice: 'none'` alone is not enough: models
|
|
18
|
+
* that emit tool-call syntax as text (e.g. GLM) will attempt another call
|
|
19
|
+
* and leak it into `content` as unparsed `<tool_call>…` text unless they
|
|
20
|
+
* are told this is the final turn.
|
|
21
|
+
* Pass a non-empty string to `allowFinalResponse` to override the wording,
|
|
22
|
+
* or `''` to append no message at all (legacy behavior).
|
|
23
|
+
*/
|
|
24
|
+
export const DEFAULT_FINAL_RESPONSE_DIRECTIVE = 'You have reached the tool-use limit, and tools are no longer available. Do not attempt to call any more tools. Using the information you already have, write your final answer now.';
|
|
14
25
|
/**
|
|
15
26
|
* Typeguard for plain-object records (non-null, non-array).
|
|
16
27
|
*/
|
|
17
28
|
function isRecord(value) {
|
|
18
29
|
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
19
30
|
}
|
|
31
|
+
// Cap consecutive Stop-hook forceResume overrides so a misbehaving handler
|
|
32
|
+
// cannot spin the loop forever. 3 is a conservative upper bound -- enough to
|
|
33
|
+
// let a hook gather a couple of follow-up actions but small enough that a
|
|
34
|
+
// buggy handler fails fast with a visible warning.
|
|
35
|
+
const MAX_FORCE_RESUME_OVERRIDES = 3;
|
|
20
36
|
/**
|
|
21
37
|
* Human-readable label for a value that failed the `isRecord` check. Used
|
|
22
38
|
* exclusively to make `toModelOutput` misuse errors specific.
|
|
@@ -44,6 +60,113 @@ function isEventStream(value) {
|
|
|
44
60
|
const maybeStream = value;
|
|
45
61
|
return typeof maybeStream.getReader === 'function';
|
|
46
62
|
}
|
|
63
|
+
/**
|
|
64
|
+
* Map the server's usage block onto the hook-facing ModelCallUsage shape.
|
|
65
|
+
* Returns undefined when the response carried no usage accounting.
|
|
66
|
+
*/
|
|
67
|
+
function extractModelCallUsage(usage) {
|
|
68
|
+
if (!usage) {
|
|
69
|
+
return undefined;
|
|
70
|
+
}
|
|
71
|
+
return {
|
|
72
|
+
inputTokens: usage.inputTokens,
|
|
73
|
+
outputTokens: usage.outputTokens,
|
|
74
|
+
totalTokens: usage.totalTokens,
|
|
75
|
+
cachedTokens: usage.inputTokensDetails?.cachedTokens ?? 0,
|
|
76
|
+
reasoningTokens: usage.outputTokensDetails?.reasoningTokens ?? 0,
|
|
77
|
+
...(usage.cost !== null &&
|
|
78
|
+
usage.cost !== undefined && {
|
|
79
|
+
cost: usage.cost,
|
|
80
|
+
}),
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* Type guard for an input message with a user role and a string `content`.
|
|
85
|
+
* These are the messages we can safely surface to UserPromptSubmit hooks.
|
|
86
|
+
*/
|
|
87
|
+
function isUserStringMessage(value) {
|
|
88
|
+
if (typeof value !== 'object' || value === null) {
|
|
89
|
+
return false;
|
|
90
|
+
}
|
|
91
|
+
const obj = value;
|
|
92
|
+
return obj.role === 'user' && typeof obj.content === 'string';
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* Find the index of the last user-role, string-content message in an input
|
|
96
|
+
* array. Returns -1 when no such message exists.
|
|
97
|
+
*/
|
|
98
|
+
function findLatestUserStringIndex(arr) {
|
|
99
|
+
for (let i = arr.length - 1; i >= 0; i--) {
|
|
100
|
+
if (isUserStringMessage(arr[i])) {
|
|
101
|
+
return i;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
return -1;
|
|
105
|
+
}
|
|
106
|
+
/**
|
|
107
|
+
* Extract a user-facing prompt string from an input (string or message array),
|
|
108
|
+
* and return an applier that writes a mutated prompt back into the same shape.
|
|
109
|
+
*
|
|
110
|
+
* For structured inputs we look at the LAST user message with a string
|
|
111
|
+
* content — this is the most common shape emitted by the SDK's own helpers
|
|
112
|
+
* (`normalizeInputToArray`) and matches what a handler would reasonably
|
|
113
|
+
* expect to mutate.
|
|
114
|
+
*
|
|
115
|
+
* Returns `{ prompt: undefined }` when no usable prompt can be extracted; the
|
|
116
|
+
* caller should skip the hook in that case.
|
|
117
|
+
*/
|
|
118
|
+
function extractPromptAndApplier(input) {
|
|
119
|
+
if (typeof input === 'string') {
|
|
120
|
+
return {
|
|
121
|
+
prompt: input,
|
|
122
|
+
applyMutated: (mutated) => mutated,
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
if (Array.isArray(input)) {
|
|
126
|
+
const targetIndex = findLatestUserStringIndex(input);
|
|
127
|
+
if (targetIndex === -1) {
|
|
128
|
+
return {
|
|
129
|
+
prompt: undefined,
|
|
130
|
+
applyMutated: (_mutated, original) => original ?? input,
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
const target = input[targetIndex];
|
|
134
|
+
if (!isUserStringMessage(target)) {
|
|
135
|
+
return {
|
|
136
|
+
prompt: undefined,
|
|
137
|
+
applyMutated: (_mutated, original) => original ?? input,
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
return {
|
|
141
|
+
prompt: target.content,
|
|
142
|
+
applyMutated: (mutated, original) => {
|
|
143
|
+
// Re-derive the target index from the effective base array so an
|
|
144
|
+
// arbitrary `original` shape lands the mutation in the correct slot
|
|
145
|
+
// rather than the closed-over index from the initial extraction.
|
|
146
|
+
const base = Array.isArray(original) ? original : input;
|
|
147
|
+
const idx = findLatestUserStringIndex(base);
|
|
148
|
+
if (idx === -1) {
|
|
149
|
+
return base;
|
|
150
|
+
}
|
|
151
|
+
const out = [
|
|
152
|
+
...base,
|
|
153
|
+
];
|
|
154
|
+
const existing = out[idx];
|
|
155
|
+
if (isUserStringMessage(existing)) {
|
|
156
|
+
out[idx] = {
|
|
157
|
+
...existing,
|
|
158
|
+
content: mutated,
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
return out;
|
|
162
|
+
},
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
return {
|
|
166
|
+
prompt: undefined,
|
|
167
|
+
applyMutated: (_mutated, original) => original ?? input,
|
|
168
|
+
};
|
|
169
|
+
}
|
|
47
170
|
/**
|
|
48
171
|
* A wrapper around a streaming response that provides multiple consumption patterns.
|
|
49
172
|
*
|
|
@@ -75,6 +198,7 @@ export class ModelResult {
|
|
|
75
198
|
this.allToolExecutionRounds = [];
|
|
76
199
|
// Track resolved request after async function resolution
|
|
77
200
|
this.resolvedRequest = null;
|
|
201
|
+
this.resumingFromClientTools = false;
|
|
78
202
|
// State management for multi-turn conversations
|
|
79
203
|
this.stateAccessor = null;
|
|
80
204
|
this.currentState = null;
|
|
@@ -88,7 +212,33 @@ export class ModelResult {
|
|
|
88
212
|
this.initialPipePromise = null;
|
|
89
213
|
// Context store for typed tool context (persists across turns)
|
|
90
214
|
this.contextStore = null;
|
|
215
|
+
// Tracks whether SessionStart has already been emitted, so SessionEnd can be
|
|
216
|
+
// guarded to fire only when a matching SessionStart actually succeeded.
|
|
217
|
+
// Without this, an exception in initStream before SessionStart would lead to
|
|
218
|
+
// a dangling SessionEnd (breaking audit-log / resource-pair contracts).
|
|
219
|
+
this.sessionStartEmitted = false;
|
|
220
|
+
// Pairs with sessionStartEmitted: guards emitSessionEndOnce so the
|
|
221
|
+
// tool-execution path and the no-tools stream paths can both attempt the
|
|
222
|
+
// emit without double-firing.
|
|
223
|
+
this.sessionEndEmitted = false;
|
|
224
|
+
// Tool call ids the PermissionRequest hook denied without pausing. The
|
|
225
|
+
// normal tool round consults this to synthesize rejected outputs instead of
|
|
226
|
+
// executing the calls.
|
|
227
|
+
this.hookDeniedCalls = new Map();
|
|
228
|
+
// Running aggregate across every PostModelCall emit; surfaced as
|
|
229
|
+
// SessionEnd.totalUsage.
|
|
230
|
+
this.sessionUsage = {
|
|
231
|
+
modelCalls: 0,
|
|
232
|
+
inputTokens: 0,
|
|
233
|
+
outputTokens: 0,
|
|
234
|
+
totalTokens: 0,
|
|
235
|
+
cachedTokens: 0,
|
|
236
|
+
reasoningTokens: 0,
|
|
237
|
+
cost: 0,
|
|
238
|
+
hasCost: false,
|
|
239
|
+
};
|
|
91
240
|
this.options = options;
|
|
241
|
+
this.hooksManager = options.hooks;
|
|
92
242
|
// Runtime validation: approval decisions require state
|
|
93
243
|
const hasApprovalDecisions = (options.approveToolCalls && options.approveToolCalls.length > 0) ||
|
|
94
244
|
(options.rejectToolCalls && options.rejectToolCalls.length > 0);
|
|
@@ -183,14 +333,24 @@ export class ModelResult {
|
|
|
183
333
|
}
|
|
184
334
|
return completedResponse;
|
|
185
335
|
}
|
|
336
|
+
/**
|
|
337
|
+
* Resolve a tool's result `source` from its call name by looking it up in the
|
|
338
|
+
* configured tools. Used where the concrete tool reference isn't in scope
|
|
339
|
+
* (e.g. a rejected execution). Defaults to `'client'` when not found.
|
|
340
|
+
*/
|
|
341
|
+
toolSourceByName(name) {
|
|
342
|
+
const matched = this.options.tools?.find((t) => isClientTool(t) && t.function.name === name);
|
|
343
|
+
return matched !== undefined && isMcpTool(matched) ? 'mcp' : 'client';
|
|
344
|
+
}
|
|
186
345
|
/**
|
|
187
346
|
* Push a tool result event to both the legacy tool event broadcaster
|
|
188
347
|
* and the unified turn broadcaster.
|
|
189
348
|
*/
|
|
190
|
-
broadcastToolResult(toolCallId, result, preliminaryResults) {
|
|
349
|
+
broadcastToolResult(toolCallId, source, result, preliminaryResults) {
|
|
191
350
|
this.toolEventBroadcaster?.push({
|
|
192
351
|
type: 'tool_result',
|
|
193
352
|
toolCallId,
|
|
353
|
+
source,
|
|
194
354
|
result,
|
|
195
355
|
...(preliminaryResults?.length && {
|
|
196
356
|
preliminaryResults,
|
|
@@ -199,6 +359,7 @@ export class ModelResult {
|
|
|
199
359
|
this.turnBroadcaster?.push({
|
|
200
360
|
type: 'tool.result',
|
|
201
361
|
toolCallId,
|
|
362
|
+
source,
|
|
202
363
|
result,
|
|
203
364
|
timestamp: Date.now(),
|
|
204
365
|
...(preliminaryResults?.length && {
|
|
@@ -253,6 +414,24 @@ export class ModelResult {
|
|
|
253
414
|
isNonStreamingResponse(value) {
|
|
254
415
|
return (value !== null && typeof value === 'object' && 'output' in value && !isEventStream(value));
|
|
255
416
|
}
|
|
417
|
+
/**
|
|
418
|
+
* Materialize a betaResponsesSend result value into a completed response:
|
|
419
|
+
* streaming values are consumed to completion (through the turn
|
|
420
|
+
* broadcaster when one is attached), non-streaming values pass through.
|
|
421
|
+
*/
|
|
422
|
+
async materializeResponse(value, turnNumber) {
|
|
423
|
+
if (isEventStream(value)) {
|
|
424
|
+
const stream = new ReusableReadableStream(value);
|
|
425
|
+
if (this.turnBroadcaster) {
|
|
426
|
+
return this.pipeAndConsumeStream(stream, turnNumber);
|
|
427
|
+
}
|
|
428
|
+
return consumeStreamForCompletion(stream);
|
|
429
|
+
}
|
|
430
|
+
if (this.isNonStreamingResponse(value)) {
|
|
431
|
+
return value;
|
|
432
|
+
}
|
|
433
|
+
throw new Error('Unexpected response type from API');
|
|
434
|
+
}
|
|
256
435
|
// =========================================================================
|
|
257
436
|
// Extracted Helper Methods for executeToolsIfNeeded
|
|
258
437
|
// =========================================================================
|
|
@@ -268,7 +447,9 @@ export class ModelResult {
|
|
|
268
447
|
return this.finalResponse;
|
|
269
448
|
}
|
|
270
449
|
if (this.reusableStream) {
|
|
271
|
-
|
|
450
|
+
const response = await consumeStreamForCompletion(this.reusableStream);
|
|
451
|
+
await this.emitPendingModelCallOnce(response);
|
|
452
|
+
return response;
|
|
272
453
|
}
|
|
273
454
|
throw new Error('Neither stream nor response initialized');
|
|
274
455
|
}
|
|
@@ -295,10 +476,21 @@ export class ModelResult {
|
|
|
295
476
|
messages = appendToMessages(messages, this.pendingFreshItems);
|
|
296
477
|
this.pendingFreshItems = undefined;
|
|
297
478
|
}
|
|
298
|
-
|
|
479
|
+
const stateUpdates = {
|
|
299
480
|
messages: appendToMessages(messages, outputItems),
|
|
300
481
|
previousResponseId: response.id,
|
|
301
|
-
}
|
|
482
|
+
};
|
|
483
|
+
if (this.resumingFromClientTools) {
|
|
484
|
+
this.currentState = {
|
|
485
|
+
...this.currentState,
|
|
486
|
+
};
|
|
487
|
+
this.clearOptionalStateProperties([
|
|
488
|
+
'pendingToolCalls',
|
|
489
|
+
]);
|
|
490
|
+
this.resumingFromClientTools = false;
|
|
491
|
+
stateUpdates.status = 'in_progress';
|
|
492
|
+
}
|
|
493
|
+
await this.saveStateSafely(stateUpdates);
|
|
302
494
|
}
|
|
303
495
|
/**
|
|
304
496
|
* Mark state as complete.
|
|
@@ -352,6 +544,57 @@ export class ModelResult {
|
|
|
352
544
|
this.finalResponse = currentResponse;
|
|
353
545
|
return true;
|
|
354
546
|
}
|
|
547
|
+
/**
|
|
548
|
+
* Inject a user-role message into the conversation state and into the
|
|
549
|
+
* accumulated request input, so the next turn picks it up. Used by the
|
|
550
|
+
* Stop hook's `appendPrompt` to nudge the model without forcing a resume.
|
|
551
|
+
*
|
|
552
|
+
* This advances observable state (messages/input change) so the next
|
|
553
|
+
* iteration of the execution loop is not a no-op.
|
|
554
|
+
*/
|
|
555
|
+
async injectAppendPromptMessage(prompt) {
|
|
556
|
+
const injectedMessage = {
|
|
557
|
+
role: 'user',
|
|
558
|
+
content: prompt,
|
|
559
|
+
};
|
|
560
|
+
if (this.currentState) {
|
|
561
|
+
// Mutate the in-memory state directly so loop progress is observable
|
|
562
|
+
// even when no StateAccessor is configured (forceResume needs state to
|
|
563
|
+
// change to avoid looping). Persist when an accessor is available.
|
|
564
|
+
const nextMessages = appendToMessages(this.currentState.messages, [
|
|
565
|
+
injectedMessage,
|
|
566
|
+
]);
|
|
567
|
+
this.currentState = updateState(this.currentState, {
|
|
568
|
+
messages: nextMessages,
|
|
569
|
+
});
|
|
570
|
+
if (this.stateAccessor) {
|
|
571
|
+
await this.saveStateSafely();
|
|
572
|
+
}
|
|
573
|
+
}
|
|
574
|
+
if (this.resolvedRequest) {
|
|
575
|
+
const currentInput = this.resolvedRequest.input;
|
|
576
|
+
const nextInput = Array.isArray(currentInput)
|
|
577
|
+
? [
|
|
578
|
+
...currentInput,
|
|
579
|
+
injectedMessage,
|
|
580
|
+
]
|
|
581
|
+
: currentInput
|
|
582
|
+
? [
|
|
583
|
+
{
|
|
584
|
+
role: 'user',
|
|
585
|
+
content: currentInput,
|
|
586
|
+
},
|
|
587
|
+
injectedMessage,
|
|
588
|
+
]
|
|
589
|
+
: [
|
|
590
|
+
injectedMessage,
|
|
591
|
+
];
|
|
592
|
+
this.resolvedRequest = {
|
|
593
|
+
...this.resolvedRequest,
|
|
594
|
+
input: nextInput,
|
|
595
|
+
};
|
|
596
|
+
}
|
|
597
|
+
}
|
|
355
598
|
/**
|
|
356
599
|
* Check if stop conditions are met.
|
|
357
600
|
* Returns true if execution should stop.
|
|
@@ -383,11 +626,18 @@ export class ModelResult {
|
|
|
383
626
|
// `toolResults` is client-tool-centric; server-tool output items are
|
|
384
627
|
// surfaced on `serverToolResults` so stop conditions can react to
|
|
385
628
|
// either class of result.
|
|
386
|
-
toolResults: round.toolResults.filter(isFunctionCallOutput).map((tr) =>
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
629
|
+
toolResults: round.toolResults.filter(isFunctionCallOutput).map((tr) => {
|
|
630
|
+
const toolName = round.toolCalls.find((tc) => tc.id === tr.callId)?.name ?? '';
|
|
631
|
+
const matchedTool = this.options.tools?.find((t) => isClientTool(t) && t.function.name === toolName);
|
|
632
|
+
return {
|
|
633
|
+
toolCallId: tr.callId,
|
|
634
|
+
toolName,
|
|
635
|
+
source: matchedTool !== undefined && isMcpTool(matchedTool)
|
|
636
|
+
? 'mcp'
|
|
637
|
+
: 'client',
|
|
638
|
+
result: typeof tr.output === 'string' ? JSON.parse(tr.output) : tr.output,
|
|
639
|
+
};
|
|
640
|
+
}),
|
|
391
641
|
serverToolResults: round.toolResults.filter(isServerToolResult),
|
|
392
642
|
response: round.response,
|
|
393
643
|
usage: round.response.usage,
|
|
@@ -423,6 +673,422 @@ export class ModelResult {
|
|
|
423
673
|
const tool = this.options.tools?.find((t) => isClientTool(t) && t.function.name === item.name);
|
|
424
674
|
return !!tool && !isAutoResolvableTool(tool);
|
|
425
675
|
}
|
|
676
|
+
/**
|
|
677
|
+
* Shared helper: execute a single tool and emit the full Pre/Post lifecycle
|
|
678
|
+
* hooks around it.
|
|
679
|
+
*
|
|
680
|
+
* Every code path that ultimately calls `executeTool()` for a user-visible
|
|
681
|
+
* tool call funnels through here so that PreToolUse/PostToolUse/
|
|
682
|
+
* PostToolUseFailure fire consistently — regardless of whether the tool was
|
|
683
|
+
* auto-executed, required approval, or was approved later.
|
|
684
|
+
*
|
|
685
|
+
* Return shape:
|
|
686
|
+
* - `parse_error`: `toolCall.arguments` was a raw JSON string the model
|
|
687
|
+
* failed to produce valid JSON for. The caller should use the prebuilt
|
|
688
|
+
* FunctionCallOutputItem and not execute the tool. No hooks fire.
|
|
689
|
+
* - `hook_blocked`: PreToolUse returned `block` (boolean true or a reason
|
|
690
|
+
* string). The caller should synthesize a denied result without invoking
|
|
691
|
+
* the tool. The FunctionCallOutputItem is prebuilt for convenience.
|
|
692
|
+
* - `execution`: The tool ran. `result` is the ToolExecutionResult.
|
|
693
|
+
* `effectiveToolCall` reflects any `mutatedInput` piped by PreToolUse.
|
|
694
|
+
*/
|
|
695
|
+
async runToolWithHooks(tool, toolCall, turnContext, onPreliminaryResult) {
|
|
696
|
+
// Reject raw-string arguments before any hook fires. When the model
|
|
697
|
+
// produces invalid JSON, the parser leaves `toolCall.arguments` as the
|
|
698
|
+
// raw string; handing that to PreToolUse would either fail payload
|
|
699
|
+
// validation (silent no-op in non-strict mode) or deliver a malformed
|
|
700
|
+
// `toolInput` to handlers. Fail closed here so every execution path
|
|
701
|
+
// (auto-approve, manual approval, approved-on-resume) gets a consistent
|
|
702
|
+
// synthetic error without running the tool or firing hooks.
|
|
703
|
+
const rawArgs = toolCall.arguments;
|
|
704
|
+
if (typeof rawArgs === 'string') {
|
|
705
|
+
const errorMessage = `Failed to parse tool call arguments for "${toolCall.name}": The model provided invalid JSON. ` +
|
|
706
|
+
`Raw arguments received: "${rawArgs}". ` +
|
|
707
|
+
'Please provide valid JSON arguments for this tool call.';
|
|
708
|
+
return {
|
|
709
|
+
type: 'parse_error',
|
|
710
|
+
toolCall,
|
|
711
|
+
errorMessage,
|
|
712
|
+
output: {
|
|
713
|
+
type: 'function_call_output',
|
|
714
|
+
id: `output_${toolCall.id}`,
|
|
715
|
+
callId: toolCall.id,
|
|
716
|
+
output: JSON.stringify({
|
|
717
|
+
error: errorMessage,
|
|
718
|
+
}),
|
|
719
|
+
},
|
|
720
|
+
};
|
|
721
|
+
}
|
|
722
|
+
let effectiveToolCall = toolCall;
|
|
723
|
+
// Emit PreToolUse hook -- can block or mutate input.
|
|
724
|
+
if (this.hooksManager) {
|
|
725
|
+
// The hook payload coerces null/undefined arguments to {} for schema
|
|
726
|
+
// validation, but `effectiveToolCall.arguments` only changes when the
|
|
727
|
+
// chain reports an actual mutation (`emit.mutated`), so tools that
|
|
728
|
+
// legitimately distinguish "no args" from "empty args" are unaffected.
|
|
729
|
+
const originalToolInput = (toolCall.arguments ?? {});
|
|
730
|
+
const preResult = await this.hooksManager.emit('PreToolUse', {
|
|
731
|
+
toolName: toolCall.name,
|
|
732
|
+
toolInput: originalToolInput,
|
|
733
|
+
}, this.hookEmitContext(toolCall.name));
|
|
734
|
+
if (preResult.blocked) {
|
|
735
|
+
// Every entry in `results` passed the PreToolUseResult schema (see
|
|
736
|
+
// EmitResult.results invariant), so no structural re-narrowing needed.
|
|
737
|
+
const block = preResult.results.find((r) => r.block)?.block;
|
|
738
|
+
const reason = typeof block === 'string' ? block : 'Blocked by PreToolUse hook';
|
|
739
|
+
return {
|
|
740
|
+
type: 'hook_blocked',
|
|
741
|
+
toolCall,
|
|
742
|
+
reason,
|
|
743
|
+
output: {
|
|
744
|
+
type: 'function_call_output',
|
|
745
|
+
id: `output_${toolCall.id}`,
|
|
746
|
+
callId: toolCall.id,
|
|
747
|
+
output: JSON.stringify({
|
|
748
|
+
error: reason,
|
|
749
|
+
}),
|
|
750
|
+
},
|
|
751
|
+
};
|
|
752
|
+
}
|
|
753
|
+
// Apply mutated input only when a handler actually piped a replacement
|
|
754
|
+
// (`emit.mutated`). Payload validation clones the object, so a
|
|
755
|
+
// reference comparison against the original would false-positive and
|
|
756
|
+
// coerce legitimately-null arguments to {}.
|
|
757
|
+
if (preResult.mutated) {
|
|
758
|
+
effectiveToolCall = {
|
|
759
|
+
...toolCall,
|
|
760
|
+
arguments: preResult.finalPayload.toolInput,
|
|
761
|
+
};
|
|
762
|
+
}
|
|
763
|
+
}
|
|
764
|
+
// performance.now() gives monotonic, sub-ms precision and is immune to
|
|
765
|
+
// system clock jumps, unlike Date.now().
|
|
766
|
+
const startTime = performance.now();
|
|
767
|
+
const result = await executeTool(tool, effectiveToolCall, turnContext, onPreliminaryResult, this.contextStore ?? undefined, this.options.sharedContextSchema);
|
|
768
|
+
const durationMs = performance.now() - startTime;
|
|
769
|
+
// HITL tools may pause (executeTool returns null). No output was produced
|
|
770
|
+
// yet, so neither PostToolUse nor PostToolUseFailure fires; they will fire
|
|
771
|
+
// if/when the tool is resumed and actually executes.
|
|
772
|
+
// Emit PostToolUse or PostToolUseFailure.
|
|
773
|
+
if (this.hooksManager && result !== null) {
|
|
774
|
+
if (result.error) {
|
|
775
|
+
await this.hooksManager.emit('PostToolUseFailure', {
|
|
776
|
+
toolName: effectiveToolCall.name,
|
|
777
|
+
toolInput: (effectiveToolCall.arguments ?? {}),
|
|
778
|
+
error: result.error,
|
|
779
|
+
}, this.hookEmitContext(effectiveToolCall.name));
|
|
780
|
+
}
|
|
781
|
+
else {
|
|
782
|
+
await this.hooksManager.emit('PostToolUse', {
|
|
783
|
+
toolName: effectiveToolCall.name,
|
|
784
|
+
toolInput: (effectiveToolCall.arguments ?? {}),
|
|
785
|
+
toolOutput: result.result,
|
|
786
|
+
durationMs,
|
|
787
|
+
}, this.hookEmitContext(effectiveToolCall.name));
|
|
788
|
+
}
|
|
789
|
+
}
|
|
790
|
+
return {
|
|
791
|
+
type: 'execution',
|
|
792
|
+
effectiveToolCall,
|
|
793
|
+
result,
|
|
794
|
+
};
|
|
795
|
+
}
|
|
796
|
+
/**
|
|
797
|
+
* Build the per-emit context for lifecycle hook emits. Threads this run's
|
|
798
|
+
* session identity into `context.sessionId` on every emit, so a
|
|
799
|
+
* `HooksManager` instance shared across concurrent runs never leaks one
|
|
800
|
+
* run's id into another's handlers (the manager-level `setSessionId`
|
|
801
|
+
* default is a single mutable field and would be clobbered by the last
|
|
802
|
+
* run to start).
|
|
803
|
+
*/
|
|
804
|
+
hookEmitContext(toolName) {
|
|
805
|
+
return {
|
|
806
|
+
...(toolName !== undefined && {
|
|
807
|
+
toolName,
|
|
808
|
+
}),
|
|
809
|
+
sessionId: this.currentState?.id ?? '',
|
|
810
|
+
};
|
|
811
|
+
}
|
|
812
|
+
/**
|
|
813
|
+
* Emit SessionEnd exactly once, and only when a matching SessionStart
|
|
814
|
+
* actually succeeded. Safe to call from multiple teardown paths.
|
|
815
|
+
*/
|
|
816
|
+
async emitSessionEndOnce(reason) {
|
|
817
|
+
if (!this.hooksManager || !this.sessionStartEmitted || this.sessionEndEmitted) {
|
|
818
|
+
return;
|
|
819
|
+
}
|
|
820
|
+
this.sessionEndEmitted = true;
|
|
821
|
+
await this.hooksManager.emit('SessionEnd', {
|
|
822
|
+
reason,
|
|
823
|
+
...(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
|
+
},
|
|
835
|
+
}),
|
|
836
|
+
}, this.hookEmitContext());
|
|
837
|
+
}
|
|
838
|
+
/**
|
|
839
|
+
* Emit PostModelCall for a completed model response and fold its usage
|
|
840
|
+
* into the session aggregate. One emit per materialized response.
|
|
841
|
+
*/
|
|
842
|
+
async emitPostModelCall(response, startedAt, turnType, turnNumber) {
|
|
843
|
+
if (!this.hooksManager) {
|
|
844
|
+
return;
|
|
845
|
+
}
|
|
846
|
+
const usage = extractModelCallUsage(response.usage);
|
|
847
|
+
this.sessionUsage.modelCalls++;
|
|
848
|
+
if (usage) {
|
|
849
|
+
this.sessionUsage.inputTokens += usage.inputTokens;
|
|
850
|
+
this.sessionUsage.outputTokens += usage.outputTokens;
|
|
851
|
+
this.sessionUsage.totalTokens += usage.totalTokens;
|
|
852
|
+
this.sessionUsage.cachedTokens += usage.cachedTokens;
|
|
853
|
+
this.sessionUsage.reasoningTokens += usage.reasoningTokens;
|
|
854
|
+
if (usage.cost !== undefined) {
|
|
855
|
+
this.sessionUsage.cost += usage.cost;
|
|
856
|
+
this.sessionUsage.hasCost = true;
|
|
857
|
+
}
|
|
858
|
+
}
|
|
859
|
+
await this.hooksManager.emit('PostModelCall', {
|
|
860
|
+
sessionId: this.currentState?.id ?? '',
|
|
861
|
+
responseId: response.id,
|
|
862
|
+
model: response.model ?? '',
|
|
863
|
+
durationMs: performance.now() - startedAt,
|
|
864
|
+
turnType,
|
|
865
|
+
turnNumber,
|
|
866
|
+
...(usage && {
|
|
867
|
+
usage,
|
|
868
|
+
}),
|
|
869
|
+
}, this.hookEmitContext());
|
|
870
|
+
}
|
|
871
|
+
/**
|
|
872
|
+
* Emit the parked initial/resume PostModelCall once its response has been
|
|
873
|
+
* materialized. No-ops when nothing is parked (e.g. the non-streaming
|
|
874
|
+
* branch already emitted). Safe to call from multiple materialization
|
|
875
|
+
* sites; the first wins.
|
|
876
|
+
*/
|
|
877
|
+
async emitPendingModelCallOnce(response) {
|
|
878
|
+
const pending = this.pendingModelCall;
|
|
879
|
+
if (!pending) {
|
|
880
|
+
return;
|
|
881
|
+
}
|
|
882
|
+
this.pendingModelCall = undefined;
|
|
883
|
+
await this.emitPostModelCall(response, pending.startedAt, pending.turnType, pending.turnNumber);
|
|
884
|
+
}
|
|
885
|
+
/**
|
|
886
|
+
* Emit the Stop hook when a stopWhen condition halts the loop, and decide
|
|
887
|
+
* whether the loop should resume.
|
|
888
|
+
*
|
|
889
|
+
* - `appendPrompt` values from all handlers are concatenated (newline
|
|
890
|
+
* separated) and injected as a user message. Honored independently of
|
|
891
|
+
* forceResume so a handler can nudge the next turn without resuming.
|
|
892
|
+
* - `forceResume` is honored if ANY handler returns it, capped at
|
|
893
|
+
* MAX_FORCE_RESUME_OVERRIDES consecutive overrides without tool
|
|
894
|
+
* progress (the caller resets its counter when a tool round or a fresh
|
|
895
|
+
* response lands).
|
|
896
|
+
*
|
|
897
|
+
* Returns 'resume' when the loop should continue, 'stop' otherwise.
|
|
898
|
+
*/
|
|
899
|
+
async runStopHook(forceResumeCount) {
|
|
900
|
+
if (!this.hooksManager) {
|
|
901
|
+
return 'stop';
|
|
902
|
+
}
|
|
903
|
+
// shouldStopExecution() is driven by stopWhen conditions (default
|
|
904
|
+
// stepCountIs), so 'max_turns' is the semantically accurate reason.
|
|
905
|
+
const stopResult = await this.hooksManager.emit('Stop', {
|
|
906
|
+
reason: 'max_turns',
|
|
907
|
+
}, this.hookEmitContext());
|
|
908
|
+
// Every entry in `results` passed the StopResult schema (see
|
|
909
|
+
// EmitResult.results invariant), so the fields can be read directly.
|
|
910
|
+
const shouldForceResume = stopResult.results.some((r) => r.forceResume === true);
|
|
911
|
+
const appendPrompt = stopResult.results
|
|
912
|
+
.map((r) => r.appendPrompt)
|
|
913
|
+
.filter((p) => typeof p === 'string' && p.length > 0)
|
|
914
|
+
.join('\n');
|
|
915
|
+
if (appendPrompt) {
|
|
916
|
+
await this.injectAppendPromptMessage(appendPrompt);
|
|
917
|
+
}
|
|
918
|
+
if (!shouldForceResume) {
|
|
919
|
+
return 'stop';
|
|
920
|
+
}
|
|
921
|
+
if (forceResumeCount >= MAX_FORCE_RESUME_OVERRIDES) {
|
|
922
|
+
// Don't let the hook loop the engine forever. Log and stop.
|
|
923
|
+
console.warn(`[Stop hook] forceResume honored ${MAX_FORCE_RESUME_OVERRIDES} times without new progress; stopping to prevent an infinite loop.`);
|
|
924
|
+
return 'stop';
|
|
925
|
+
}
|
|
926
|
+
return 'resume';
|
|
927
|
+
}
|
|
928
|
+
/**
|
|
929
|
+
* Session teardown for the no-tools stream paths, which bypass
|
|
930
|
+
* executeToolsIfNeeded (the normal SessionEnd site). Emits SessionEnd once
|
|
931
|
+
* and drains pending hook work. Never throws: teardown must not mask the
|
|
932
|
+
* stream's own outcome.
|
|
933
|
+
*/
|
|
934
|
+
async finishHooksSessionForStream(reason = 'complete') {
|
|
935
|
+
if (!this.hooksManager) {
|
|
936
|
+
return;
|
|
937
|
+
}
|
|
938
|
+
try {
|
|
939
|
+
// Materialize the parked initial-call telemetry when the stream fully
|
|
940
|
+
// completed (the retained buffer replays without touching the source).
|
|
941
|
+
// A failed/errored stream skips it: no materialized response exists.
|
|
942
|
+
// (`response.incomplete` responses DO emit — they are materialized, have
|
|
943
|
+
// a generation id, and consumed tokens.)
|
|
944
|
+
// Isolated try: a telemetry failure (e.g. a buffer without a completion
|
|
945
|
+
// event, or a throwing strict-mode handler) must not skip SessionEnd or
|
|
946
|
+
// the drain below — those are contractual on every exit path.
|
|
947
|
+
try {
|
|
948
|
+
if (this.pendingModelCall) {
|
|
949
|
+
if (this.finalResponse) {
|
|
950
|
+
await this.emitPendingModelCallOnce(this.finalResponse);
|
|
951
|
+
}
|
|
952
|
+
else if (this.reusableStream?.isComplete) {
|
|
953
|
+
await this.emitPendingModelCallOnce(await consumeStreamForCompletion(this.reusableStream));
|
|
954
|
+
}
|
|
955
|
+
}
|
|
956
|
+
}
|
|
957
|
+
catch (telemetryError) {
|
|
958
|
+
console.warn('[PostModelCall] error during stream teardown:', telemetryError);
|
|
959
|
+
}
|
|
960
|
+
await this.emitSessionEndOnce(reason);
|
|
961
|
+
await this.hooksManager.drain();
|
|
962
|
+
}
|
|
963
|
+
catch (teardownError) {
|
|
964
|
+
console.warn('[SessionEnd] error during stream teardown:', teardownError);
|
|
965
|
+
}
|
|
966
|
+
}
|
|
967
|
+
/**
|
|
968
|
+
* initStream wrapper for the streaming getters. initStream can throw
|
|
969
|
+
* after SessionStart was emitted (e.g. the initial API call fails), and
|
|
970
|
+
* the "Stream not initialized" guard can throw right after it — on both
|
|
971
|
+
* paths the hook session must still be torn down (SessionEnd + drain),
|
|
972
|
+
* otherwise Start/End handlers that treat the pair as a contract see a
|
|
973
|
+
* dangling Start. Teardown is emit-once, so the tools path (which runs
|
|
974
|
+
* its own teardown in executeToolsIfNeeded) is unaffected.
|
|
975
|
+
*/
|
|
976
|
+
async initStreamGuarded(options) {
|
|
977
|
+
// The not-initialized invariant applies to streaming getters only:
|
|
978
|
+
// state-inspection methods (getPendingToolCalls/getState/...) are valid
|
|
979
|
+
// on paused resumes (awaiting_hitl/awaiting_approval) where initStream
|
|
980
|
+
// returns early with neither a stream nor a finalResponse.
|
|
981
|
+
const requireStream = options?.requireStream ?? true;
|
|
982
|
+
try {
|
|
983
|
+
await this.initStream();
|
|
984
|
+
if (requireStream && !this.reusableStream && !this.finalResponse) {
|
|
985
|
+
throw new Error('Stream not initialized');
|
|
986
|
+
}
|
|
987
|
+
}
|
|
988
|
+
catch (error) {
|
|
989
|
+
await this.finishHooksSessionForStream('error');
|
|
990
|
+
throw error;
|
|
991
|
+
}
|
|
992
|
+
}
|
|
993
|
+
/**
|
|
994
|
+
* Emit the PermissionRequest hook before the SDK blocks for user approval.
|
|
995
|
+
*
|
|
996
|
+
* Returns the hook's collective decision:
|
|
997
|
+
* - `allow`: the tool should proceed as if auto-approved (skip approval gate)
|
|
998
|
+
* - `deny`: the tool should NOT run; caller should produce a denied result
|
|
999
|
+
* - `ask_user`: fall through to the existing approval flow (the default)
|
|
1000
|
+
*
|
|
1001
|
+
* Last-wins when multiple handlers return conflicting decisions.
|
|
1002
|
+
*/
|
|
1003
|
+
async emitPermissionRequest(toolCall) {
|
|
1004
|
+
if (!this.hooksManager) {
|
|
1005
|
+
return {
|
|
1006
|
+
decision: 'ask_user',
|
|
1007
|
+
};
|
|
1008
|
+
}
|
|
1009
|
+
// Raw-string arguments mean the model produced invalid JSON. Fail closed
|
|
1010
|
+
// (fall through to the human approval flow) rather than emitting a
|
|
1011
|
+
// malformed payload the schema would reject anyway -- mirrors the guard
|
|
1012
|
+
// in runToolWithHooks.
|
|
1013
|
+
if (typeof toolCall.arguments === 'string') {
|
|
1014
|
+
return {
|
|
1015
|
+
decision: 'ask_user',
|
|
1016
|
+
};
|
|
1017
|
+
}
|
|
1018
|
+
// Derive risk level from the approval gate's shape: a callback (tool- or
|
|
1019
|
+
// call-level requireApproval function) => 'high' (caller actively
|
|
1020
|
+
// decides per call), blanket true => 'medium', otherwise 'low'.
|
|
1021
|
+
const tool = this.options.tools?.find((t) => isClientTool(t) && t.function.name === toolCall.name);
|
|
1022
|
+
const requireApproval = tool && isClientTool(tool) ? tool.function.requireApproval : undefined;
|
|
1023
|
+
const riskLevel = typeof requireApproval === 'function' || this.requireApprovalFn
|
|
1024
|
+
? 'high'
|
|
1025
|
+
: requireApproval === true
|
|
1026
|
+
? 'medium'
|
|
1027
|
+
: 'low';
|
|
1028
|
+
const emit = await this.hooksManager.emit('PermissionRequest', {
|
|
1029
|
+
toolName: toolCall.name,
|
|
1030
|
+
toolInput: (toolCall.arguments ?? {}),
|
|
1031
|
+
riskLevel,
|
|
1032
|
+
}, this.hookEmitContext(toolCall.name));
|
|
1033
|
+
// Last-wins: if multiple handlers disagree, the most recently registered
|
|
1034
|
+
// handler dictates the outcome. This is documented and intentional —
|
|
1035
|
+
// callers that want stricter semantics should register a single final
|
|
1036
|
+
// handler (or use `throwOnHandlerError` to surface conflicts in tests).
|
|
1037
|
+
// Every entry in `results` passed the PermissionRequestResult schema
|
|
1038
|
+
// (`decision` is a required enum), so it can be read directly.
|
|
1039
|
+
const last = emit.results.at(-1);
|
|
1040
|
+
if (!last) {
|
|
1041
|
+
return {
|
|
1042
|
+
decision: 'ask_user',
|
|
1043
|
+
};
|
|
1044
|
+
}
|
|
1045
|
+
return {
|
|
1046
|
+
decision: last.decision,
|
|
1047
|
+
...(last.reason !== undefined && {
|
|
1048
|
+
reason: last.reason,
|
|
1049
|
+
}),
|
|
1050
|
+
};
|
|
1051
|
+
}
|
|
1052
|
+
/**
|
|
1053
|
+
* Run the UserPromptSubmit hook, supporting both string and structured
|
|
1054
|
+
* inputs. If a handler returns a mutated prompt, the returned object
|
|
1055
|
+
* applies the mutation back to the original input shape (string in =
|
|
1056
|
+
* string out, message array in = message array out with the latest user
|
|
1057
|
+
* text replaced).
|
|
1058
|
+
*
|
|
1059
|
+
* Throws if any handler rejects the prompt.
|
|
1060
|
+
*
|
|
1061
|
+
* Returns `undefined` when the hook does nothing, or when no usable prompt
|
|
1062
|
+
* could be extracted from a structured input (handler is skipped and a
|
|
1063
|
+
* one-time `console.warn` in dev builds explains why).
|
|
1064
|
+
*/
|
|
1065
|
+
async maybeRunUserPromptSubmit(currentInput) {
|
|
1066
|
+
if (!this.hooksManager || currentInput === undefined) {
|
|
1067
|
+
return undefined;
|
|
1068
|
+
}
|
|
1069
|
+
const { prompt, applyMutated } = extractPromptAndApplier(currentInput);
|
|
1070
|
+
if (prompt === undefined) {
|
|
1071
|
+
if (process.env['NODE_ENV'] !== 'production') {
|
|
1072
|
+
console.warn('[UserPromptSubmit] Could not extract a user prompt from structured input; skipping hook.');
|
|
1073
|
+
}
|
|
1074
|
+
return undefined;
|
|
1075
|
+
}
|
|
1076
|
+
const emit = await this.hooksManager.emit('UserPromptSubmit', {
|
|
1077
|
+
prompt,
|
|
1078
|
+
}, this.hookEmitContext());
|
|
1079
|
+
if (emit.blocked) {
|
|
1080
|
+
// Every entry in `results` passed the UserPromptSubmitResult schema.
|
|
1081
|
+
const reject = emit.results.find((r) => r.reject)?.reject;
|
|
1082
|
+
throw new Error(typeof reject === 'string' ? reject : 'Prompt rejected by hook');
|
|
1083
|
+
}
|
|
1084
|
+
if (!emit.mutated) {
|
|
1085
|
+
return undefined;
|
|
1086
|
+
}
|
|
1087
|
+
const mutated = emit.finalPayload.prompt;
|
|
1088
|
+
return {
|
|
1089
|
+
applyTo: (original) => applyMutated(mutated, original),
|
|
1090
|
+
};
|
|
1091
|
+
}
|
|
426
1092
|
/**
|
|
427
1093
|
* Execute tools that can auto-execute (don't require approval) in parallel.
|
|
428
1094
|
*
|
|
@@ -436,7 +1102,20 @@ export class ModelResult {
|
|
|
436
1102
|
if (!tool || !isAutoResolvableTool(tool)) {
|
|
437
1103
|
return null;
|
|
438
1104
|
}
|
|
439
|
-
|
|
1105
|
+
// Route through runToolWithHooks so PreToolUse/PostToolUse fire even on
|
|
1106
|
+
// the auto-approve path. `runToolWithHooks` also fails closed on raw
|
|
1107
|
+
// JSON-parse failures so hooks never see a malformed payload.
|
|
1108
|
+
const hookOutcome = await this.runToolWithHooks(tool, tc, turnContext);
|
|
1109
|
+
if (hookOutcome.type === 'parse_error') {
|
|
1110
|
+
this.broadcastToolResult(tc.id, isMcpTool(tool) ? 'mcp' : 'client', {
|
|
1111
|
+
error: hookOutcome.errorMessage,
|
|
1112
|
+
});
|
|
1113
|
+
return createRejectedResult(tc.id, String(tc.name), hookOutcome.errorMessage);
|
|
1114
|
+
}
|
|
1115
|
+
if (hookOutcome.type === 'hook_blocked') {
|
|
1116
|
+
return createRejectedResult(tc.id, String(tc.name), hookOutcome.reason);
|
|
1117
|
+
}
|
|
1118
|
+
const result = hookOutcome.result;
|
|
440
1119
|
if (result === null) {
|
|
441
1120
|
// HITL tool paused — no unsent result for this call in this round
|
|
442
1121
|
return null;
|
|
@@ -484,24 +1163,71 @@ export class ModelResult {
|
|
|
484
1163
|
// context is handled via contextStore, not on TurnContext
|
|
485
1164
|
};
|
|
486
1165
|
const { requiresApproval: needsApproval, autoExecute } = await partitionToolCalls(toolCalls, this.options.tools, turnContext, this.requireApprovalFn ?? undefined);
|
|
1166
|
+
// Nothing needs an approval gate: return immediately WITHOUT executing
|
|
1167
|
+
// anything. The main loop's executeToolRound runs every call exactly
|
|
1168
|
+
// once; pre-executing here would double-run side-effecting tools.
|
|
487
1169
|
if (needsApproval.length === 0) {
|
|
488
1170
|
return false;
|
|
489
1171
|
}
|
|
490
|
-
//
|
|
1172
|
+
// Run the PermissionRequest hook for each tool that needs approval.
|
|
1173
|
+
// This lets hooks short-circuit the approval flow in either direction:
|
|
1174
|
+
// 'allow' promotes the call past the gate (executed once by the normal
|
|
1175
|
+
// round), 'deny' synthesizes a rejection (recorded so the round emits a
|
|
1176
|
+
// rejected output instead of executing), 'ask_user' falls through to the
|
|
1177
|
+
// human approval flow.
|
|
1178
|
+
const denied = [];
|
|
1179
|
+
const stillPending = [];
|
|
1180
|
+
if (this.hooksManager) {
|
|
1181
|
+
for (const tc of needsApproval) {
|
|
1182
|
+
const { decision, reason } = await this.emitPermissionRequest(tc);
|
|
1183
|
+
if (decision === 'allow') {
|
|
1184
|
+
// Promoted past the gate; the normal tool round executes it once.
|
|
1185
|
+
}
|
|
1186
|
+
else if (decision === 'deny') {
|
|
1187
|
+
denied.push({
|
|
1188
|
+
tc,
|
|
1189
|
+
reason: reason ?? 'Denied by PermissionRequest hook',
|
|
1190
|
+
});
|
|
1191
|
+
}
|
|
1192
|
+
else {
|
|
1193
|
+
stillPending.push(tc);
|
|
1194
|
+
}
|
|
1195
|
+
}
|
|
1196
|
+
}
|
|
1197
|
+
else {
|
|
1198
|
+
stillPending.push(...needsApproval);
|
|
1199
|
+
}
|
|
1200
|
+
if (stillPending.length === 0) {
|
|
1201
|
+
// The hook resolved every gated call, so we do not pause. Record denied
|
|
1202
|
+
// calls so executeToolRound synthesizes rejections instead of running
|
|
1203
|
+
// them; allowed calls execute once via the normal round.
|
|
1204
|
+
for (const d of denied) {
|
|
1205
|
+
this.hookDeniedCalls.set(d.tc.id, d.reason);
|
|
1206
|
+
}
|
|
1207
|
+
return false;
|
|
1208
|
+
}
|
|
1209
|
+
// Validate: pausing for approval requires a state accessor.
|
|
491
1210
|
if (!this.stateAccessor) {
|
|
492
|
-
const toolNames =
|
|
1211
|
+
const toolNames = stillPending.map((tc) => tc.name).join(', ');
|
|
493
1212
|
throw new Error(`Tool(s) require approval but no state accessor is configured: ${toolNames}. ` +
|
|
494
1213
|
'Provide a StateAccessor via the "state" parameter to enable approval workflows.');
|
|
495
1214
|
}
|
|
496
|
-
//
|
|
1215
|
+
// We are pausing: the normal tool round will NOT run for this response,
|
|
1216
|
+
// so execute the auto-approved calls now and persist their results as
|
|
1217
|
+
// unsent so the resume path can pick them up without re-executing.
|
|
497
1218
|
const unsentResults = await this.executeAutoApproveTools(autoExecute, turnContext);
|
|
498
|
-
//
|
|
1219
|
+
// Combine pre-denied results (from hook "deny") with executed results.
|
|
1220
|
+
const combinedResults = [
|
|
1221
|
+
...unsentResults,
|
|
1222
|
+
...denied.map((d) => createRejectedResult(d.tc.id, String(d.tc.name), d.reason)),
|
|
1223
|
+
];
|
|
1224
|
+
// Save state with pending approvals (only reached when stillPending > 0).
|
|
499
1225
|
const stateUpdates = {
|
|
500
|
-
pendingToolCalls:
|
|
1226
|
+
pendingToolCalls: stillPending,
|
|
501
1227
|
status: 'awaiting_approval',
|
|
502
1228
|
};
|
|
503
|
-
if (
|
|
504
|
-
stateUpdates.unsentToolResults =
|
|
1229
|
+
if (combinedResults.length > 0) {
|
|
1230
|
+
stateUpdates.unsentToolResults = combinedResults;
|
|
505
1231
|
}
|
|
506
1232
|
await this.saveStateSafely(stateUpdates);
|
|
507
1233
|
this.finalResponse = currentResponse;
|
|
@@ -533,6 +1259,41 @@ export class ModelResult {
|
|
|
533
1259
|
};
|
|
534
1260
|
await this.saveStateSafely(stateUpdates);
|
|
535
1261
|
}
|
|
1262
|
+
/**
|
|
1263
|
+
* Persist state when the loop stops due to unresolved manual (client-executed)
|
|
1264
|
+
* tool calls — tools with neither `execute` nor `onToolCalled`.
|
|
1265
|
+
*
|
|
1266
|
+
* Mirrors `persistHitlPause` so callers can read the unresolved calls via
|
|
1267
|
+
* `getPendingToolCalls()` / `getState()` after the loop ends. Uses the
|
|
1268
|
+
* distinct status `awaiting_client_tools` so consumers can discriminate
|
|
1269
|
+
* manual pauses from HITL pauses (`awaiting_hitl`).
|
|
1270
|
+
*
|
|
1271
|
+
* Resume behavior: `awaiting_client_tools` is intentionally NOT treated as a
|
|
1272
|
+
* resumable status for `processApprovalDecisions` — manual tools are not
|
|
1273
|
+
* approved/rejected via call IDs; the caller executes them externally and
|
|
1274
|
+
* typically supplies `function_call_output` items as new input on the next
|
|
1275
|
+
* `callModel`. The paused status and calls remain durable until that request
|
|
1276
|
+
* produces a response; they are then cleared atomically with the response.
|
|
1277
|
+
*
|
|
1278
|
+
* Without a `StateAccessor` nothing is persisted (mirroring
|
|
1279
|
+
* `persistHitlPause`): `getPendingToolCalls()` returns `[]` and the caller
|
|
1280
|
+
* must read the unresolved `function_call` items off `getResponse().output`.
|
|
1281
|
+
* Manual tools require a StateAccessor to be recoverable across processes.
|
|
1282
|
+
*
|
|
1283
|
+
* @param currentResponse - The response that produced the unresolved calls
|
|
1284
|
+
* @param unresolvedCalls - Manual (or otherwise non-auto-resolvable) tool calls
|
|
1285
|
+
*/
|
|
1286
|
+
async persistClientToolsPause(currentResponse, unresolvedCalls) {
|
|
1287
|
+
this.finalResponse = currentResponse;
|
|
1288
|
+
if (!this.stateAccessor || unresolvedCalls.length === 0) {
|
|
1289
|
+
return;
|
|
1290
|
+
}
|
|
1291
|
+
const stateUpdates = {
|
|
1292
|
+
pendingToolCalls: unresolvedCalls,
|
|
1293
|
+
status: 'awaiting_client_tools',
|
|
1294
|
+
};
|
|
1295
|
+
await this.saveStateSafely(stateUpdates);
|
|
1296
|
+
}
|
|
536
1297
|
/**
|
|
537
1298
|
* Compute the `output` payload sent to the model for a successfully
|
|
538
1299
|
* settled tool execution. Routes through `toModelOutput` when the tool
|
|
@@ -578,60 +1339,76 @@ export class ModelResult {
|
|
|
578
1339
|
* `pausedCalls` is non-empty rather than sending an incomplete set of
|
|
579
1340
|
* outputs back to the model.
|
|
580
1341
|
*/
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
type: 'parse_error',
|
|
599
|
-
toolCall,
|
|
600
|
-
output: {
|
|
601
|
-
type: 'function_call_output',
|
|
602
|
-
id: `output_${toolCall.id}`,
|
|
603
|
-
callId: toolCall.id,
|
|
604
|
-
output: JSON.stringify({
|
|
605
|
-
error: errorMessage,
|
|
606
|
-
}),
|
|
607
|
-
},
|
|
608
|
-
};
|
|
609
|
-
}
|
|
610
|
-
const preliminaryResultsForCall = [];
|
|
611
|
-
const hasBroadcaster = this.toolEventBroadcaster || this.turnBroadcaster;
|
|
612
|
-
const onPreliminaryResult = hasBroadcaster
|
|
613
|
-
? (callId, resultValue) => {
|
|
614
|
-
const typedResult = resultValue;
|
|
615
|
-
preliminaryResultsForCall.push(typedResult);
|
|
616
|
-
this.broadcastPreliminaryResult(callId, typedResult);
|
|
617
|
-
}
|
|
618
|
-
: undefined;
|
|
619
|
-
const result = await executeTool(tool, toolCall, turnContext, onPreliminaryResult, this.contextStore ?? undefined, this.options.sharedContextSchema);
|
|
620
|
-
if (result === null) {
|
|
621
|
-
// HITL tool paused — surface as manual (no output this round)
|
|
622
|
-
return {
|
|
623
|
-
type: 'paused',
|
|
624
|
-
toolCall,
|
|
625
|
-
};
|
|
626
|
-
}
|
|
1342
|
+
/**
|
|
1343
|
+
* Execute one tool call for a round: resolve the tool, honor any pending
|
|
1344
|
+
* PermissionRequest denial, wire preliminary-result broadcasting, and run
|
|
1345
|
+
* the tool through the full Pre/Post lifecycle hooks. Returns a tagged
|
|
1346
|
+
* outcome consumed by `executeToolRound`'s aggregation loop.
|
|
1347
|
+
*/
|
|
1348
|
+
async executeSingleToolCall(toolCall, turnContext) {
|
|
1349
|
+
const tool = this.options.tools?.find((t) => isClientTool(t) && t.function.name === toolCall.name);
|
|
1350
|
+
if (!tool || !isAutoResolvableTool(tool)) {
|
|
1351
|
+
return null;
|
|
1352
|
+
}
|
|
1353
|
+
// PermissionRequest hook denied this call without pausing: synthesize a
|
|
1354
|
+
// rejection instead of executing. Consume the entry so a later round
|
|
1355
|
+
// with a reused id is not affected.
|
|
1356
|
+
const denialReason = this.hookDeniedCalls.get(toolCall.id);
|
|
1357
|
+
if (denialReason !== undefined) {
|
|
1358
|
+
this.hookDeniedCalls.delete(toolCall.id);
|
|
627
1359
|
return {
|
|
628
|
-
type: '
|
|
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
|
+
};
|
|
1370
|
+
}
|
|
1371
|
+
const preliminaryResultsForCall = [];
|
|
1372
|
+
const hasBroadcaster = this.toolEventBroadcaster || this.turnBroadcaster;
|
|
1373
|
+
const onPreliminaryResult = hasBroadcaster
|
|
1374
|
+
? (callId, resultValue) => {
|
|
1375
|
+
const typedResult = resultValue;
|
|
1376
|
+
preliminaryResultsForCall.push(typedResult);
|
|
1377
|
+
this.broadcastPreliminaryResult(callId, typedResult);
|
|
1378
|
+
}
|
|
1379
|
+
: undefined;
|
|
1380
|
+
// Run the tool through the full Pre/Post lifecycle hooks. The helper
|
|
1381
|
+
// fails closed on a JSON-parse failure in toolCall.arguments so hooks
|
|
1382
|
+
// never see a malformed payload; the caller handles that case via the
|
|
1383
|
+
// shared `parse_error` / `hook_blocked` branch.
|
|
1384
|
+
const executed = await this.runToolWithHooks(tool, toolCall, turnContext, onPreliminaryResult);
|
|
1385
|
+
if (executed.type === 'parse_error') {
|
|
1386
|
+
this.broadcastToolResult(toolCall.id, isMcpTool(tool) ? 'mcp' : 'client', {
|
|
1387
|
+
error: executed.errorMessage,
|
|
1388
|
+
});
|
|
1389
|
+
return executed;
|
|
1390
|
+
}
|
|
1391
|
+
if (executed.type === 'hook_blocked') {
|
|
1392
|
+
return executed;
|
|
1393
|
+
}
|
|
1394
|
+
const result = executed.result;
|
|
1395
|
+
if (result === null) {
|
|
1396
|
+
// HITL tool paused — surface as manual (no output this round)
|
|
1397
|
+
return {
|
|
1398
|
+
type: 'paused',
|
|
629
1399
|
toolCall,
|
|
630
|
-
tool,
|
|
631
|
-
result,
|
|
632
|
-
preliminaryResultsForCall,
|
|
633
1400
|
};
|
|
634
|
-
}
|
|
1401
|
+
}
|
|
1402
|
+
return {
|
|
1403
|
+
type: 'execution',
|
|
1404
|
+
toolCall: executed.effectiveToolCall,
|
|
1405
|
+
tool,
|
|
1406
|
+
result,
|
|
1407
|
+
preliminaryResultsForCall,
|
|
1408
|
+
};
|
|
1409
|
+
}
|
|
1410
|
+
async executeToolRound(toolCalls, turnContext) {
|
|
1411
|
+
const toolCallPromises = toolCalls.map((toolCall) => this.executeSingleToolCall(toolCall, turnContext));
|
|
635
1412
|
const settledResults = await Promise.allSettled(toolCallPromises);
|
|
636
1413
|
const toolResults = [];
|
|
637
1414
|
const pausedCalls = [];
|
|
@@ -643,7 +1420,8 @@ export class ModelResult {
|
|
|
643
1420
|
}
|
|
644
1421
|
if (settled.status === 'rejected') {
|
|
645
1422
|
const errorMessage = settled.reason instanceof Error ? settled.reason.message : String(settled.reason);
|
|
646
|
-
|
|
1423
|
+
// `runToolWithHooks` is the single point of emission for PostToolUseFailure.
|
|
1424
|
+
this.broadcastToolResult(originalToolCall.id, this.toolSourceByName(originalToolCall.name), {
|
|
647
1425
|
error: errorMessage,
|
|
648
1426
|
});
|
|
649
1427
|
const rejectedOutput = {
|
|
@@ -666,7 +1444,7 @@ export class ModelResult {
|
|
|
666
1444
|
if (!value) {
|
|
667
1445
|
continue;
|
|
668
1446
|
}
|
|
669
|
-
if (value.type === 'parse_error') {
|
|
1447
|
+
if (value.type === 'parse_error' || value.type === 'hook_blocked') {
|
|
670
1448
|
toolResults.push(value.output);
|
|
671
1449
|
this.turnBroadcaster?.push({
|
|
672
1450
|
type: 'tool.call_output',
|
|
@@ -688,7 +1466,7 @@ export class ModelResult {
|
|
|
688
1466
|
error: value.result.error.message,
|
|
689
1467
|
}
|
|
690
1468
|
: value.result.result);
|
|
691
|
-
this.broadcastToolResult(value.toolCall.id, toolResult, value.preliminaryResultsForCall.length > 0 ? value.preliminaryResultsForCall : undefined);
|
|
1469
|
+
this.broadcastToolResult(value.toolCall.id, isMcpTool(value.tool) ? 'mcp' : 'client', toolResult, value.preliminaryResultsForCall.length > 0 ? value.preliminaryResultsForCall : undefined);
|
|
692
1470
|
const outputForModel = await this.computeToolOutputForModel(value);
|
|
693
1471
|
const executedOutput = {
|
|
694
1472
|
type: 'function_call_output',
|
|
@@ -781,32 +1559,24 @@ export class ModelResult {
|
|
|
781
1559
|
...this.resolvedRequest,
|
|
782
1560
|
stream: true,
|
|
783
1561
|
};
|
|
1562
|
+
const startedAt = performance.now();
|
|
784
1563
|
const newResult = await betaResponsesSend(this.options.client, {
|
|
785
1564
|
responsesRequest: newRequest,
|
|
786
1565
|
}, this.options.options);
|
|
787
1566
|
if (!newResult.ok) {
|
|
788
1567
|
throw newResult.error;
|
|
789
1568
|
}
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
const followUpStream = new ReusableReadableStream(value);
|
|
794
|
-
if (this.turnBroadcaster) {
|
|
795
|
-
return this.pipeAndConsumeStream(followUpStream, turnNumber);
|
|
796
|
-
}
|
|
797
|
-
return consumeStreamForCompletion(followUpStream);
|
|
798
|
-
}
|
|
799
|
-
if (this.isNonStreamingResponse(value)) {
|
|
800
|
-
return value;
|
|
801
|
-
}
|
|
802
|
-
throw new Error('Unexpected response type from API');
|
|
1569
|
+
const response = await this.materializeResponse(newResult.value, turnNumber);
|
|
1570
|
+
await this.emitPostModelCall(response, startedAt, 'tool_round', turnNumber);
|
|
1571
|
+
return response;
|
|
803
1572
|
}
|
|
804
1573
|
/**
|
|
805
|
-
* Make a final no-
|
|
806
|
-
* was halted by `stopWhen` mid-tool-call. Reuses the resolved request
|
|
807
|
-
* `instructions`, `model`, and other API fields ride along unchanged.
|
|
808
|
-
* `tools
|
|
809
|
-
*
|
|
1574
|
+
* Make a final no-tool-calls request to coerce a text response after the
|
|
1575
|
+
* loop was halted by `stopWhen` mid-tool-call. Reuses the resolved request
|
|
1576
|
+
* so `instructions`, `model`, and other API fields ride along unchanged.
|
|
1577
|
+
* `tools` stays in the request with `toolChoice: 'none'` — forbidding
|
|
1578
|
+
* calls while keeping the prompt-cache prefix intact (stripping the tools
|
|
1579
|
+
* block would bust the cache). The caller is expected to have already
|
|
810
1580
|
* executed the pending tool calls and to pass their outputs in
|
|
811
1581
|
* `toolOutputs` so every function_call in the input has a matching output.
|
|
812
1582
|
*/
|
|
@@ -825,6 +1595,12 @@ export class ModelResult {
|
|
|
825
1595
|
},
|
|
826
1596
|
]
|
|
827
1597
|
: [];
|
|
1598
|
+
// Default-on (`undefined`) and bare `true` get the built-in directive;
|
|
1599
|
+
// a non-empty string overrides the wording; `''` appends nothing
|
|
1600
|
+
// (explicit opt-out).
|
|
1601
|
+
const finalDirective = allowFinalResponse === true || allowFinalResponse === undefined
|
|
1602
|
+
? DEFAULT_FINAL_RESPONSE_DIRECTIVE
|
|
1603
|
+
: allowFinalResponse;
|
|
828
1604
|
const newInput = [
|
|
829
1605
|
...normalizedOriginalInput,
|
|
830
1606
|
...(Array.isArray(currentResponse.output)
|
|
@@ -833,54 +1609,89 @@ export class ModelResult {
|
|
|
833
1609
|
currentResponse.output,
|
|
834
1610
|
]),
|
|
835
1611
|
...toolOutputs,
|
|
836
|
-
...(typeof
|
|
1612
|
+
...(typeof finalDirective === 'string' && finalDirective.length > 0
|
|
837
1613
|
? [
|
|
838
1614
|
{
|
|
839
1615
|
role: 'user',
|
|
840
|
-
content:
|
|
1616
|
+
content: finalDirective,
|
|
841
1617
|
},
|
|
842
1618
|
]
|
|
843
1619
|
: []),
|
|
844
1620
|
];
|
|
845
|
-
const { tools: _tools, toolChoice: _toolChoice, parallelToolCalls: _parallelToolCalls, ...rest } = this.resolvedRequest;
|
|
846
1621
|
const finalRequest = {
|
|
847
|
-
...
|
|
1622
|
+
...this.resolvedRequest,
|
|
1623
|
+
// Forbid tool calls without dropping the tools block: removing
|
|
1624
|
+
// `tools` would invalidate the prompt-cache prefix.
|
|
1625
|
+
...(this.resolvedRequest.tools !== undefined && {
|
|
1626
|
+
toolChoice: 'none',
|
|
1627
|
+
}),
|
|
848
1628
|
input: newInput,
|
|
849
1629
|
stream: true,
|
|
850
1630
|
};
|
|
1631
|
+
this.resolvedRequest = finalRequest;
|
|
1632
|
+
const startedAt = performance.now();
|
|
851
1633
|
const result = await betaResponsesSend(this.options.client, {
|
|
852
1634
|
responsesRequest: finalRequest,
|
|
853
1635
|
}, this.options.options);
|
|
854
1636
|
if (!result.ok) {
|
|
855
1637
|
throw result.error;
|
|
856
1638
|
}
|
|
857
|
-
const
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
if (this.turnBroadcaster) {
|
|
861
|
-
return this.pipeAndConsumeStream(stream, turnNumber);
|
|
862
|
-
}
|
|
863
|
-
return consumeStreamForCompletion(stream);
|
|
864
|
-
}
|
|
865
|
-
if (this.isNonStreamingResponse(value)) {
|
|
866
|
-
return value;
|
|
867
|
-
}
|
|
868
|
-
throw new Error('Unexpected response type from API');
|
|
1639
|
+
const response = await this.materializeResponse(result.value, turnNumber);
|
|
1640
|
+
await this.emitPostModelCall(response, startedAt, 'final', turnNumber);
|
|
1641
|
+
return response;
|
|
869
1642
|
}
|
|
870
1643
|
/**
|
|
871
1644
|
* Validate the final response has required fields.
|
|
872
1645
|
*
|
|
873
1646
|
* @param response - The response to validate
|
|
1647
|
+
* @param allowEmptyOutput - When true, tolerate an empty (but present) output array
|
|
874
1648
|
* @throws Error if response is missing required fields or has invalid output
|
|
875
1649
|
*/
|
|
876
|
-
validateFinalResponse(response) {
|
|
1650
|
+
validateFinalResponse(response, allowEmptyOutput = false) {
|
|
877
1651
|
if (!response?.id || !response?.output) {
|
|
878
1652
|
throw new Error('Invalid final response: missing required fields');
|
|
879
1653
|
}
|
|
880
1654
|
if (!Array.isArray(response.output) || response.output.length === 0) {
|
|
1655
|
+
if (allowEmptyOutput) {
|
|
1656
|
+
return;
|
|
1657
|
+
}
|
|
881
1658
|
throw new Error('Invalid final response: empty or invalid output');
|
|
882
1659
|
}
|
|
883
1660
|
}
|
|
1661
|
+
/**
|
|
1662
|
+
* Re-send the current resolved request (same accumulated input) once.
|
|
1663
|
+
* Used when a follow-up after tool execution returned an empty `output`.
|
|
1664
|
+
*
|
|
1665
|
+
* `toolChoice` is forced to `'none'` when tools are present (mirroring
|
|
1666
|
+
* `makeFinalResponseRequest`) so the retry coerces a text turn: on the
|
|
1667
|
+
* natural-loop-completion path the resolved request still carries tools,
|
|
1668
|
+
* and a retry that emitted a fresh `function_call` would pass
|
|
1669
|
+
* `validateFinalResponse` but never be executed — silently dropping a
|
|
1670
|
+
* proposed tool call. Tools stay in the request to keep the prompt-cache
|
|
1671
|
+
* prefix intact.
|
|
1672
|
+
*/
|
|
1673
|
+
async retryCurrentRequest(turnNumber) {
|
|
1674
|
+
if (!this.resolvedRequest) {
|
|
1675
|
+
throw new Error('Request not initialized');
|
|
1676
|
+
}
|
|
1677
|
+
const newRequest = {
|
|
1678
|
+
...this.resolvedRequest,
|
|
1679
|
+
...(this.resolvedRequest.tools !== undefined && {
|
|
1680
|
+
toolChoice: 'none',
|
|
1681
|
+
}),
|
|
1682
|
+
stream: true,
|
|
1683
|
+
};
|
|
1684
|
+
const startedAt = performance.now();
|
|
1685
|
+
const newResult = await betaResponsesSend(this.options.client, {
|
|
1686
|
+
responsesRequest: newRequest,
|
|
1687
|
+
}, this.options.options);
|
|
1688
|
+
if (!newResult.ok) {
|
|
1689
|
+
throw newResult.error;
|
|
1690
|
+
}
|
|
1691
|
+
const response = await this.materializeResponse(newResult.value, turnNumber);
|
|
1692
|
+
await this.emitPostModelCall(response, startedAt, 'retry', turnNumber);
|
|
1693
|
+
return response;
|
|
1694
|
+
}
|
|
884
1695
|
/**
|
|
885
1696
|
* Resolve async functions in the request for a given turn context.
|
|
886
1697
|
* Extracts non-function fields and resolves any async parameter functions.
|
|
@@ -892,9 +1703,12 @@ export class ModelResult {
|
|
|
892
1703
|
if (hasAsyncFunctions(this.options.request)) {
|
|
893
1704
|
return resolveAsyncFunctions(this.options.request, context);
|
|
894
1705
|
}
|
|
895
|
-
// Already resolved, extract non-function fields
|
|
896
|
-
//
|
|
897
|
-
|
|
1706
|
+
// Already resolved, extract non-function fields.
|
|
1707
|
+
// Strip ALL client-only fields — keep this list in sync with
|
|
1708
|
+
// `clientOnlyFields` in async-params.ts, which handles the async path.
|
|
1709
|
+
// (`sharedContextSchema` is absent here: call-model.ts destructures it
|
|
1710
|
+
// 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;
|
|
898
1712
|
return rest;
|
|
899
1713
|
}
|
|
900
1714
|
/**
|
|
@@ -1035,6 +1849,12 @@ export class ModelResult {
|
|
|
1035
1849
|
this.contextStore = new ToolContextStore(resolvedCtx);
|
|
1036
1850
|
}
|
|
1037
1851
|
this.isResumingFromApproval = true;
|
|
1852
|
+
// This path bypasses the SessionStart block below but still fires
|
|
1853
|
+
// tool hooks (PreToolUse/PostToolUse) during the resume. Those
|
|
1854
|
+
// emits thread the session id per emit via hookEmitContext();
|
|
1855
|
+
// priming the manager-level default here covers direct emit()
|
|
1856
|
+
// callers on a shared manager.
|
|
1857
|
+
this.hooksManager?.setSessionId(loadedState.id);
|
|
1038
1858
|
await this.processApprovalDecisions();
|
|
1039
1859
|
return; // Skip normal initialization, we're resuming
|
|
1040
1860
|
}
|
|
@@ -1049,14 +1869,19 @@ export class ModelResult {
|
|
|
1049
1869
|
]);
|
|
1050
1870
|
await this.saveStateSafely();
|
|
1051
1871
|
}
|
|
1872
|
+
// Keep manual calls durable until the resumed request produces a
|
|
1873
|
+
// response. Clearing before the API call would lose the only copy if
|
|
1874
|
+
// that request failed.
|
|
1875
|
+
this.resumingFromClientTools = loadedState.status === 'awaiting_client_tools';
|
|
1052
1876
|
}
|
|
1053
1877
|
else {
|
|
1054
1878
|
this.currentState = createInitialState();
|
|
1055
1879
|
}
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1880
|
+
if (!this.resumingFromClientTools) {
|
|
1881
|
+
await this.saveStateSafely({
|
|
1882
|
+
status: 'in_progress',
|
|
1883
|
+
});
|
|
1884
|
+
}
|
|
1060
1885
|
}
|
|
1061
1886
|
// Resolve async functions before initial request
|
|
1062
1887
|
// Build initial turn context (turn 0 for initial request)
|
|
@@ -1070,6 +1895,44 @@ export class ModelResult {
|
|
|
1070
1895
|
}
|
|
1071
1896
|
// Resolve any async functions first
|
|
1072
1897
|
let baseRequest = await this.resolveRequestForContext(initialContext);
|
|
1898
|
+
// Emit SessionStart hook. The `config` payload carries a stable, small
|
|
1899
|
+
// summary of session-level options so handlers can make routing/auditing
|
|
1900
|
+
// decisions without the SDK having to promise more than it can deliver.
|
|
1901
|
+
// If future session config becomes available, extend this object rather
|
|
1902
|
+
// than introducing a new payload field.
|
|
1903
|
+
if (this.hooksManager) {
|
|
1904
|
+
// Prime the manager-level default for callers that emit custom hooks
|
|
1905
|
+
// on a shared manager; the engine's own emits thread the session id
|
|
1906
|
+
// per emit via hookEmitContext() so concurrent runs sharing one
|
|
1907
|
+
// manager can't clobber each other's context.sessionId.
|
|
1908
|
+
this.hooksManager.setSessionId(this.currentState?.id ?? '');
|
|
1909
|
+
await this.hooksManager.emit('SessionStart', {
|
|
1910
|
+
config: {
|
|
1911
|
+
hasTools: !!this.options.tools?.length,
|
|
1912
|
+
hasApproval: !!this.requireApprovalFn ||
|
|
1913
|
+
!!(this.options.tools ?? []).some((t) => isClientTool(t) &&
|
|
1914
|
+
(t.function.requireApproval === true ||
|
|
1915
|
+
typeof t.function.requireApproval === 'function')),
|
|
1916
|
+
hasState: !!this.stateAccessor,
|
|
1917
|
+
},
|
|
1918
|
+
}, this.hookEmitContext());
|
|
1919
|
+
this.sessionStartEmitted = true;
|
|
1920
|
+
}
|
|
1921
|
+
// Emit UserPromptSubmit hook BEFORE the stateful input-wrapping block so
|
|
1922
|
+
// the handler sees the original user-supplied prompt string (and can
|
|
1923
|
+
// reject or mutate it before any messages are appended). For structured
|
|
1924
|
+
// (non-string) inputs we extract the latest user-role text content so
|
|
1925
|
+
// handlers still get a chance to intercept; if nothing suitable is
|
|
1926
|
+
// found we skip silently and document the limitation in the log below.
|
|
1927
|
+
if (this.hooksManager) {
|
|
1928
|
+
const promptResult = await this.maybeRunUserPromptSubmit(baseRequest.input);
|
|
1929
|
+
if (promptResult) {
|
|
1930
|
+
baseRequest = {
|
|
1931
|
+
...baseRequest,
|
|
1932
|
+
input: promptResult.applyTo(baseRequest.input),
|
|
1933
|
+
};
|
|
1934
|
+
}
|
|
1935
|
+
}
|
|
1073
1936
|
// Split input into "historical" (already in state.messages) and "fresh"
|
|
1074
1937
|
// (newly supplied this turn). `onResponseReceived` must fire only for
|
|
1075
1938
|
// fresh items — re-hooking historical outputs on every callModel call
|
|
@@ -1094,15 +1957,12 @@ export class ModelResult {
|
|
|
1094
1957
|
// SDK input items with different nominal types, and BaseInputsUnion
|
|
1095
1958
|
// already includes `any` in its element type, so the runtime shape
|
|
1096
1959
|
// is preserved either way.
|
|
1960
|
+
// Normalize (not just wrap): bare strings must become EasyInputMessage
|
|
1961
|
+
// items, exactly as the no-history branch does via
|
|
1962
|
+
// normalizeInputToArray. Passing a raw string through to the request
|
|
1963
|
+
// input array is rejected by OpenResponses validation (400).
|
|
1097
1964
|
const newInput = baseRequest.input;
|
|
1098
|
-
|
|
1099
|
-
if (newInput !== undefined) {
|
|
1100
|
-
freshItems = Array.isArray(newInput)
|
|
1101
|
-
? newInput
|
|
1102
|
-
: [
|
|
1103
|
-
newInput,
|
|
1104
|
-
];
|
|
1105
|
-
}
|
|
1965
|
+
const freshItems = newInput !== undefined ? normalizeInputToArray(newInput) : undefined;
|
|
1106
1966
|
// Hook fresh items only (historical function_calls serve as
|
|
1107
1967
|
// name-resolution context). Leave historical items untouched.
|
|
1108
1968
|
const hookedFresh = freshItems
|
|
@@ -1134,6 +1994,14 @@ export class ModelResult {
|
|
|
1134
1994
|
};
|
|
1135
1995
|
// Force stream mode for initial request
|
|
1136
1996
|
const request = this.resolvedRequest;
|
|
1997
|
+
// Park PostModelCall telemetry for this dispatch: the response is
|
|
1998
|
+
// materialized later (getInitialResponse or the no-tools stream
|
|
1999
|
+
// teardown), which completes the emit with the true duration.
|
|
2000
|
+
this.pendingModelCall = {
|
|
2001
|
+
startedAt: performance.now(),
|
|
2002
|
+
turnType: 'initial',
|
|
2003
|
+
turnNumber: 0,
|
|
2004
|
+
};
|
|
1137
2005
|
// Make the API request
|
|
1138
2006
|
const apiResult = await betaResponsesSend(this.options.client, {
|
|
1139
2007
|
responsesRequest: request,
|
|
@@ -1156,6 +2024,7 @@ export class ModelResult {
|
|
|
1156
2024
|
else if (this.isNonStreamingResponse(apiResult.value)) {
|
|
1157
2025
|
// API returned a complete response directly - use it as the final response
|
|
1158
2026
|
this.finalResponse = apiResult.value;
|
|
2027
|
+
await this.emitPendingModelCallOnce(this.finalResponse);
|
|
1159
2028
|
}
|
|
1160
2029
|
else {
|
|
1161
2030
|
throw new Error('Unexpected response type from API');
|
|
@@ -1182,7 +2051,8 @@ export class ModelResult {
|
|
|
1182
2051
|
// Track approved HITL calls that paused (onToolCalled returned null) —
|
|
1183
2052
|
// these stay in pendingToolCalls so the caller can resume them later.
|
|
1184
2053
|
const hitlPausedIds = new Set();
|
|
1185
|
-
// Process approvals - execute the approved tools
|
|
2054
|
+
// Process approvals - execute the approved tools. Route through
|
|
2055
|
+
// runToolWithHooks so PreToolUse/PostToolUse fire even on this path.
|
|
1186
2056
|
for (const callId of this.approvedToolCalls) {
|
|
1187
2057
|
const toolCall = pendingCalls.find((tc) => tc.id === callId);
|
|
1188
2058
|
if (!toolCall) {
|
|
@@ -1194,7 +2064,19 @@ export class ModelResult {
|
|
|
1194
2064
|
unsentResults.push(createRejectedResult(callId, String(toolCall.name), 'Tool not found or not executable'));
|
|
1195
2065
|
continue;
|
|
1196
2066
|
}
|
|
1197
|
-
const
|
|
2067
|
+
const hookOutcome = await this.runToolWithHooks(tool, toolCall, turnContext);
|
|
2068
|
+
if (hookOutcome.type === 'parse_error') {
|
|
2069
|
+
this.broadcastToolResult(callId, this.toolSourceByName(String(toolCall.name)), {
|
|
2070
|
+
error: hookOutcome.errorMessage,
|
|
2071
|
+
});
|
|
2072
|
+
unsentResults.push(createRejectedResult(callId, String(toolCall.name), hookOutcome.errorMessage));
|
|
2073
|
+
continue;
|
|
2074
|
+
}
|
|
2075
|
+
if (hookOutcome.type === 'hook_blocked') {
|
|
2076
|
+
unsentResults.push(createRejectedResult(callId, String(toolCall.name), hookOutcome.reason));
|
|
2077
|
+
continue;
|
|
2078
|
+
}
|
|
2079
|
+
const result = hookOutcome.result;
|
|
1198
2080
|
if (result === null) {
|
|
1199
2081
|
// HITL tool paused on approval — keep the call visible to the caller
|
|
1200
2082
|
// via pendingToolCalls (status becomes 'awaiting_hitl' below).
|
|
@@ -1314,6 +2196,12 @@ export class ModelResult {
|
|
|
1314
2196
|
stream: true,
|
|
1315
2197
|
};
|
|
1316
2198
|
this.resolvedRequest = request;
|
|
2199
|
+
// Park PostModelCall telemetry for the resume dispatch (see initStream).
|
|
2200
|
+
this.pendingModelCall = {
|
|
2201
|
+
startedAt: performance.now(),
|
|
2202
|
+
turnType: 'resume',
|
|
2203
|
+
turnNumber: turnContext.numberOfTurns,
|
|
2204
|
+
};
|
|
1317
2205
|
// Make the API request
|
|
1318
2206
|
const apiResult = await betaResponsesSend(this.options.client, {
|
|
1319
2207
|
responsesRequest: request,
|
|
@@ -1327,6 +2215,7 @@ export class ModelResult {
|
|
|
1327
2215
|
}
|
|
1328
2216
|
else if (this.isNonStreamingResponse(apiResult.value)) {
|
|
1329
2217
|
this.finalResponse = apiResult.value;
|
|
2218
|
+
await this.emitPendingModelCallOnce(this.finalResponse);
|
|
1330
2219
|
}
|
|
1331
2220
|
else {
|
|
1332
2221
|
throw new Error('Unexpected response type from API');
|
|
@@ -1342,187 +2231,299 @@ export class ModelResult {
|
|
|
1342
2231
|
}
|
|
1343
2232
|
// biome-ignore lint: IIFE used for lazy initialization pattern
|
|
1344
2233
|
this.toolExecutionPromise = (async () => {
|
|
1345
|
-
|
|
1346
|
-
//
|
|
1347
|
-
//
|
|
1348
|
-
//
|
|
1349
|
-
|
|
1350
|
-
|
|
1351
|
-
|
|
1352
|
-
|
|
1353
|
-
|
|
1354
|
-
|
|
1355
|
-
|
|
1356
|
-
|
|
1357
|
-
|
|
1358
|
-
|
|
1359
|
-
const hasToolCalls = currentResponse.output.some((item) => hasTypeProperty(item) && item.type === 'function_call');
|
|
1360
|
-
if (!this.options.tools?.length || !hasToolCalls) {
|
|
1361
|
-
this.finalResponse = currentResponse;
|
|
1362
|
-
await this.markStateComplete();
|
|
1363
|
-
return;
|
|
1364
|
-
}
|
|
1365
|
-
// Extract and check tool calls
|
|
1366
|
-
const toolCalls = extractToolCallsFromResponse(currentResponse);
|
|
1367
|
-
// Check for approval requirements
|
|
1368
|
-
if (await this.handleApprovalCheck(toolCalls, 0, currentResponse)) {
|
|
1369
|
-
return; // Paused for approval
|
|
1370
|
-
}
|
|
1371
|
-
if (!this.hasExecutableToolCalls(toolCalls)) {
|
|
1372
|
-
this.finalResponse = currentResponse;
|
|
1373
|
-
await this.markStateComplete();
|
|
1374
|
-
return;
|
|
1375
|
-
}
|
|
1376
|
-
// Main execution loop
|
|
1377
|
-
let currentRound = 0;
|
|
1378
|
-
let stoppedByStopWhen = false;
|
|
1379
|
-
while (true) {
|
|
1380
|
-
// Check for external interruption
|
|
1381
|
-
if (await this.checkForInterruption(currentResponse)) {
|
|
2234
|
+
// SessionEnd/drain must fire on every exit path (success, early return,
|
|
2235
|
+
// approval pause, interruption, and exceptions), so wrap the body in
|
|
2236
|
+
// try/catch/finally and track the session-end reason as we go.
|
|
2237
|
+
// Approval pauses keep reason='complete' because the run hasn't failed —
|
|
2238
|
+
// it's simply paused awaiting user decisions.
|
|
2239
|
+
let sessionEndReason = 'complete';
|
|
2240
|
+
try {
|
|
2241
|
+
await this.initStream();
|
|
2242
|
+
// If resuming from approval or HITL pause and still pending, don't continue.
|
|
2243
|
+
// `processApprovalDecisions` runs in initStream for resumes; if it left us
|
|
2244
|
+
// paused (any remaining pending calls), the outer loop should not execute.
|
|
2245
|
+
if (this.isResumingFromApproval &&
|
|
2246
|
+
(this.currentState?.status === 'awaiting_approval' ||
|
|
2247
|
+
this.currentState?.status === 'awaiting_hitl')) {
|
|
1382
2248
|
return;
|
|
1383
2249
|
}
|
|
1384
|
-
//
|
|
1385
|
-
|
|
1386
|
-
|
|
1387
|
-
|
|
1388
|
-
|
|
1389
|
-
const
|
|
1390
|
-
if (
|
|
1391
|
-
|
|
2250
|
+
// Get initial response
|
|
2251
|
+
let currentResponse = await this.getInitialResponse();
|
|
2252
|
+
// Save initial response to state
|
|
2253
|
+
await this.saveResponseToState(currentResponse);
|
|
2254
|
+
// Check if tools should be executed
|
|
2255
|
+
const hasToolCalls = currentResponse.output.some((item) => hasTypeProperty(item) && item.type === 'function_call');
|
|
2256
|
+
if (!this.options.tools?.length || !hasToolCalls) {
|
|
2257
|
+
// No tool work: keep hard throw on empty/invalid final output.
|
|
2258
|
+
this.validateFinalResponse(currentResponse);
|
|
2259
|
+
this.finalResponse = currentResponse;
|
|
2260
|
+
await this.markStateComplete();
|
|
2261
|
+
return;
|
|
1392
2262
|
}
|
|
2263
|
+
// Extract and check tool calls
|
|
2264
|
+
const toolCalls = extractToolCallsFromResponse(currentResponse);
|
|
1393
2265
|
// Check for approval requirements
|
|
1394
|
-
if (await this.handleApprovalCheck(
|
|
1395
|
-
return;
|
|
2266
|
+
if (await this.handleApprovalCheck(toolCalls, 0, currentResponse)) {
|
|
2267
|
+
return; // Paused for approval
|
|
1396
2268
|
}
|
|
1397
|
-
|
|
1398
|
-
|
|
2269
|
+
// All tool calls are manual (no execute / no onToolCalled) or
|
|
2270
|
+
// otherwise non-auto-resolvable — stop and surface them as pending
|
|
2271
|
+
// client tools instead of marking the conversation complete with
|
|
2272
|
+
// empty pendings.
|
|
2273
|
+
if (!this.hasExecutableToolCalls(toolCalls)) {
|
|
2274
|
+
await this.persistClientToolsPause(currentResponse, toolCalls);
|
|
2275
|
+
return;
|
|
1399
2276
|
}
|
|
1400
|
-
//
|
|
1401
|
-
|
|
1402
|
-
|
|
1403
|
-
|
|
1404
|
-
|
|
1405
|
-
|
|
1406
|
-
|
|
1407
|
-
|
|
1408
|
-
|
|
1409
|
-
|
|
1410
|
-
|
|
1411
|
-
|
|
1412
|
-
|
|
1413
|
-
|
|
1414
|
-
if (
|
|
1415
|
-
|
|
2277
|
+
// Main execution loop
|
|
2278
|
+
let currentRound = 0;
|
|
2279
|
+
let stoppedByStopWhen = false;
|
|
2280
|
+
// Counts consecutive Stop-hook forceResume overrides without tool
|
|
2281
|
+
// progress; reset when a tool round or fresh response lands. See
|
|
2282
|
+
// runStopHook for the cap.
|
|
2283
|
+
let forceResumeCount = 0;
|
|
2284
|
+
while (true) {
|
|
2285
|
+
// Check for external interruption
|
|
2286
|
+
if (await this.checkForInterruption(currentResponse)) {
|
|
2287
|
+
sessionEndReason = 'user';
|
|
2288
|
+
return;
|
|
2289
|
+
}
|
|
2290
|
+
// Check stop conditions
|
|
2291
|
+
if (await this.shouldStopExecution()) {
|
|
2292
|
+
// Emit the Stop hook -- handlers can force resume or inject a
|
|
2293
|
+
// prompt. The helper enforces the consecutive-override cap so a
|
|
2294
|
+
// misbehaving handler cannot spin the loop forever.
|
|
2295
|
+
const stopDecision = await this.runStopHook(forceResumeCount);
|
|
2296
|
+
if (stopDecision === 'resume') {
|
|
2297
|
+
forceResumeCount++;
|
|
2298
|
+
// Continue the loop. If appendPrompt was supplied it was
|
|
2299
|
+
// already injected, which advances state so the stop condition
|
|
2300
|
+
// may no longer fire on the next iteration.
|
|
2301
|
+
continue;
|
|
2302
|
+
}
|
|
2303
|
+
// Stop condition fired and the hook (if any) did not force resume
|
|
2304
|
+
// -- this is a max_turns-style exit, not a natural completion.
|
|
2305
|
+
sessionEndReason = 'max_turns';
|
|
2306
|
+
stoppedByStopWhen = true;
|
|
2307
|
+
break;
|
|
2308
|
+
}
|
|
2309
|
+
const currentToolCalls = extractToolCallsFromResponse(currentResponse);
|
|
2310
|
+
if (currentToolCalls.length === 0) {
|
|
2311
|
+
break;
|
|
2312
|
+
}
|
|
2313
|
+
// Check for approval requirements
|
|
2314
|
+
if (await this.handleApprovalCheck(currentToolCalls, currentRound + 1, currentResponse)) {
|
|
2315
|
+
return;
|
|
2316
|
+
}
|
|
2317
|
+
// All-manual (or otherwise non-auto-resolvable) mid-loop round: stop
|
|
2318
|
+
// and persist the unresolved calls the same way the first-round
|
|
2319
|
+
// guard does above, so getPendingToolCalls() surfaces them after
|
|
2320
|
+
// loop end.
|
|
2321
|
+
if (!this.hasExecutableToolCalls(currentToolCalls)) {
|
|
2322
|
+
await this.persistClientToolsPause(currentResponse, currentToolCalls);
|
|
2323
|
+
return;
|
|
2324
|
+
}
|
|
2325
|
+
// Build turn context
|
|
2326
|
+
const turnNumber = currentRound + 1;
|
|
2327
|
+
const turnContext = {
|
|
2328
|
+
numberOfTurns: turnNumber,
|
|
2329
|
+
};
|
|
2330
|
+
await this.options.onTurnStart?.(turnContext);
|
|
2331
|
+
// Resolve async functions for this turn
|
|
2332
|
+
await this.resolveAsyncFunctionsForTurn(turnContext);
|
|
2333
|
+
// Execute tools
|
|
2334
|
+
const { toolResults, pausedCalls } = await this.executeToolRound(currentToolCalls, turnContext);
|
|
2335
|
+
// A tool round with observable progress resets the consecutive
|
|
2336
|
+
// forceResume counter so a legitimate override earlier in the run
|
|
2337
|
+
// does not count against a later, independent one.
|
|
2338
|
+
// Hook-blocked / rejected outputs deliberately count as progress:
|
|
2339
|
+
// the model receives the block or denial as feedback and can change
|
|
2340
|
+
// course on the next turn, which is observable forward motion even
|
|
2341
|
+
// though no tool body executed. A PreToolUse hook that blocks every
|
|
2342
|
+
// call therefore keeps resetting this counter -- acceptable,
|
|
2343
|
+
// because each reset requires a full model round trip (the loop
|
|
2344
|
+
// cannot spin hot) and stopWhen conditions still bound the run.
|
|
2345
|
+
if (toolResults.length > 0) {
|
|
2346
|
+
forceResumeCount = 0;
|
|
2347
|
+
}
|
|
2348
|
+
// Server-tool output items are already-executed results in the
|
|
2349
|
+
// response; collect them so toolResults presents a unified list.
|
|
2350
|
+
const serverToolItems = [];
|
|
2351
|
+
for (const item of currentResponse.output) {
|
|
2352
|
+
if (!hasTypeProperty(item)) {
|
|
2353
|
+
continue;
|
|
2354
|
+
}
|
|
2355
|
+
if (item.type === 'message' ||
|
|
2356
|
+
item.type === 'reasoning' ||
|
|
2357
|
+
item.type === 'function_call') {
|
|
2358
|
+
continue;
|
|
2359
|
+
}
|
|
2360
|
+
// Everything else is a server-tool output item (web_search_call,
|
|
2361
|
+
// image_generation_call, file_search_call, or generic
|
|
2362
|
+
// OutputServerToolItem covering openrouter:datetime and any new
|
|
2363
|
+
// SDK server tool types).
|
|
2364
|
+
if (isServerToolResultItem(item)) {
|
|
2365
|
+
serverToolItems.push(item);
|
|
2366
|
+
}
|
|
1416
2367
|
}
|
|
1417
|
-
|
|
1418
|
-
|
|
1419
|
-
|
|
1420
|
-
|
|
2368
|
+
// Track execution round
|
|
2369
|
+
this.allToolExecutionRounds.push({
|
|
2370
|
+
round: currentRound,
|
|
2371
|
+
toolCalls: currentToolCalls,
|
|
2372
|
+
response: currentResponse,
|
|
2373
|
+
toolResults: [
|
|
2374
|
+
...toolResults,
|
|
2375
|
+
...serverToolItems,
|
|
2376
|
+
],
|
|
2377
|
+
});
|
|
2378
|
+
// Save tool results to state
|
|
2379
|
+
await this.saveToolResultsToState(toolResults);
|
|
2380
|
+
// If any HITL tools paused this round, stop here without making a
|
|
2381
|
+
// follow-up request — sending an incomplete set of outputs would be
|
|
2382
|
+
// incorrect. Persist the paused calls so the caller can resume later.
|
|
2383
|
+
if (pausedCalls.length > 0) {
|
|
2384
|
+
await this.persistHitlPause(currentResponse, pausedCalls);
|
|
2385
|
+
return;
|
|
1421
2386
|
}
|
|
1422
|
-
//
|
|
1423
|
-
//
|
|
1424
|
-
//
|
|
1425
|
-
//
|
|
1426
|
-
|
|
1427
|
-
|
|
2387
|
+
// Manual (client-executed) tools produce no output this round —
|
|
2388
|
+
// `executeToolRound` returns nothing for them — so a mixed round of
|
|
2389
|
+
// auto-executed and manual calls would otherwise send a follow-up
|
|
2390
|
+
// request whose input contains a `function_call` with no matching
|
|
2391
|
+
// `function_call_output`. Providers reject that history with a 400
|
|
2392
|
+
// ("No tool output found for function call ..."). Stop the loop and
|
|
2393
|
+
// surface the response instead, so the caller can execute the manual
|
|
2394
|
+
// calls and continue — mirroring the all-manual behavior of the
|
|
2395
|
+
// `hasExecutableToolCalls` guards. Also covers calls to tool names
|
|
2396
|
+
// not present in `options.tools` at all.
|
|
2397
|
+
const resolvedCallIds = new Set(toolResults.map((r) => r.callId));
|
|
2398
|
+
const unresolvedToolCalls = currentToolCalls.filter((tc) => !resolvedCallIds.has(tc.id));
|
|
2399
|
+
if (unresolvedToolCalls.length > 0) {
|
|
2400
|
+
// Mixed auto + manual (or unknown-name) round — regular tool
|
|
2401
|
+
// outputs were already persisted via saveToolResultsToState above;
|
|
2402
|
+
// surface the unresolved calls on pendingToolCalls and stop.
|
|
2403
|
+
await this.persistClientToolsPause(currentResponse, unresolvedToolCalls);
|
|
2404
|
+
return;
|
|
1428
2405
|
}
|
|
2406
|
+
// Apply nextTurnParams
|
|
2407
|
+
await this.applyNextTurnParams(currentToolCalls);
|
|
2408
|
+
currentResponse = await this.makeFollowupRequest(currentResponse, toolResults, turnNumber);
|
|
2409
|
+
// A fresh response replaces the prior one -- that's new progress,
|
|
2410
|
+
// so reset consecutive forceResume counting.
|
|
2411
|
+
forceResumeCount = 0;
|
|
2412
|
+
await this.options.onTurnEnd?.(turnContext, currentResponse);
|
|
2413
|
+
// Save new response to state
|
|
2414
|
+
await this.saveResponseToState(currentResponse);
|
|
2415
|
+
currentRound++;
|
|
1429
2416
|
}
|
|
1430
|
-
//
|
|
1431
|
-
|
|
1432
|
-
|
|
1433
|
-
|
|
1434
|
-
|
|
1435
|
-
|
|
2417
|
+
// If stopWhen broke the loop while the model was still emitting tool
|
|
2418
|
+
// calls, execute those tool calls so they have matching outputs, then
|
|
2419
|
+
// make one more `toolChoice: 'none'` request to coerce a final text
|
|
2420
|
+
// response. Default-on: `undefined`, `true`, and any string enable
|
|
2421
|
+
// it (`''` means "don't append a user message"); `false` opts out.
|
|
2422
|
+
const allowFinalResponse = this.options.allowFinalResponse;
|
|
2423
|
+
const finalResponseEnabled = allowFinalResponse !== false;
|
|
2424
|
+
const pendingToolCalls = stoppedByStopWhen
|
|
2425
|
+
? extractToolCallsFromResponse(currentResponse)
|
|
2426
|
+
: [];
|
|
2427
|
+
if (stoppedByStopWhen &&
|
|
2428
|
+
finalResponseEnabled &&
|
|
2429
|
+
pendingToolCalls.length > 0 &&
|
|
2430
|
+
this.hasExecutableToolCalls(pendingToolCalls)) {
|
|
2431
|
+
const turnNumber = currentRound + 1;
|
|
2432
|
+
const turnContext = {
|
|
2433
|
+
numberOfTurns: turnNumber,
|
|
2434
|
+
};
|
|
2435
|
+
await this.options.onTurnStart?.(turnContext);
|
|
2436
|
+
await this.resolveAsyncFunctionsForTurn(turnContext);
|
|
2437
|
+
const { toolResults, pausedCalls } = await this.executeToolRound(pendingToolCalls, turnContext);
|
|
2438
|
+
// Track the executed round and persist real outputs BEFORE the HITL
|
|
2439
|
+
// pause check — mirrors the in-loop ordering at executeToolsIfNeeded
|
|
2440
|
+
// so a partial batch (HITL + regular tools) doesn't drop the regular
|
|
2441
|
+
// tool's output from state on resume.
|
|
2442
|
+
this.allToolExecutionRounds.push({
|
|
2443
|
+
round: currentRound,
|
|
2444
|
+
toolCalls: pendingToolCalls,
|
|
2445
|
+
response: currentResponse,
|
|
2446
|
+
toolResults: [
|
|
2447
|
+
...toolResults,
|
|
2448
|
+
],
|
|
2449
|
+
});
|
|
2450
|
+
await this.saveToolResultsToState(toolResults);
|
|
2451
|
+
if (pausedCalls.length > 0) {
|
|
2452
|
+
// HITL paused — persist and exit without making the final
|
|
2453
|
+
// text-coercion request. The conversation will resume via the
|
|
2454
|
+
// normal awaiting_hitl flow.
|
|
2455
|
+
await this.persistHitlPause(currentResponse, pausedCalls);
|
|
2456
|
+
return;
|
|
2457
|
+
}
|
|
2458
|
+
// Apply any nextTurnParams from the executed tools so they affect the
|
|
2459
|
+
// final text-coercion request (mirrors the in-loop behavior).
|
|
2460
|
+
await this.applyNextTurnParams(pendingToolCalls);
|
|
2461
|
+
// Pair any manual tool calls (no execute fn) with stub outputs so
|
|
2462
|
+
// every function_call in the *request* has a matching output. Stubs
|
|
2463
|
+
// are NOT persisted to state — only real tool outputs are — so a
|
|
2464
|
+
// resumed conversation doesn't see "Tool execution skipped" as if it
|
|
2465
|
+
// were a real result.
|
|
2466
|
+
const executedCallIds = new Set(toolResults.map((r) => r.callId));
|
|
2467
|
+
const stubOutputs = pendingToolCalls
|
|
2468
|
+
.filter((tc) => !executedCallIds.has(tc.id))
|
|
2469
|
+
.map((tc) => ({
|
|
2470
|
+
type: 'function_call_output',
|
|
2471
|
+
callId: tc.id,
|
|
2472
|
+
output: 'Tool execution skipped: step limit reached.',
|
|
2473
|
+
}));
|
|
2474
|
+
const requestOutputs = [
|
|
1436
2475
|
...toolResults,
|
|
1437
|
-
...
|
|
1438
|
-
]
|
|
1439
|
-
|
|
1440
|
-
|
|
1441
|
-
|
|
1442
|
-
// If any HITL tools paused this round, stop here without making a
|
|
1443
|
-
// follow-up request — sending an incomplete set of outputs would be
|
|
1444
|
-
// incorrect. Persist the paused calls so the caller can resume later.
|
|
1445
|
-
if (pausedCalls.length > 0) {
|
|
1446
|
-
await this.persistHitlPause(currentResponse, pausedCalls);
|
|
1447
|
-
return;
|
|
2476
|
+
...stubOutputs,
|
|
2477
|
+
];
|
|
2478
|
+
currentResponse = await this.makeFinalResponseRequest(currentResponse, requestOutputs, allowFinalResponse, turnNumber);
|
|
2479
|
+
await this.options.onTurnEnd?.(turnContext, currentResponse);
|
|
2480
|
+
await this.saveResponseToState(currentResponse);
|
|
1448
2481
|
}
|
|
1449
|
-
//
|
|
1450
|
-
|
|
1451
|
-
|
|
1452
|
-
|
|
1453
|
-
|
|
1454
|
-
|
|
1455
|
-
|
|
1456
|
-
|
|
1457
|
-
|
|
1458
|
-
|
|
1459
|
-
|
|
1460
|
-
|
|
1461
|
-
|
|
1462
|
-
|
|
1463
|
-
|
|
1464
|
-
|
|
1465
|
-
|
|
1466
|
-
|
|
1467
|
-
|
|
1468
|
-
|
|
1469
|
-
|
|
1470
|
-
|
|
1471
|
-
|
|
1472
|
-
|
|
1473
|
-
|
|
1474
|
-
|
|
1475
|
-
|
|
1476
|
-
|
|
1477
|
-
|
|
1478
|
-
//
|
|
1479
|
-
//
|
|
1480
|
-
//
|
|
1481
|
-
//
|
|
1482
|
-
|
|
1483
|
-
|
|
1484
|
-
|
|
1485
|
-
|
|
1486
|
-
|
|
1487
|
-
|
|
1488
|
-
|
|
1489
|
-
}
|
|
1490
|
-
|
|
1491
|
-
|
|
1492
|
-
// HITL paused — persist and exit without making the final no-tools
|
|
1493
|
-
// request. The conversation will resume via the normal awaiting_hitl
|
|
1494
|
-
// flow.
|
|
1495
|
-
await this.persistHitlPause(currentResponse, pausedCalls);
|
|
1496
|
-
return;
|
|
2482
|
+
// Validate and finalize. Mini-class models intermittently return an
|
|
2483
|
+
// empty final turn after a successful tool round (the tool call was
|
|
2484
|
+
// the answer). Retry once, then tolerate empty output so a completed
|
|
2485
|
+
// run isn't reported as failure — unless `strictFinalResponse` is set.
|
|
2486
|
+
const canTolerateEmptyFinal = this.allToolExecutionRounds.length > 0 && this.options.strictFinalResponse !== true;
|
|
2487
|
+
const isEmptyOutput = Array.isArray(currentResponse.output) && currentResponse.output.length === 0;
|
|
2488
|
+
if (canTolerateEmptyFinal && isEmptyOutput) {
|
|
2489
|
+
const turnNumber = this.allToolExecutionRounds.length + 1;
|
|
2490
|
+
currentResponse = await this.retryCurrentRequest(turnNumber);
|
|
2491
|
+
// Persist the retried response like every other response in the
|
|
2492
|
+
// loop — otherwise stateful conversations silently lose the final
|
|
2493
|
+
// turn's content on resume.
|
|
2494
|
+
await this.saveResponseToState(currentResponse);
|
|
2495
|
+
}
|
|
2496
|
+
const allowEmptyOutput = canTolerateEmptyFinal &&
|
|
2497
|
+
Array.isArray(currentResponse.output) &&
|
|
2498
|
+
currentResponse.output.length === 0;
|
|
2499
|
+
this.validateFinalResponse(currentResponse, allowEmptyOutput);
|
|
2500
|
+
this.finalResponse = currentResponse;
|
|
2501
|
+
await this.markStateComplete();
|
|
2502
|
+
}
|
|
2503
|
+
catch (error) {
|
|
2504
|
+
sessionEndReason = 'error';
|
|
2505
|
+
throw error;
|
|
2506
|
+
}
|
|
2507
|
+
finally {
|
|
2508
|
+
// Session teardown must never mask the original error: a throw from
|
|
2509
|
+
// a `finally` block replaces the in-flight exception, so a throwing
|
|
2510
|
+
// SessionEnd handler (strict mode) would silently swallow the real
|
|
2511
|
+
// root cause. Log teardown failures instead.
|
|
2512
|
+
//
|
|
2513
|
+
// drain() runs unconditionally (not gated on SessionStart) so
|
|
2514
|
+
// fire-and-forget hook work from paths that skip SessionStart --
|
|
2515
|
+
// e.g. approval resume, which runs Pre/PostToolUse in initStream --
|
|
2516
|
+
// is still awaited before the run settles.
|
|
2517
|
+
try {
|
|
2518
|
+
await this.emitSessionEndOnce(sessionEndReason);
|
|
2519
|
+
if (this.hooksManager) {
|
|
2520
|
+
await this.hooksManager.drain();
|
|
2521
|
+
}
|
|
2522
|
+
}
|
|
2523
|
+
catch (teardownError) {
|
|
2524
|
+
console.warn('[SessionEnd] error during session teardown:', teardownError);
|
|
1497
2525
|
}
|
|
1498
|
-
// Apply any nextTurnParams from the executed tools so they affect the
|
|
1499
|
-
// final no-tools request (mirrors the in-loop behavior).
|
|
1500
|
-
await this.applyNextTurnParams(pendingToolCalls);
|
|
1501
|
-
// Pair any manual tool calls (no execute fn) with stub outputs so
|
|
1502
|
-
// every function_call in the *request* has a matching output. Stubs
|
|
1503
|
-
// are NOT persisted to state — only real tool outputs are — so a
|
|
1504
|
-
// resumed conversation doesn't see "Tool execution skipped" as if it
|
|
1505
|
-
// were a real result.
|
|
1506
|
-
const executedCallIds = new Set(toolResults.map((r) => r.callId));
|
|
1507
|
-
const stubOutputs = pendingToolCalls
|
|
1508
|
-
.filter((tc) => !executedCallIds.has(tc.id))
|
|
1509
|
-
.map((tc) => ({
|
|
1510
|
-
type: 'function_call_output',
|
|
1511
|
-
callId: tc.id,
|
|
1512
|
-
output: 'Tool execution skipped: step limit reached.',
|
|
1513
|
-
}));
|
|
1514
|
-
const requestOutputs = [
|
|
1515
|
-
...toolResults,
|
|
1516
|
-
...stubOutputs,
|
|
1517
|
-
];
|
|
1518
|
-
currentResponse = await this.makeFinalResponseRequest(currentResponse, requestOutputs, allowFinalResponse, turnNumber);
|
|
1519
|
-
await this.options.onTurnEnd?.(turnContext, currentResponse);
|
|
1520
|
-
await this.saveResponseToState(currentResponse);
|
|
1521
2526
|
}
|
|
1522
|
-
// Validate and finalize
|
|
1523
|
-
this.validateFinalResponse(currentResponse);
|
|
1524
|
-
this.finalResponse = currentResponse;
|
|
1525
|
-
await this.markStateComplete();
|
|
1526
2527
|
})();
|
|
1527
2528
|
return this.toolExecutionPromise;
|
|
1528
2529
|
}
|
|
@@ -1566,17 +2567,24 @@ export class ModelResult {
|
|
|
1566
2567
|
*/
|
|
1567
2568
|
getFullResponsesStream() {
|
|
1568
2569
|
return async function* () {
|
|
1569
|
-
await this.
|
|
1570
|
-
if (!this.reusableStream && !this.finalResponse) {
|
|
1571
|
-
throw new Error('Stream not initialized');
|
|
1572
|
-
}
|
|
2570
|
+
await this.initStreamGuarded();
|
|
1573
2571
|
if (!this.options.tools?.length) {
|
|
1574
|
-
|
|
1575
|
-
|
|
1576
|
-
|
|
1577
|
-
|
|
2572
|
+
let streamFailed = false;
|
|
2573
|
+
try {
|
|
2574
|
+
if (this.reusableStream) {
|
|
2575
|
+
const consumer = this.reusableStream.createConsumer();
|
|
2576
|
+
for await (const event of consumer) {
|
|
2577
|
+
yield event;
|
|
2578
|
+
}
|
|
1578
2579
|
}
|
|
1579
2580
|
}
|
|
2581
|
+
catch (error) {
|
|
2582
|
+
streamFailed = true;
|
|
2583
|
+
throw error;
|
|
2584
|
+
}
|
|
2585
|
+
finally {
|
|
2586
|
+
await this.finishHooksSessionForStream(streamFailed ? 'error' : 'complete');
|
|
2587
|
+
}
|
|
1580
2588
|
return;
|
|
1581
2589
|
}
|
|
1582
2590
|
const { consumer, executionPromise } = this.startTurnBroadcasterExecution();
|
|
@@ -1593,13 +2601,20 @@ export class ModelResult {
|
|
|
1593
2601
|
*/
|
|
1594
2602
|
getTextStream() {
|
|
1595
2603
|
return async function* () {
|
|
1596
|
-
await this.
|
|
1597
|
-
if (!this.reusableStream && !this.finalResponse) {
|
|
1598
|
-
throw new Error('Stream not initialized');
|
|
1599
|
-
}
|
|
2604
|
+
await this.initStreamGuarded();
|
|
1600
2605
|
if (!this.options.tools?.length) {
|
|
1601
|
-
|
|
1602
|
-
|
|
2606
|
+
let streamFailed = false;
|
|
2607
|
+
try {
|
|
2608
|
+
if (this.reusableStream) {
|
|
2609
|
+
yield* extractTextDeltas(this.reusableStream);
|
|
2610
|
+
}
|
|
2611
|
+
}
|
|
2612
|
+
catch (error) {
|
|
2613
|
+
streamFailed = true;
|
|
2614
|
+
throw error;
|
|
2615
|
+
}
|
|
2616
|
+
finally {
|
|
2617
|
+
await this.finishHooksSessionForStream(streamFailed ? 'error' : 'complete');
|
|
1603
2618
|
}
|
|
1604
2619
|
return;
|
|
1605
2620
|
}
|
|
@@ -1652,19 +2667,26 @@ export class ModelResult {
|
|
|
1652
2667
|
return false;
|
|
1653
2668
|
};
|
|
1654
2669
|
return async function* () {
|
|
1655
|
-
await this.
|
|
1656
|
-
if (!this.reusableStream && !this.finalResponse) {
|
|
1657
|
-
throw new Error('Stream not initialized');
|
|
1658
|
-
}
|
|
2670
|
+
await this.initStreamGuarded();
|
|
1659
2671
|
// No tools — stream single turn directly (no broadcaster needed)
|
|
1660
2672
|
if (!this.options.tools?.length) {
|
|
1661
|
-
|
|
1662
|
-
|
|
1663
|
-
|
|
1664
|
-
|
|
2673
|
+
let streamFailed = false;
|
|
2674
|
+
try {
|
|
2675
|
+
if (this.reusableStream) {
|
|
2676
|
+
for await (const item of buildItemsStream(this.reusableStream)) {
|
|
2677
|
+
if (isInScope(item)) {
|
|
2678
|
+
yield item;
|
|
2679
|
+
}
|
|
1665
2680
|
}
|
|
1666
2681
|
}
|
|
1667
2682
|
}
|
|
2683
|
+
catch (error) {
|
|
2684
|
+
streamFailed = true;
|
|
2685
|
+
throw error;
|
|
2686
|
+
}
|
|
2687
|
+
finally {
|
|
2688
|
+
await this.finishHooksSessionForStream(streamFailed ? 'error' : 'complete');
|
|
2689
|
+
}
|
|
1668
2690
|
return;
|
|
1669
2691
|
}
|
|
1670
2692
|
// Use turnBroadcaster (same pattern as getTextStream/getFullResponsesStream).
|
|
@@ -1806,10 +2828,10 @@ export class ModelResult {
|
|
|
1806
2828
|
*/
|
|
1807
2829
|
getNewMessagesStream() {
|
|
1808
2830
|
return async function* () {
|
|
1809
|
-
|
|
1810
|
-
|
|
1811
|
-
|
|
1812
|
-
|
|
2831
|
+
// Guarded: tears down the hook session (SessionEnd + drain) if
|
|
2832
|
+
// initStream throws after SessionStart. Includes the not-initialized
|
|
2833
|
+
// guard, so the manual check below is covered too.
|
|
2834
|
+
await this.initStreamGuarded();
|
|
1813
2835
|
// First yield messages from the stream in responses format
|
|
1814
2836
|
if (this.reusableStream) {
|
|
1815
2837
|
yield* buildResponsesMessageStream(this.reusableStream);
|
|
@@ -1862,13 +2884,20 @@ export class ModelResult {
|
|
|
1862
2884
|
*/
|
|
1863
2885
|
getReasoningStream() {
|
|
1864
2886
|
return async function* () {
|
|
1865
|
-
await this.
|
|
1866
|
-
if (!this.reusableStream && !this.finalResponse) {
|
|
1867
|
-
throw new Error('Stream not initialized');
|
|
1868
|
-
}
|
|
2887
|
+
await this.initStreamGuarded();
|
|
1869
2888
|
if (!this.options.tools?.length) {
|
|
1870
|
-
|
|
1871
|
-
|
|
2889
|
+
let streamFailed = false;
|
|
2890
|
+
try {
|
|
2891
|
+
if (this.reusableStream) {
|
|
2892
|
+
yield* extractReasoningDeltas(this.reusableStream);
|
|
2893
|
+
}
|
|
2894
|
+
}
|
|
2895
|
+
catch (error) {
|
|
2896
|
+
streamFailed = true;
|
|
2897
|
+
throw error;
|
|
2898
|
+
}
|
|
2899
|
+
finally {
|
|
2900
|
+
await this.finishHooksSessionForStream(streamFailed ? 'error' : 'complete');
|
|
1872
2901
|
}
|
|
1873
2902
|
return;
|
|
1874
2903
|
}
|
|
@@ -1889,19 +2918,26 @@ export class ModelResult {
|
|
|
1889
2918
|
*/
|
|
1890
2919
|
getToolStream() {
|
|
1891
2920
|
return async function* () {
|
|
1892
|
-
await this.
|
|
1893
|
-
if (!this.reusableStream && !this.finalResponse) {
|
|
1894
|
-
throw new Error('Stream not initialized');
|
|
1895
|
-
}
|
|
2921
|
+
await this.initStreamGuarded();
|
|
1896
2922
|
if (!this.options.tools?.length) {
|
|
1897
|
-
|
|
1898
|
-
|
|
1899
|
-
|
|
1900
|
-
|
|
1901
|
-
|
|
1902
|
-
|
|
2923
|
+
let streamFailed = false;
|
|
2924
|
+
try {
|
|
2925
|
+
if (this.reusableStream) {
|
|
2926
|
+
for await (const delta of extractToolDeltas(this.reusableStream)) {
|
|
2927
|
+
yield {
|
|
2928
|
+
type: 'delta',
|
|
2929
|
+
content: delta,
|
|
2930
|
+
};
|
|
2931
|
+
}
|
|
1903
2932
|
}
|
|
1904
2933
|
}
|
|
2934
|
+
catch (error) {
|
|
2935
|
+
streamFailed = true;
|
|
2936
|
+
throw error;
|
|
2937
|
+
}
|
|
2938
|
+
finally {
|
|
2939
|
+
await this.finishHooksSessionForStream(streamFailed ? 'error' : 'complete');
|
|
2940
|
+
}
|
|
1905
2941
|
return;
|
|
1906
2942
|
}
|
|
1907
2943
|
const { consumer, executionPromise } = this.startTurnBroadcasterExecution();
|
|
@@ -1931,7 +2967,9 @@ export class ModelResult {
|
|
|
1931
2967
|
* Returns structured tool calls with parsed arguments.
|
|
1932
2968
|
*/
|
|
1933
2969
|
async getToolCalls() {
|
|
1934
|
-
await this.
|
|
2970
|
+
await this.initStreamGuarded({
|
|
2971
|
+
requireStream: false,
|
|
2972
|
+
});
|
|
1935
2973
|
// Handle non-streaming response case - use finalResponse directly
|
|
1936
2974
|
if (this.finalResponse) {
|
|
1937
2975
|
return extractToolCallsFromResponse(this.finalResponse);
|
|
@@ -1940,6 +2978,7 @@ export class ModelResult {
|
|
|
1940
2978
|
throw new Error('Stream not initialized');
|
|
1941
2979
|
}
|
|
1942
2980
|
const completedResponse = await consumeStreamForCompletion(this.reusableStream);
|
|
2981
|
+
await this.emitPendingModelCallOnce(completedResponse);
|
|
1943
2982
|
return extractToolCallsFromResponse(completedResponse);
|
|
1944
2983
|
}
|
|
1945
2984
|
/**
|
|
@@ -1948,10 +2987,8 @@ export class ModelResult {
|
|
|
1948
2987
|
*/
|
|
1949
2988
|
getToolCallsStream() {
|
|
1950
2989
|
return async function* () {
|
|
1951
|
-
|
|
1952
|
-
|
|
1953
|
-
throw new Error('Stream not initialized');
|
|
1954
|
-
}
|
|
2990
|
+
// Guarded: hook-session teardown on init failure (see initStreamGuarded).
|
|
2991
|
+
await this.initStreamGuarded();
|
|
1955
2992
|
if (this.reusableStream) {
|
|
1956
2993
|
yield* buildToolCallStream(this.reusableStream);
|
|
1957
2994
|
}
|
|
@@ -1970,8 +3007,11 @@ export class ModelResult {
|
|
|
1970
3007
|
* ```
|
|
1971
3008
|
*/
|
|
1972
3009
|
async *getContextUpdates() {
|
|
1973
|
-
// Ensure stream is initialized (which creates the context store)
|
|
1974
|
-
|
|
3010
|
+
// Ensure stream is initialized (which creates the context store).
|
|
3011
|
+
// Guarded: hook-session teardown on init failure.
|
|
3012
|
+
await this.initStreamGuarded({
|
|
3013
|
+
requireStream: false,
|
|
3014
|
+
});
|
|
1975
3015
|
if (!this.contextStore) {
|
|
1976
3016
|
return;
|
|
1977
3017
|
}
|
|
@@ -2033,16 +3073,21 @@ export class ModelResult {
|
|
|
2033
3073
|
// Multi-Turn Conversation State Methods
|
|
2034
3074
|
// =========================================================================
|
|
2035
3075
|
/**
|
|
2036
|
-
* Check if the conversation requires human input to continue.
|
|
3076
|
+
* Check if the conversation requires human/client input to continue.
|
|
2037
3077
|
* Returns true when the conversation is paused waiting for caller-supplied
|
|
2038
|
-
* decisions —
|
|
2039
|
-
*
|
|
2040
|
-
* is populated regardless of
|
|
3078
|
+
* decisions — approval/rejection (`awaiting_approval`), HITL tool resume
|
|
3079
|
+
* (`awaiting_hitl`), or client-executed manual tools (`awaiting_client_tools`).
|
|
3080
|
+
* Also returns true whenever `pendingToolCalls` is populated regardless of
|
|
3081
|
+
* status.
|
|
2041
3082
|
*/
|
|
2042
3083
|
async requiresApproval() {
|
|
2043
|
-
await this.
|
|
3084
|
+
await this.initStreamGuarded({
|
|
3085
|
+
requireStream: false,
|
|
3086
|
+
});
|
|
2044
3087
|
const status = this.currentState?.status;
|
|
2045
|
-
if (status === 'awaiting_approval' ||
|
|
3088
|
+
if (status === 'awaiting_approval' ||
|
|
3089
|
+
status === 'awaiting_hitl' ||
|
|
3090
|
+
status === 'awaiting_client_tools') {
|
|
2046
3091
|
return true;
|
|
2047
3092
|
}
|
|
2048
3093
|
// Also check if pendingToolCalls is populated
|
|
@@ -2053,7 +3098,9 @@ export class ModelResult {
|
|
|
2053
3098
|
* Returns empty array if no approvals needed.
|
|
2054
3099
|
*/
|
|
2055
3100
|
async getPendingToolCalls() {
|
|
2056
|
-
await this.
|
|
3101
|
+
await this.initStreamGuarded({
|
|
3102
|
+
requireStream: false,
|
|
3103
|
+
});
|
|
2057
3104
|
// Try to trigger tool execution to populate pending calls
|
|
2058
3105
|
if (!this.isResumingFromApproval) {
|
|
2059
3106
|
await this.executeToolsIfNeeded();
|
|
@@ -2067,7 +3114,9 @@ export class ModelResult {
|
|
|
2067
3114
|
* To resume a conversation, use the StateAccessor pattern.
|
|
2068
3115
|
*/
|
|
2069
3116
|
async getState() {
|
|
2070
|
-
await this.
|
|
3117
|
+
await this.initStreamGuarded({
|
|
3118
|
+
requireStream: false,
|
|
3119
|
+
});
|
|
2071
3120
|
// Ensure tool execution has been attempted (to populate final state)
|
|
2072
3121
|
if (!this.isResumingFromApproval) {
|
|
2073
3122
|
await this.executeToolsIfNeeded();
|