@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.
Files changed (64) hide show
  1. package/README.md +248 -17
  2. package/esm/index.d.ts +8 -5
  3. package/esm/index.d.ts.map +1 -1
  4. package/esm/index.js +12 -4
  5. package/esm/index.js.map +1 -1
  6. package/esm/inner-loop/call-model.d.ts +8 -5
  7. package/esm/inner-loop/call-model.d.ts.map +1 -1
  8. package/esm/inner-loop/call-model.js +16 -6
  9. package/esm/inner-loop/call-model.js.map +1 -1
  10. package/esm/lib/async-params.d.ts +25 -6
  11. package/esm/lib/async-params.d.ts.map +1 -1
  12. package/esm/lib/async-params.js +3 -1
  13. package/esm/lib/async-params.js.map +1 -1
  14. package/esm/lib/conversation-state.d.ts +44 -1
  15. package/esm/lib/conversation-state.d.ts.map +1 -1
  16. package/esm/lib/conversation-state.js +111 -0
  17. package/esm/lib/conversation-state.js.map +1 -1
  18. package/esm/lib/hooks-emit.d.ts +50 -0
  19. package/esm/lib/hooks-emit.d.ts.map +1 -0
  20. package/esm/lib/hooks-emit.js +307 -0
  21. package/esm/lib/hooks-emit.js.map +1 -0
  22. package/esm/lib/hooks-manager.d.ts +141 -0
  23. package/esm/lib/hooks-manager.d.ts.map +1 -0
  24. package/esm/lib/hooks-manager.js +282 -0
  25. package/esm/lib/hooks-manager.js.map +1 -0
  26. package/esm/lib/hooks-matchers.d.ts +12 -0
  27. package/esm/lib/hooks-matchers.d.ts.map +1 -0
  28. package/esm/lib/hooks-matchers.js +26 -0
  29. package/esm/lib/hooks-matchers.js.map +1 -0
  30. package/esm/lib/hooks-resolve.d.ts +17 -0
  31. package/esm/lib/hooks-resolve.d.ts.map +1 -0
  32. package/esm/lib/hooks-resolve.js +42 -0
  33. package/esm/lib/hooks-resolve.js.map +1 -0
  34. package/esm/lib/hooks-schemas.d.ts +188 -0
  35. package/esm/lib/hooks-schemas.d.ts.map +1 -0
  36. package/esm/lib/hooks-schemas.js +184 -0
  37. package/esm/lib/hooks-schemas.js.map +1 -0
  38. package/esm/lib/hooks-types.d.ts +174 -0
  39. package/esm/lib/hooks-types.d.ts.map +1 -0
  40. package/esm/lib/hooks-types.js +45 -0
  41. package/esm/lib/hooks-types.js.map +1 -0
  42. package/esm/lib/model-result.d.ts +209 -9
  43. package/esm/lib/model-result.d.ts.map +1 -1
  44. package/esm/lib/model-result.js +1397 -348
  45. package/esm/lib/model-result.js.map +1 -1
  46. package/esm/lib/reusable-stream.d.ts +6 -0
  47. package/esm/lib/reusable-stream.d.ts.map +1 -1
  48. package/esm/lib/reusable-stream.js +8 -0
  49. package/esm/lib/reusable-stream.js.map +1 -1
  50. package/esm/lib/tool-executor.d.ts.map +1 -1
  51. package/esm/lib/tool-executor.js +11 -1
  52. package/esm/lib/tool-executor.js.map +1 -1
  53. package/esm/lib/tool-orchestrator.d.ts.map +1 -1
  54. package/esm/lib/tool-orchestrator.js +4 -1
  55. package/esm/lib/tool-orchestrator.js.map +1 -1
  56. package/esm/lib/tool-types.d.ts +137 -45
  57. package/esm/lib/tool-types.d.ts.map +1 -1
  58. package/esm/lib/tool-types.js +13 -0
  59. package/esm/lib/tool-types.js.map +1 -1
  60. package/esm/lib/tool.d.ts +35 -25
  61. package/esm/lib/tool.d.ts.map +1 -1
  62. package/esm/lib/tool.js +19 -0
  63. package/esm/lib/tool.js.map +1 -1
  64. package/package.json +7 -3
@@ -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
- return consumeStreamForCompletion(this.reusableStream);
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
- await this.saveStateSafely({
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
- toolCallId: tr.callId,
388
- toolName: round.toolCalls.find((tc) => tc.id === tr.callId)?.name ?? '',
389
- result: typeof tr.output === 'string' ? JSON.parse(tr.output) : tr.output,
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
- const result = await executeTool(tool, tc, turnContext, undefined, this.contextStore ?? undefined, this.options.sharedContextSchema);
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
- // Validate: approval requires state accessor
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 = needsApproval.map((tc) => tc.name).join(', ');
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
- // Execute auto-approve tools
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
- // Save state with pending approvals
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: needsApproval,
1226
+ pendingToolCalls: stillPending,
501
1227
  status: 'awaiting_approval',
502
1228
  };
503
- if (unsentResults.length > 0) {
504
- stateUpdates.unsentToolResults = unsentResults;
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
- async executeToolRound(toolCalls, turnContext) {
582
- const toolCallPromises = toolCalls.map(async (toolCall) => {
583
- const tool = this.options.tools?.find((t) => isClientTool(t) && t.function.name === toolCall.name);
584
- if (!tool || !isAutoResolvableTool(tool)) {
585
- return null;
586
- }
587
- // Check if arguments failed to parse (remained as string instead of object)
588
- const args = toolCall.arguments;
589
- if (typeof args === 'string') {
590
- const rawArgs = args;
591
- const errorMessage = `Failed to parse tool call arguments for "${toolCall.name}": The model provided invalid JSON. ` +
592
- `Raw arguments received: "${rawArgs}". ` +
593
- 'Please provide valid JSON arguments for this tool call.';
594
- this.broadcastToolResult(toolCall.id, {
595
- error: errorMessage,
596
- });
597
- return {
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: 'execution',
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
- this.broadcastToolResult(originalToolCall.id, {
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
- // Handle streaming or non-streaming response
791
- const value = newResult.value;
792
- if (isEventStream(value)) {
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-tools request to coerce a text response after the loop
806
- * was halted by `stopWhen` mid-tool-call. Reuses the resolved request so
807
- * `instructions`, `model`, and other API fields ride along unchanged.
808
- * `tools`, `toolChoice`, and `parallelToolCalls` are stripped — the whole
809
- * point is to force a text turn. The caller is expected to have already
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 allowFinalResponse === 'string' && allowFinalResponse.length > 0
1612
+ ...(typeof finalDirective === 'string' && finalDirective.length > 0
837
1613
  ? [
838
1614
  {
839
1615
  role: 'user',
840
- content: allowFinalResponse,
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
- ...rest,
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 value = result.value;
858
- if (isEventStream(value)) {
859
- const stream = new ReusableReadableStream(value);
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
- // Filter out stopWhen and state-related fields that aren't part of the API request
897
- const { stopWhen: _, state: _s, requireApproval: _r, approveToolCalls: _a, rejectToolCalls: _rj, context: _c, ...rest } = this.options.request;
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
- // Update status to in_progress
1057
- await this.saveStateSafely({
1058
- status: 'in_progress',
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
- let freshItems;
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 result = await executeTool(tool, toolCall, turnContext, undefined, this.contextStore ?? undefined, this.options.sharedContextSchema);
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
- await this.initStream();
1346
- // If resuming from approval or HITL pause and still pending, don't continue.
1347
- // `processApprovalDecisions` runs in initStream for resumes; if it left us
1348
- // paused (any remaining pending calls), the outer loop should not execute.
1349
- if (this.isResumingFromApproval &&
1350
- (this.currentState?.status === 'awaiting_approval' ||
1351
- this.currentState?.status === 'awaiting_hitl')) {
1352
- return;
1353
- }
1354
- // Get initial response
1355
- let currentResponse = await this.getInitialResponse();
1356
- // Save initial response to state
1357
- await this.saveResponseToState(currentResponse);
1358
- // Check if tools should be executed
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
- // Check stop conditions
1385
- if (await this.shouldStopExecution()) {
1386
- stoppedByStopWhen = true;
1387
- break;
1388
- }
1389
- const currentToolCalls = extractToolCallsFromResponse(currentResponse);
1390
- if (currentToolCalls.length === 0) {
1391
- break;
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(currentToolCalls, currentRound + 1, currentResponse)) {
1395
- return;
2266
+ if (await this.handleApprovalCheck(toolCalls, 0, currentResponse)) {
2267
+ return; // Paused for approval
1396
2268
  }
1397
- if (!this.hasExecutableToolCalls(currentToolCalls)) {
1398
- break;
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
- // Build turn context
1401
- const turnNumber = currentRound + 1;
1402
- const turnContext = {
1403
- numberOfTurns: turnNumber,
1404
- };
1405
- await this.options.onTurnStart?.(turnContext);
1406
- // Resolve async functions for this turn
1407
- await this.resolveAsyncFunctionsForTurn(turnContext);
1408
- // Execute tools
1409
- const { toolResults, pausedCalls } = await this.executeToolRound(currentToolCalls, turnContext);
1410
- // Server-tool output items are already-executed results in the
1411
- // response; collect them so toolResults presents a unified list.
1412
- const serverToolItems = [];
1413
- for (const item of currentResponse.output) {
1414
- if (!hasTypeProperty(item)) {
1415
- continue;
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
- if (item.type === 'message' ||
1418
- item.type === 'reasoning' ||
1419
- item.type === 'function_call') {
1420
- continue;
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
- // Everything else is a server-tool output item (web_search_call,
1423
- // image_generation_call, file_search_call, or generic
1424
- // OutputServerToolItem covering openrouter:datetime and any new
1425
- // SDK server tool types).
1426
- if (isServerToolResultItem(item)) {
1427
- serverToolItems.push(item);
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
- // Track execution round
1431
- this.allToolExecutionRounds.push({
1432
- round: currentRound,
1433
- toolCalls: currentToolCalls,
1434
- response: currentResponse,
1435
- toolResults: [
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
- ...serverToolItems,
1438
- ],
1439
- });
1440
- // Save tool results to state
1441
- await this.saveToolResultsToState(toolResults);
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
- // Apply nextTurnParams
1450
- await this.applyNextTurnParams(currentToolCalls);
1451
- currentResponse = await this.makeFollowupRequest(currentResponse, toolResults, turnNumber);
1452
- await this.options.onTurnEnd?.(turnContext, currentResponse);
1453
- // Save new response to state
1454
- await this.saveResponseToState(currentResponse);
1455
- currentRound++;
1456
- }
1457
- // If stopWhen broke the loop while the model was still emitting tool
1458
- // calls, execute those tool calls so they have matching outputs, then
1459
- // make one more no-tools request to coerce a final text response. An
1460
- // empty string still counts as "on" — it just means "don't append a
1461
- // user message."
1462
- const allowFinalResponse = this.options.allowFinalResponse;
1463
- const finalResponseEnabled = allowFinalResponse === true || typeof allowFinalResponse === 'string';
1464
- const pendingToolCalls = stoppedByStopWhen
1465
- ? extractToolCallsFromResponse(currentResponse)
1466
- : [];
1467
- if (stoppedByStopWhen &&
1468
- finalResponseEnabled &&
1469
- pendingToolCalls.length > 0 &&
1470
- this.hasExecutableToolCalls(pendingToolCalls)) {
1471
- const turnNumber = currentRound + 1;
1472
- const turnContext = {
1473
- numberOfTurns: turnNumber,
1474
- };
1475
- await this.options.onTurnStart?.(turnContext);
1476
- await this.resolveAsyncFunctionsForTurn(turnContext);
1477
- const { toolResults, pausedCalls } = await this.executeToolRound(pendingToolCalls, turnContext);
1478
- // Track the executed round and persist real outputs BEFORE the HITL
1479
- // pause check — mirrors the in-loop ordering at executeToolsIfNeeded
1480
- // so a partial batch (HITL + regular tools) doesn't drop the regular
1481
- // tool's output from state on resume.
1482
- this.allToolExecutionRounds.push({
1483
- round: currentRound,
1484
- toolCalls: pendingToolCalls,
1485
- response: currentResponse,
1486
- toolResults: [
1487
- ...toolResults,
1488
- ],
1489
- });
1490
- await this.saveToolResultsToState(toolResults);
1491
- if (pausedCalls.length > 0) {
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.initStream();
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
- if (this.reusableStream) {
1575
- const consumer = this.reusableStream.createConsumer();
1576
- for await (const event of consumer) {
1577
- yield event;
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.initStream();
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
- if (this.reusableStream) {
1602
- yield* extractTextDeltas(this.reusableStream);
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.initStream();
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
- if (this.reusableStream) {
1662
- for await (const item of buildItemsStream(this.reusableStream)) {
1663
- if (isInScope(item)) {
1664
- yield item;
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
- await this.initStream();
1810
- if (!this.reusableStream && !this.finalResponse) {
1811
- throw new Error('Stream not initialized');
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.initStream();
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
- if (this.reusableStream) {
1871
- yield* extractReasoningDeltas(this.reusableStream);
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.initStream();
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
- if (this.reusableStream) {
1898
- for await (const delta of extractToolDeltas(this.reusableStream)) {
1899
- yield {
1900
- type: 'delta',
1901
- content: delta,
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.initStream();
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
- await this.initStream();
1952
- if (!this.reusableStream && !this.finalResponse) {
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
- await this.initStream();
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 — either approval/rejection (`awaiting_approval`) or HITL tool
2039
- * resume (`awaiting_hitl`). Also returns true whenever `pendingToolCalls`
2040
- * is populated regardless of status.
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.initStream();
3084
+ await this.initStreamGuarded({
3085
+ requireStream: false,
3086
+ });
2044
3087
  const status = this.currentState?.status;
2045
- if (status === 'awaiting_approval' || status === 'awaiting_hitl') {
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.initStream();
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.initStream();
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();