@openrouter/agent 0.4.0 → 0.6.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.
@@ -3,18 +3,13 @@ import { hasAsyncFunctions, resolveAsyncFunctions } from './async-params.js';
3
3
  import { appendToMessages, createInitialState, createRejectedResult, createUnsentResult, extractTextFromResponse as extractTextFromResponseState, partitionToolCalls, unsentResultsToAPIFormat, updateState, } from './conversation-state.js';
4
4
  import { applyNextTurnParamsToRequest, executeNextTurnParamsFunctions, } from './next-turn-params.js';
5
5
  import { ReusableReadableStream } from './reusable-stream.js';
6
- import { isStopConditionMet, stepCountIs } from './stop-conditions.js';
6
+ import { isStopConditionMet } from './stop-conditions.js';
7
7
  import { buildItemsStream, buildResponsesMessageStream, buildToolCallStream, consumeStreamForCompletion, extractReasoningDeltas, extractResponsesMessageFromResponse, extractTextDeltas, extractTextFromResponse, extractToolCallsFromResponse, extractToolDeltas, itemsStreamHandlers, streamTerminationEvents, } from './stream-transformers.js';
8
8
  import { hasTypeProperty, isFunctionCallItem, isFunctionCallOutputItem, isOutputTextDeltaEvent, isReasoningDeltaEvent, isResponseCompletedEvent, isResponseFailedEvent, isResponseIncompleteEvent, isServerToolResultItem, } from './stream-type-guards.js';
9
9
  import { resolveContext, ToolContextStore } from './tool-context.js';
10
10
  import { ToolEventBroadcaster } from './tool-event-broadcaster.js';
11
- import { executeTool } from './tool-executor.js';
12
- import { hasExecuteFunction, isClientTool, isServerTool, isToolCallOutputEvent, } from './tool-types.js';
13
- /**
14
- * Default maximum number of tool execution steps if no stopWhen is specified.
15
- * This prevents infinite loops in tool execution.
16
- */
17
- const DEFAULT_MAX_STEPS = 5;
11
+ import { applyOnResponseReceivedHooks, executeTool } from './tool-executor.js';
12
+ import { isAutoResolvableTool, isClientTool, isServerTool, isToolCallOutputEvent, } from './tool-types.js';
18
13
  /**
19
14
  * Typeguard for plain-object records (non-null, non-array).
20
15
  */
@@ -22,21 +17,31 @@ function isRecord(value) {
22
17
  return typeof value === 'object' && value !== null && !Array.isArray(value);
23
18
  }
24
19
  /**
25
- * Type guard for stream event with toReadableStream method
26
- * Checks constructor name, prototype, and method availability
20
+ * Human-readable label for a value that failed the `isRecord` check. Used
21
+ * exclusively to make `toModelOutput` misuse errors specific.
22
+ */
23
+ function describeNonRecord(value) {
24
+ if (value === null) {
25
+ return 'null';
26
+ }
27
+ if (Array.isArray(value)) {
28
+ return 'array';
29
+ }
30
+ return typeof value;
31
+ }
32
+ /**
33
+ * Type guard for stream event responses
34
+ * Checks constructor name and readable stream behavior
27
35
  */
28
36
  function isEventStream(value) {
29
37
  if (value === null || typeof value !== 'object') {
30
38
  return false;
31
39
  }
32
- // Check constructor name for EventStream
33
- const constructorName = Object.getPrototypeOf(value)?.constructor?.name;
34
- if (constructorName === 'EventStream') {
40
+ if (typeof ReadableStream !== 'undefined' && value instanceof ReadableStream) {
35
41
  return true;
36
42
  }
37
- // Fallback: check for toReadableStream method (may be on prototype)
38
43
  const maybeStream = value;
39
- return typeof maybeStream.toReadableStream === 'function';
44
+ return typeof maybeStream.getReader === 'function';
40
45
  }
41
46
  /**
42
47
  * A wrapper around a streaming response that provides multiple consumption patterns.
@@ -242,13 +247,10 @@ export class ModelResult {
242
247
  }
243
248
  /**
244
249
  * Type guard to check if a value is a non-streaming response
245
- * Only requires 'output' field and absence of 'toReadableStream' method
250
+ * Only requires 'output' field and absence of readable stream behavior
246
251
  */
247
252
  isNonStreamingResponse(value) {
248
- return (value !== null &&
249
- typeof value === 'object' &&
250
- 'output' in value &&
251
- !('toReadableStream' in value));
253
+ return (value !== null && typeof value === 'object' && 'output' in value && !isEventStream(value));
252
254
  }
253
255
  // =========================================================================
254
256
  // Extracted Helper Methods for executeToolsIfNeeded
@@ -346,11 +348,16 @@ export class ModelResult {
346
348
  * Returns true if execution should stop.
347
349
  *
348
350
  * @remarks
349
- * Default: stepCountIs(DEFAULT_MAX_STEPS) if no stopWhen is specified.
351
+ * When no `stopWhen` is specified, this returns false and execution stops
352
+ * only when the model produces a turn without tool calls. Pass an explicit
353
+ * `stopWhen` (e.g. `stepCountIs(n)`, `maxCost(...)`) to bound the loop.
350
354
  * This evaluates stop conditions against the complete step history.
351
355
  */
352
356
  async shouldStopExecution() {
353
- const stopWhen = this.options.stopWhen ?? stepCountIs(DEFAULT_MAX_STEPS);
357
+ const { stopWhen } = this.options;
358
+ if (stopWhen === undefined) {
359
+ return false;
360
+ }
354
361
  const stopConditions = Array.isArray(stopWhen)
355
362
  ? stopWhen
356
363
  : [
@@ -380,21 +387,32 @@ export class ModelResult {
380
387
  });
381
388
  }
382
389
  /**
383
- * Check if any tool calls have execute functions.
390
+ * Check if any tool calls can be auto-resolved in the current turn.
384
391
  * Used to determine if automatic tool execution should be attempted.
385
392
  *
393
+ * A tool call is auto-resolvable if its tool has either an `execute` function
394
+ * (regular or generator) or an `onToolCalled` hook (HITL). HITL tools are
395
+ * included here because their hook fires before the model's follow-up request,
396
+ * even when the hook ultimately decides to pause by returning `null`.
397
+ *
386
398
  * @param toolCalls - The tool calls to check
387
- * @returns True if at least one tool call has an executable function
399
+ * @returns True if at least one tool call is auto-resolvable
388
400
  */
389
401
  hasExecutableToolCalls(toolCalls) {
390
402
  return toolCalls.some((toolCall) => {
391
403
  const tool = this.options.tools?.find((t) => isClientTool(t) && t.function.name === toolCall.name);
392
- return tool && hasExecuteFunction(tool);
404
+ return tool && isAutoResolvableTool(tool);
393
405
  });
394
406
  }
407
+ /**
408
+ * A manual tool call is one whose tool has neither an `execute` function nor
409
+ * an `onToolCalled` hook — i.e. the caller is expected to produce the output
410
+ * externally. HITL tools are auto-resolvable even when they pause, so they
411
+ * are not classified as manual here.
412
+ */
395
413
  isManualToolCall(item) {
396
414
  const tool = this.options.tools?.find((t) => isClientTool(t) && t.function.name === item.name);
397
- return !!tool && !hasExecuteFunction(tool);
415
+ return !!tool && !isAutoResolvableTool(tool);
398
416
  }
399
417
  /**
400
418
  * Execute tools that can auto-execute (don't require approval) in parallel.
@@ -406,10 +424,14 @@ export class ModelResult {
406
424
  async executeAutoApproveTools(toolCalls, turnContext) {
407
425
  const toolCallPromises = toolCalls.map(async (tc) => {
408
426
  const tool = this.options.tools?.find((t) => isClientTool(t) && t.function.name === tc.name);
409
- if (!tool || !hasExecuteFunction(tool)) {
427
+ if (!tool || !isAutoResolvableTool(tool)) {
410
428
  return null;
411
429
  }
412
430
  const result = await executeTool(tool, tc, turnContext, undefined, this.contextStore ?? undefined, this.options.sharedContextSchema);
431
+ if (result === null) {
432
+ // HITL tool paused — no unsent result for this call in this round
433
+ return null;
434
+ }
413
435
  if (result.error) {
414
436
  return createRejectedResult(tc.id, String(tc.name), result.error.message);
415
437
  }
@@ -476,18 +498,81 @@ export class ModelResult {
476
498
  this.finalResponse = currentResponse;
477
499
  return true; // Pause for approval
478
500
  }
501
+ /**
502
+ * Persist state when one or more HITL tools paused during a round.
503
+ *
504
+ * Mirrors `handleApprovalCheck` so paused HITL calls are surfaced through
505
+ * `pendingToolCalls` (visible via `getPendingToolCalls()` / `getState()`).
506
+ * Sets the status to `awaiting_hitl` so the caller can discriminate HITL
507
+ * pauses from approval pauses.
508
+ *
509
+ * Already-executed results from the same round are persisted on the turn's
510
+ * message history via `saveToolResultsToState` (called by the outer loop
511
+ * before this helper) — no need to duplicate them in `unsentToolResults`.
512
+ *
513
+ * @param currentResponse - The response that produced the paused tool calls
514
+ * @param pausedCalls - HITL tool calls whose `onToolCalled` returned `null`
515
+ */
516
+ async persistHitlPause(currentResponse, pausedCalls) {
517
+ this.finalResponse = currentResponse;
518
+ if (!this.stateAccessor) {
519
+ return;
520
+ }
521
+ const stateUpdates = {
522
+ pendingToolCalls: pausedCalls,
523
+ status: 'awaiting_hitl',
524
+ };
525
+ await this.saveStateSafely(stateUpdates);
526
+ }
527
+ /**
528
+ * Compute the `output` payload sent to the model for a successfully
529
+ * settled tool execution. Routes through `toModelOutput` when the tool
530
+ * defines one (which may itself throw to surface an error), falls back to
531
+ * `JSON.stringify(result)` otherwise, and emits an error envelope when the
532
+ * executor itself reported an error.
533
+ */
534
+ async computeToolOutputForModel(value) {
535
+ if (value.result.error) {
536
+ return JSON.stringify({
537
+ error: value.result.error.message,
538
+ });
539
+ }
540
+ if (!isAutoResolvableTool(value.tool) || !value.tool.function.toModelOutput) {
541
+ return JSON.stringify(value.result.result);
542
+ }
543
+ // Arguments have already been validated upstream by the tool's Zod
544
+ // inputSchema (which must be a ZodObject), so the runtime shape is
545
+ // always a record here. A non-record value here signals a real upstream
546
+ // bug we want surfaced, not a case to paper over with `{}`.
547
+ const rawArgs = value.toolCall.arguments;
548
+ if (!isRecord(rawArgs)) {
549
+ throw new Error(`toolCall.arguments for "${value.toolCall.name}" must be an object after Zod validation, got ${describeNonRecord(rawArgs)}`);
550
+ }
551
+ const modelOutputResult = await value.tool.function.toModelOutput({
552
+ output: value.result.result,
553
+ input: rawArgs,
554
+ });
555
+ if (modelOutputResult.type === 'content') {
556
+ return modelOutputResult.value;
557
+ }
558
+ return JSON.stringify(value.result.result);
559
+ }
479
560
  /**
480
561
  * Execute all tools in a single round in parallel.
481
562
  * Emits tool.result events after tool execution completes.
482
563
  *
483
564
  * @param toolCalls - The tool calls to execute
484
565
  * @param turnContext - The current turn context
485
- * @returns Array of function call outputs formatted for the API
566
+ * @returns Object with the function call outputs formatted for the API and
567
+ * the list of HITL tool calls that paused (returned `null` from
568
+ * `onToolCalled`). Callers should break out of the execution loop when
569
+ * `pausedCalls` is non-empty rather than sending an incomplete set of
570
+ * outputs back to the model.
486
571
  */
487
572
  async executeToolRound(toolCalls, turnContext) {
488
573
  const toolCallPromises = toolCalls.map(async (toolCall) => {
489
574
  const tool = this.options.tools?.find((t) => isClientTool(t) && t.function.name === toolCall.name);
490
- if (!tool || !hasExecuteFunction(tool)) {
575
+ if (!tool || !isAutoResolvableTool(tool)) {
491
576
  return null;
492
577
  }
493
578
  // Check if arguments failed to parse (remained as string instead of object)
@@ -523,6 +608,13 @@ export class ModelResult {
523
608
  }
524
609
  : undefined;
525
610
  const result = await executeTool(tool, toolCall, turnContext, onPreliminaryResult, this.contextStore ?? undefined, this.options.sharedContextSchema);
611
+ if (result === null) {
612
+ // HITL tool paused — surface as manual (no output this round)
613
+ return {
614
+ type: 'paused',
615
+ toolCall,
616
+ };
617
+ }
526
618
  return {
527
619
  type: 'execution',
528
620
  toolCall,
@@ -533,6 +625,7 @@ export class ModelResult {
533
625
  });
534
626
  const settledResults = await Promise.allSettled(toolCallPromises);
535
627
  const toolResults = [];
628
+ const pausedCalls = [];
536
629
  for (let i = 0; i < settledResults.length; i++) {
537
630
  const settled = settledResults[i];
538
631
  const originalToolCall = toolCalls[i];
@@ -573,42 +666,21 @@ export class ModelResult {
573
666
  });
574
667
  continue;
575
668
  }
669
+ if (value.type === 'paused') {
670
+ // HITL tool returned null — record the pause so the caller can break
671
+ // out of the outer loop before attempting a follow-up request with an
672
+ // incomplete set of outputs. The call will be surfaced via state
673
+ // (pendingToolCalls + status='awaiting_hitl') for manual resume.
674
+ pausedCalls.push(value.toolCall);
675
+ continue;
676
+ }
576
677
  const toolResult = (value.result.error
577
678
  ? {
578
679
  error: value.result.error.message,
579
680
  }
580
681
  : value.result.result);
581
682
  this.broadcastToolResult(value.toolCall.id, toolResult, value.preliminaryResultsForCall.length > 0 ? value.preliminaryResultsForCall : undefined);
582
- let outputForModel;
583
- if (value.result.error) {
584
- outputForModel = JSON.stringify({
585
- error: value.result.error.message,
586
- });
587
- }
588
- else if (value.tool.function.toModelOutput) {
589
- // toModelOutput exists - call it (may throw, which surfaces the error).
590
- // Arguments have already been validated upstream by the tool's Zod
591
- // inputSchema (which must be a ZodObject), so the runtime shape is
592
- // always a record here. The `Tool` union widening loses the specific
593
- // InferToolInput type, so we re-narrow defensively — a non-record
594
- // value here signals a real upstream bug we want surfaced, not a
595
- // case to paper over with `{}`.
596
- const rawArgs = value.toolCall.arguments;
597
- if (!isRecord(rawArgs)) {
598
- throw new Error(`toolCall.arguments for "${value.toolCall.name}" must be an object after Zod validation, got ${rawArgs === null ? 'null' : Array.isArray(rawArgs) ? 'array' : typeof rawArgs}`);
599
- }
600
- const modelOutputResult = await value.tool.function.toModelOutput({
601
- output: value.result.result,
602
- input: rawArgs,
603
- });
604
- outputForModel =
605
- modelOutputResult.type === 'content'
606
- ? modelOutputResult.value
607
- : JSON.stringify(value.result.result);
608
- }
609
- else {
610
- outputForModel = JSON.stringify(value.result.result);
611
- }
683
+ const outputForModel = await this.computeToolOutputForModel(value);
612
684
  const executedOutput = {
613
685
  type: 'function_call_output',
614
686
  id: `output_${value.toolCall.id}`,
@@ -622,7 +694,10 @@ export class ModelResult {
622
694
  timestamp: Date.now(),
623
695
  });
624
696
  }
625
- return toolResults;
697
+ return {
698
+ toolResults,
699
+ pausedCalls,
700
+ };
626
701
  }
627
702
  /**
628
703
  * Resolve async functions for the current turn.
@@ -747,6 +822,72 @@ export class ModelResult {
747
822
  const { stopWhen: _, state: _s, requireApproval: _r, approveToolCalls: _a, rejectToolCalls: _rj, context: _c, ...rest } = this.options.request;
748
823
  return rest;
749
824
  }
825
+ /**
826
+ * Apply `onResponseReceived` hooks to the freshly-supplied input items
827
+ * only, without re-hooking historical items that live in
828
+ * `currentState.messages`. Historical `function_call` items are passed to
829
+ * `applyOnResponseReceivedHooks` purely as callId → toolName
830
+ * name-resolution context and are dropped from the returned array.
831
+ *
832
+ * This keeps hooks idempotent across `callModel` invocations on the same
833
+ * conversation: the first call hooks the caller-supplied output, and
834
+ * subsequent calls (which rehydrate state) do not re-fire it.
835
+ *
836
+ * @param freshItems - Items newly supplied this turn (not yet hooked).
837
+ * May contain any mix of InputsUnion array members — only
838
+ * `function_call_output` items are affected by hooks; everything else
839
+ * is returned unchanged.
840
+ * @param historicalItems - Existing messages from loaded state. Only
841
+ * `function_call` entries are consulted for name resolution; no other
842
+ * items are inspected and none are mutated.
843
+ * @param turnContext - Turn context for hook invocation
844
+ * @returns The fresh items in original order, with `output` rewritten on
845
+ * any `function_call_output` whose matching HITL tool defines
846
+ * `onResponseReceived`.
847
+ */
848
+ async applyHooksToFreshItems(freshItems, historicalItems, turnContext) {
849
+ if (freshItems.length === 0) {
850
+ return freshItems;
851
+ }
852
+ // Collect function_call items from history so the hook executor can
853
+ // resolve callId -> toolName without us having to mirror that logic.
854
+ const historyArray = Array.isArray(historicalItems)
855
+ ? historicalItems
856
+ : [
857
+ historicalItems,
858
+ ];
859
+ const functionCallItems = [];
860
+ for (const item of historyArray) {
861
+ if (isFunctionCallItem(item)) {
862
+ functionCallItems.push(item);
863
+ }
864
+ }
865
+ // Build a synthetic input that puts the historical function_calls
866
+ // BEFORE the fresh items. `applyOnResponseReceivedHooks` only rewrites
867
+ // function_call_output items, so the function_call items are seen only
868
+ // as name-resolution context.
869
+ const syntheticInput = [
870
+ ...functionCallItems,
871
+ ...freshItems,
872
+ ];
873
+ const hookedInput = await applyOnResponseReceivedHooks(syntheticInput, this.options.tools, turnContext, this.contextStore ?? undefined, this.options.sharedContextSchema);
874
+ if (hookedInput === syntheticInput) {
875
+ // No rewrites; return the originals unchanged.
876
+ return freshItems;
877
+ }
878
+ // Drop the leading function_call items we prepended; what remains is
879
+ // the fresh items in their original order (some with rewritten outputs).
880
+ const hookedArray = Array.isArray(hookedInput)
881
+ ? hookedInput
882
+ : [
883
+ hookedInput,
884
+ ];
885
+ if (hookedArray.length !== syntheticInput.length) {
886
+ // Shouldn't happen (hooks only rewrite in-place), but be conservative.
887
+ return freshItems;
888
+ }
889
+ return hookedArray.slice(functionCallItems.length);
890
+ }
750
891
  /**
751
892
  * Safely persist state with error handling.
752
893
  * Wraps state save operations to ensure failures are properly reported.
@@ -801,8 +942,14 @@ export class ModelResult {
801
942
  const loadedState = await this.stateAccessor.load();
802
943
  if (loadedState) {
803
944
  this.currentState = loadedState;
804
- // Check if we're resuming from awaiting_approval with decisions
805
- if (loadedState.status === 'awaiting_approval' &&
945
+ // Check if we're resuming from awaiting_approval or awaiting_hitl
946
+ // with decisions. `awaiting_hitl` reuses `processApprovalDecisions`
947
+ // because the resume mechanism is identical — the caller supplies
948
+ // `approveToolCalls`/`rejectToolCalls` for paused call IDs, and we
949
+ // re-invoke `executeTool` on approved calls (which re-runs
950
+ // `onToolCalled` for HITL tools).
951
+ const isResumableStatus = loadedState.status === 'awaiting_approval' || loadedState.status === 'awaiting_hitl';
952
+ if (isResumableStatus &&
806
953
  (this.approvedToolCalls.length > 0 || this.rejectedToolCalls.length > 0)) {
807
954
  // Initialize context store before resuming so tools have access
808
955
  if (this.options.context !== undefined) {
@@ -848,29 +995,55 @@ export class ModelResult {
848
995
  }
849
996
  // Resolve any async functions first
850
997
  let baseRequest = await this.resolveRequestForContext(initialContext);
851
- // If we have state with existing messages, use those as input
852
- if (this.currentState?.messages &&
998
+ // Split input into "historical" (already in state.messages) and "fresh"
999
+ // (newly supplied this turn). `onResponseReceived` must fire only for
1000
+ // fresh items — re-hooking historical outputs on every callModel call
1001
+ // would double-invoke non-idempotent hooks.
1002
+ const hasLoadedHistory = !!this.currentState?.messages &&
853
1003
  Array.isArray(this.currentState.messages) &&
854
- this.currentState.messages.length > 0) {
855
- // Append new input to existing messages
1004
+ this.currentState.messages.length > 0;
1005
+ if (hasLoadedHistory && this.currentState) {
1006
+ // `currentState.messages` is InputsUnion — keep it as that union so
1007
+ // appendToMessages (which expects InputsUnion) accepts it directly.
1008
+ const historicalMessages = this.currentState.messages;
1009
+ // Normalize the caller-supplied input for this turn into an array of
1010
+ // fresh items. Undefined stays undefined (no new items). The widening
1011
+ // to BaseInputsUnion[] matches the signature of appendToMessages and
1012
+ // mirrors the pre-existing pattern elsewhere in this file; the two
1013
+ // union shapes (InputsUnion1 vs BaseInputsUnion1) describe the same
1014
+ // SDK input items with different nominal types, and BaseInputsUnion
1015
+ // already includes `any` in its element type, so the runtime shape
1016
+ // is preserved either way.
856
1017
  const newInput = baseRequest.input;
857
- if (newInput) {
858
- const inputArray = Array.isArray(newInput)
1018
+ let freshItems;
1019
+ if (newInput !== undefined) {
1020
+ freshItems = Array.isArray(newInput)
859
1021
  ? newInput
860
1022
  : [
861
1023
  newInput,
862
1024
  ];
863
- baseRequest = {
864
- ...baseRequest,
865
- input: appendToMessages(this.currentState.messages, inputArray),
866
- };
867
- }
868
- else {
869
- baseRequest = {
870
- ...baseRequest,
871
- input: this.currentState.messages,
872
- };
873
1025
  }
1026
+ // Hook fresh items only (historical function_calls serve as
1027
+ // name-resolution context). Leave historical items untouched.
1028
+ const hookedFresh = freshItems
1029
+ ? await this.applyHooksToFreshItems(freshItems, historicalMessages, initialContext)
1030
+ : undefined;
1031
+ baseRequest = {
1032
+ ...baseRequest,
1033
+ input: hookedFresh
1034
+ ? appendToMessages(historicalMessages, hookedFresh)
1035
+ : historicalMessages,
1036
+ };
1037
+ }
1038
+ else if (baseRequest.input !== undefined) {
1039
+ // No loaded history — everything in input is fresh. Hook the whole
1040
+ // thing (non-array inputs pass through applyOnResponseReceivedHooks
1041
+ // unchanged).
1042
+ const hookedInput = await applyOnResponseReceivedHooks(baseRequest.input, this.options.tools, initialContext, this.contextStore ?? undefined, this.options.sharedContextSchema);
1043
+ baseRequest = {
1044
+ ...baseRequest,
1045
+ input: hookedInput,
1046
+ };
874
1047
  }
875
1048
  // Store resolved request with stream mode
876
1049
  this.resolvedRequest = {
@@ -917,6 +1090,9 @@ export class ModelResult {
917
1090
  numberOfTurns: this.allToolExecutionRounds.length + 1,
918
1091
  // context is handled via contextStore, not on TurnContext
919
1092
  };
1093
+ // Track approved HITL calls that paused (onToolCalled returned null) —
1094
+ // these stay in pendingToolCalls so the caller can resume them later.
1095
+ const hitlPausedIds = new Set();
920
1096
  // Process approvals - execute the approved tools
921
1097
  for (const callId of this.approvedToolCalls) {
922
1098
  const toolCall = pendingCalls.find((tc) => tc.id === callId);
@@ -924,12 +1100,18 @@ export class ModelResult {
924
1100
  continue;
925
1101
  }
926
1102
  const tool = this.options.tools?.find((t) => isClientTool(t) && t.function.name === toolCall.name);
927
- if (!tool || !hasExecuteFunction(tool)) {
1103
+ if (!tool || !isAutoResolvableTool(tool)) {
928
1104
  // Can't execute, create error result
929
1105
  unsentResults.push(createRejectedResult(callId, String(toolCall.name), 'Tool not found or not executable'));
930
1106
  continue;
931
1107
  }
932
1108
  const result = await executeTool(tool, toolCall, turnContext, undefined, this.contextStore ?? undefined, this.options.sharedContextSchema);
1109
+ if (result === null) {
1110
+ // HITL tool paused on approval — keep the call visible to the caller
1111
+ // via pendingToolCalls (status becomes 'awaiting_hitl' below).
1112
+ hitlPausedIds.add(callId);
1113
+ continue;
1114
+ }
933
1115
  if (result.error) {
934
1116
  unsentResults.push(createRejectedResult(callId, String(toolCall.name), result.error.message));
935
1117
  }
@@ -945,15 +1127,32 @@ export class ModelResult {
945
1127
  }
946
1128
  unsentResults.push(createRejectedResult(callId, String(toolCall.name), 'Rejected by user'));
947
1129
  }
948
- // Remove processed calls from pending
1130
+ // Remove processed calls from pending. Approved HITL calls that paused are
1131
+ // NOT considered processed — they stay on pendingToolCalls so getPendingToolCalls()
1132
+ // still surfaces them to the caller on resume.
949
1133
  const processedIds = new Set([
950
1134
  ...this.approvedToolCalls,
951
1135
  ...this.rejectedToolCalls,
952
- ]);
1136
+ ].filter((id) => !hitlPausedIds.has(id)));
953
1137
  const remainingPending = pendingCalls.filter((tc) => !processedIds.has(tc.id));
1138
+ // Determine status:
1139
+ // - Any still-unprocessed approval-required call keeps us in 'awaiting_approval'
1140
+ // - Otherwise, any HITL paused call moves us to 'awaiting_hitl'
1141
+ // - Otherwise, we continue with 'in_progress'
1142
+ const remainingUnresolvedApprovals = remainingPending.filter((tc) => !hitlPausedIds.has(tc.id));
1143
+ let nextStatus;
1144
+ if (remainingUnresolvedApprovals.length > 0) {
1145
+ nextStatus = 'awaiting_approval';
1146
+ }
1147
+ else if (hitlPausedIds.size > 0) {
1148
+ nextStatus = 'awaiting_hitl';
1149
+ }
1150
+ else {
1151
+ nextStatus = 'in_progress';
1152
+ }
954
1153
  // Update state - conditionally include optional properties only if they have values
955
1154
  const stateUpdates = {
956
- status: remainingPending.length > 0 ? 'awaiting_approval' : 'in_progress',
1155
+ status: nextStatus,
957
1156
  };
958
1157
  if (remainingPending.length > 0) {
959
1158
  stateUpdates.pendingToolCalls = remainingPending;
@@ -974,8 +1173,8 @@ export class ModelResult {
974
1173
  this.clearOptionalStateProperties(propsToClear);
975
1174
  await this.saveStateSafely();
976
1175
  }
977
- // If we still have pending approvals, stop here
978
- if (remainingPending.length > 0) {
1176
+ // If we are paused (for approval or for HITL), stop here
1177
+ if (nextStatus !== 'in_progress') {
979
1178
  return;
980
1179
  }
981
1180
  // Otherwise, continue with tool execution using unsent results
@@ -994,7 +1193,16 @@ export class ModelResult {
994
1193
  }
995
1194
  // Convert to API format
996
1195
  const toolOutputs = unsentResultsToAPIFormat(unsentResults);
997
- // Build new input with tool results
1196
+ // Build turn context for hook resolution
1197
+ // numberOfTurns represents the current turn number (1-indexed after initial)
1198
+ const turnContext = {
1199
+ numberOfTurns: this.allToolExecutionRounds.length + 1,
1200
+ };
1201
+ // Append SDK-generated tool outputs directly — `onResponseReceived` is
1202
+ // reserved for caller-supplied outputs (the resume-with-function-call-
1203
+ // output path, hooked during init). SDK-produced outputs from auto-
1204
+ // executed tools already went through the tool's own execute/generator
1205
+ // pipeline and must not be mutated by the resume hook.
998
1206
  const currentMessages = this.currentState.messages;
999
1207
  const newInput = appendToMessages(currentMessages, toolOutputs);
1000
1208
  // Clear unsent results from state
@@ -1006,12 +1214,11 @@ export class ModelResult {
1006
1214
  ]);
1007
1215
  await this.saveStateSafely();
1008
1216
  // Build request with the updated input
1009
- // numberOfTurns represents the current turn number (1-indexed after initial)
1010
- const turnContext = {
1011
- numberOfTurns: this.allToolExecutionRounds.length + 1,
1012
- };
1013
1217
  const baseRequest = await this.resolveRequestForContext(turnContext);
1014
- // Create request with the accumulated messages
1218
+ // No hooking here: SDK-generated outputs are appended as-is and any
1219
+ // caller-supplied items in `newInput` (carried over from init) were
1220
+ // already hooked during `initStream` — re-hooking would double-fire
1221
+ // non-idempotent hooks.
1015
1222
  const request = {
1016
1223
  ...baseRequest,
1017
1224
  input: newInput,
@@ -1047,8 +1254,12 @@ export class ModelResult {
1047
1254
  // biome-ignore lint: IIFE used for lazy initialization pattern
1048
1255
  this.toolExecutionPromise = (async () => {
1049
1256
  await this.initStream();
1050
- // If resuming from approval and still pending, don't continue
1051
- if (this.isResumingFromApproval && this.currentState?.status === 'awaiting_approval') {
1257
+ // If resuming from approval or HITL pause and still pending, don't continue.
1258
+ // `processApprovalDecisions` runs in initStream for resumes; if it left us
1259
+ // paused (any remaining pending calls), the outer loop should not execute.
1260
+ if (this.isResumingFromApproval &&
1261
+ (this.currentState?.status === 'awaiting_approval' ||
1262
+ this.currentState?.status === 'awaiting_hitl')) {
1052
1263
  return;
1053
1264
  }
1054
1265
  // Get initial response
@@ -1104,7 +1315,7 @@ export class ModelResult {
1104
1315
  // Resolve async functions for this turn
1105
1316
  await this.resolveAsyncFunctionsForTurn(turnContext);
1106
1317
  // Execute tools
1107
- const toolResults = await this.executeToolRound(currentToolCalls, turnContext);
1318
+ const { toolResults, pausedCalls } = await this.executeToolRound(currentToolCalls, turnContext);
1108
1319
  // Server-tool output items are already-executed results in the
1109
1320
  // response; collect them so toolResults presents a unified list.
1110
1321
  const serverToolItems = [];
@@ -1137,6 +1348,13 @@ export class ModelResult {
1137
1348
  });
1138
1349
  // Save tool results to state
1139
1350
  await this.saveToolResultsToState(toolResults);
1351
+ // If any HITL tools paused this round, stop here without making a
1352
+ // follow-up request — sending an incomplete set of outputs would be
1353
+ // incorrect. Persist the paused calls so the caller can resume later.
1354
+ if (pausedCalls.length > 0) {
1355
+ await this.persistHitlPause(currentResponse, pausedCalls);
1356
+ return;
1357
+ }
1140
1358
  // Apply nextTurnParams
1141
1359
  await this.applyNextTurnParams(currentToolCalls);
1142
1360
  currentResponse = await this.makeFollowupRequest(currentResponse, toolResults, turnNumber);
@@ -1659,13 +1877,16 @@ export class ModelResult {
1659
1877
  // Multi-Turn Conversation State Methods
1660
1878
  // =========================================================================
1661
1879
  /**
1662
- * Check if the conversation requires human approval to continue.
1663
- * Returns true if there are pending tool calls awaiting approval.
1880
+ * Check if the conversation requires human input to continue.
1881
+ * Returns true when the conversation is paused waiting for caller-supplied
1882
+ * decisions — either approval/rejection (`awaiting_approval`) or HITL tool
1883
+ * resume (`awaiting_hitl`). Also returns true whenever `pendingToolCalls`
1884
+ * is populated regardless of status.
1664
1885
  */
1665
1886
  async requiresApproval() {
1666
1887
  await this.initStream();
1667
- // If we have pending tool calls in state, approval is required
1668
- if (this.currentState?.status === 'awaiting_approval') {
1888
+ const status = this.currentState?.status;
1889
+ if (status === 'awaiting_approval' || status === 'awaiting_hitl') {
1669
1890
  return true;
1670
1891
  }
1671
1892
  // Also check if pendingToolCalls is populated