@openrouter/agent 0.3.2 → 0.5.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 (42) hide show
  1. package/esm/index.d.ts +7 -5
  2. package/esm/index.d.ts.map +1 -1
  3. package/esm/index.js +4 -2
  4. package/esm/index.js.map +1 -1
  5. package/esm/lib/conversation-state.d.ts +5 -0
  6. package/esm/lib/conversation-state.d.ts.map +1 -1
  7. package/esm/lib/conversation-state.js +17 -5
  8. package/esm/lib/conversation-state.js.map +1 -1
  9. package/esm/lib/model-result.d.ts +91 -7
  10. package/esm/lib/model-result.d.ts.map +1 -1
  11. package/esm/lib/model-result.js +483 -92
  12. package/esm/lib/model-result.js.map +1 -1
  13. package/esm/lib/next-turn-params.d.ts.map +1 -1
  14. package/esm/lib/next-turn-params.js +3 -1
  15. package/esm/lib/next-turn-params.js.map +1 -1
  16. package/esm/lib/stream-transformers.d.ts +84 -5
  17. package/esm/lib/stream-transformers.d.ts.map +1 -1
  18. package/esm/lib/stream-transformers.js +8 -15
  19. package/esm/lib/stream-transformers.js.map +1 -1
  20. package/esm/lib/stream-type-guards.d.ts +29 -0
  21. package/esm/lib/stream-type-guards.d.ts.map +1 -1
  22. package/esm/lib/stream-type-guards.js +37 -0
  23. package/esm/lib/stream-type-guards.js.map +1 -1
  24. package/esm/lib/tool-executor.d.ts +48 -9
  25. package/esm/lib/tool-executor.d.ts.map +1 -1
  26. package/esm/lib/tool-executor.js +257 -14
  27. package/esm/lib/tool-executor.js.map +1 -1
  28. package/esm/lib/tool-orchestrator.js +6 -6
  29. package/esm/lib/tool-orchestrator.js.map +1 -1
  30. package/esm/lib/tool-types.d.ts +173 -8
  31. package/esm/lib/tool-types.d.ts.map +1 -1
  32. package/esm/lib/tool-types.js +53 -2
  33. package/esm/lib/tool-types.js.map +1 -1
  34. package/esm/lib/tool.d.ts +58 -1
  35. package/esm/lib/tool.d.ts.map +1 -1
  36. package/esm/lib/tool.js +65 -0
  37. package/esm/lib/tool.js.map +1 -1
  38. package/esm/openrouter.d.ts +10 -4
  39. package/esm/openrouter.d.ts.map +1 -1
  40. package/esm/openrouter.js +41 -2
  41. package/esm/openrouter.js.map +1 -1
  42. package/package.json +9 -15
@@ -5,32 +5,48 @@ import { applyNextTurnParamsToRequest, executeNextTurnParamsFunctions, } from '.
5
5
  import { ReusableReadableStream } from './reusable-stream.js';
6
6
  import { isStopConditionMet, stepCountIs } from './stop-conditions.js';
7
7
  import { buildItemsStream, buildResponsesMessageStream, buildToolCallStream, consumeStreamForCompletion, extractReasoningDeltas, extractResponsesMessageFromResponse, extractTextDeltas, extractTextFromResponse, extractToolCallsFromResponse, extractToolDeltas, itemsStreamHandlers, streamTerminationEvents, } from './stream-transformers.js';
8
- import { hasTypeProperty, isFunctionCallItem, isOutputTextDeltaEvent, isReasoningDeltaEvent, isResponseCompletedEvent, isResponseFailedEvent, isResponseIncompleteEvent, } from './stream-type-guards.js';
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, isToolCallOutputEvent } from './tool-types.js';
11
+ import { applyOnResponseReceivedHooks, executeTool } from './tool-executor.js';
12
+ import { isAutoResolvableTool, isClientTool, isServerTool, isToolCallOutputEvent, } from './tool-types.js';
13
13
  /**
14
14
  * Default maximum number of tool execution steps if no stopWhen is specified.
15
15
  * This prevents infinite loops in tool execution.
16
16
  */
17
17
  const DEFAULT_MAX_STEPS = 5;
18
18
  /**
19
- * Type guard for stream event with toReadableStream method
20
- * Checks constructor name, prototype, and method availability
19
+ * Typeguard for plain-object records (non-null, non-array).
20
+ */
21
+ function isRecord(value) {
22
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
23
+ }
24
+ /**
25
+ * Human-readable label for a value that failed the `isRecord` check. Used
26
+ * exclusively to make `toModelOutput` misuse errors specific.
27
+ */
28
+ function describeNonRecord(value) {
29
+ if (value === null) {
30
+ return 'null';
31
+ }
32
+ if (Array.isArray(value)) {
33
+ return 'array';
34
+ }
35
+ return typeof value;
36
+ }
37
+ /**
38
+ * Type guard for stream event responses
39
+ * Checks constructor name and readable stream behavior
21
40
  */
22
41
  function isEventStream(value) {
23
42
  if (value === null || typeof value !== 'object') {
24
43
  return false;
25
44
  }
26
- // Check constructor name for EventStream
27
- const constructorName = Object.getPrototypeOf(value)?.constructor?.name;
28
- if (constructorName === 'EventStream') {
45
+ if (typeof ReadableStream !== 'undefined' && value instanceof ReadableStream) {
29
46
  return true;
30
47
  }
31
- // Fallback: check for toReadableStream method (may be on prototype)
32
48
  const maybeStream = value;
33
- return typeof maybeStream.toReadableStream === 'function';
49
+ return typeof maybeStream.getReader === 'function';
34
50
  }
35
51
  /**
36
52
  * A wrapper around a streaming response that provides multiple consumption patterns.
@@ -236,13 +252,10 @@ export class ModelResult {
236
252
  }
237
253
  /**
238
254
  * Type guard to check if a value is a non-streaming response
239
- * Only requires 'output' field and absence of 'toReadableStream' method
255
+ * Only requires 'output' field and absence of readable stream behavior
240
256
  */
241
257
  isNonStreamingResponse(value) {
242
- return (value !== null &&
243
- typeof value === 'object' &&
244
- 'output' in value &&
245
- !('toReadableStream' in value));
258
+ return (value !== null && typeof value === 'object' && 'output' in value && !isEventStream(value));
246
259
  }
247
260
  // =========================================================================
248
261
  // Extracted Helper Methods for executeToolsIfNeeded
@@ -350,17 +363,23 @@ export class ModelResult {
350
363
  : [
351
364
  stopWhen,
352
365
  ];
366
+ const isFunctionCallOutput = (tr) => tr.type === 'function_call_output';
367
+ const isServerToolResult = (tr) => tr.type !== 'function_call_output';
353
368
  return isStopConditionMet({
354
369
  stopConditions,
355
370
  steps: this.allToolExecutionRounds.map((round) => ({
356
371
  stepType: 'continue',
357
372
  text: extractTextFromResponse(round.response),
358
373
  toolCalls: round.toolCalls,
359
- toolResults: round.toolResults.map((tr) => ({
374
+ // `toolResults` is client-tool-centric; server-tool output items are
375
+ // surfaced on `serverToolResults` so stop conditions can react to
376
+ // either class of result.
377
+ toolResults: round.toolResults.filter(isFunctionCallOutput).map((tr) => ({
360
378
  toolCallId: tr.callId,
361
379
  toolName: round.toolCalls.find((tc) => tc.id === tr.callId)?.name ?? '',
362
380
  result: typeof tr.output === 'string' ? JSON.parse(tr.output) : tr.output,
363
381
  })),
382
+ serverToolResults: round.toolResults.filter(isServerToolResult),
364
383
  response: round.response,
365
384
  usage: round.response.usage,
366
385
  finishReason: undefined,
@@ -368,21 +387,32 @@ export class ModelResult {
368
387
  });
369
388
  }
370
389
  /**
371
- * Check if any tool calls have execute functions.
390
+ * Check if any tool calls can be auto-resolved in the current turn.
372
391
  * Used to determine if automatic tool execution should be attempted.
373
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
+ *
374
398
  * @param toolCalls - The tool calls to check
375
- * @returns True if at least one tool call has an executable function
399
+ * @returns True if at least one tool call is auto-resolvable
376
400
  */
377
401
  hasExecutableToolCalls(toolCalls) {
378
402
  return toolCalls.some((toolCall) => {
379
- const tool = this.options.tools?.find((t) => t.function.name === toolCall.name);
380
- return tool && hasExecuteFunction(tool);
403
+ const tool = this.options.tools?.find((t) => isClientTool(t) && t.function.name === toolCall.name);
404
+ return tool && isAutoResolvableTool(tool);
381
405
  });
382
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
+ */
383
413
  isManualToolCall(item) {
384
- const tool = this.options.tools?.find((t) => t.function.name === item.name);
385
- return !!tool && !hasExecuteFunction(tool);
414
+ const tool = this.options.tools?.find((t) => isClientTool(t) && t.function.name === item.name);
415
+ return !!tool && !isAutoResolvableTool(tool);
386
416
  }
387
417
  /**
388
418
  * Execute tools that can auto-execute (don't require approval) in parallel.
@@ -393,11 +423,15 @@ export class ModelResult {
393
423
  */
394
424
  async executeAutoApproveTools(toolCalls, turnContext) {
395
425
  const toolCallPromises = toolCalls.map(async (tc) => {
396
- const tool = this.options.tools?.find((t) => t.function.name === tc.name);
397
- if (!tool || !hasExecuteFunction(tool)) {
426
+ const tool = this.options.tools?.find((t) => isClientTool(t) && t.function.name === tc.name);
427
+ if (!tool || !isAutoResolvableTool(tool)) {
398
428
  return null;
399
429
  }
400
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
+ }
401
435
  if (result.error) {
402
436
  return createRejectedResult(tc.id, String(tc.name), result.error.message);
403
437
  }
@@ -464,18 +498,81 @@ export class ModelResult {
464
498
  this.finalResponse = currentResponse;
465
499
  return true; // Pause for approval
466
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
+ }
467
560
  /**
468
561
  * Execute all tools in a single round in parallel.
469
562
  * Emits tool.result events after tool execution completes.
470
563
  *
471
564
  * @param toolCalls - The tool calls to execute
472
565
  * @param turnContext - The current turn context
473
- * @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.
474
571
  */
475
572
  async executeToolRound(toolCalls, turnContext) {
476
573
  const toolCallPromises = toolCalls.map(async (toolCall) => {
477
- const tool = this.options.tools?.find((t) => t.function.name === toolCall.name);
478
- if (!tool || !hasExecuteFunction(tool)) {
574
+ const tool = this.options.tools?.find((t) => isClientTool(t) && t.function.name === toolCall.name);
575
+ if (!tool || !isAutoResolvableTool(tool)) {
479
576
  return null;
480
577
  }
481
578
  // Check if arguments failed to parse (remained as string instead of object)
@@ -511,6 +608,13 @@ export class ModelResult {
511
608
  }
512
609
  : undefined;
513
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
+ }
514
618
  return {
515
619
  type: 'execution',
516
620
  toolCall,
@@ -521,6 +625,7 @@ export class ModelResult {
521
625
  });
522
626
  const settledResults = await Promise.allSettled(toolCallPromises);
523
627
  const toolResults = [];
628
+ const pausedCalls = [];
524
629
  for (let i = 0; i < settledResults.length; i++) {
525
630
  const settled = settledResults[i];
526
631
  const originalToolCall = toolCalls[i];
@@ -561,32 +666,21 @@ export class ModelResult {
561
666
  });
562
667
  continue;
563
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
+ }
564
677
  const toolResult = (value.result.error
565
678
  ? {
566
679
  error: value.result.error.message,
567
680
  }
568
681
  : value.result.result);
569
682
  this.broadcastToolResult(value.toolCall.id, toolResult, value.preliminaryResultsForCall.length > 0 ? value.preliminaryResultsForCall : undefined);
570
- let outputForModel;
571
- if (value.result.error) {
572
- outputForModel = JSON.stringify({
573
- error: value.result.error.message,
574
- });
575
- }
576
- else if (value.tool.function.toModelOutput) {
577
- // toModelOutput exists - call it (may throw, which surfaces the error)
578
- const modelOutputResult = await value.tool.function.toModelOutput({
579
- output: value.result.result,
580
- input: value.toolCall.arguments,
581
- });
582
- outputForModel =
583
- modelOutputResult.type === 'content'
584
- ? modelOutputResult.value
585
- : JSON.stringify(value.result.result);
586
- }
587
- else {
588
- outputForModel = JSON.stringify(value.result.result);
589
- }
683
+ const outputForModel = await this.computeToolOutputForModel(value);
590
684
  const executedOutput = {
591
685
  type: 'function_call_output',
592
686
  id: `output_${value.toolCall.id}`,
@@ -600,7 +694,10 @@ export class ModelResult {
600
694
  timestamp: Date.now(),
601
695
  });
602
696
  }
603
- return toolResults;
697
+ return {
698
+ toolResults,
699
+ pausedCalls,
700
+ };
604
701
  }
605
702
  /**
606
703
  * Resolve async functions for the current turn.
@@ -725,6 +822,72 @@ export class ModelResult {
725
822
  const { stopWhen: _, state: _s, requireApproval: _r, approveToolCalls: _a, rejectToolCalls: _rj, context: _c, ...rest } = this.options.request;
726
823
  return rest;
727
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
+ }
728
891
  /**
729
892
  * Safely persist state with error handling.
730
893
  * Wraps state save operations to ensure failures are properly reported.
@@ -779,8 +942,14 @@ export class ModelResult {
779
942
  const loadedState = await this.stateAccessor.load();
780
943
  if (loadedState) {
781
944
  this.currentState = loadedState;
782
- // Check if we're resuming from awaiting_approval with decisions
783
- 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 &&
784
953
  (this.approvedToolCalls.length > 0 || this.rejectedToolCalls.length > 0)) {
785
954
  // Initialize context store before resuming so tools have access
786
955
  if (this.options.context !== undefined) {
@@ -826,29 +995,55 @@ export class ModelResult {
826
995
  }
827
996
  // Resolve any async functions first
828
997
  let baseRequest = await this.resolveRequestForContext(initialContext);
829
- // If we have state with existing messages, use those as input
830
- 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 &&
831
1003
  Array.isArray(this.currentState.messages) &&
832
- this.currentState.messages.length > 0) {
833
- // 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.
834
1017
  const newInput = baseRequest.input;
835
- if (newInput) {
836
- const inputArray = Array.isArray(newInput)
1018
+ let freshItems;
1019
+ if (newInput !== undefined) {
1020
+ freshItems = Array.isArray(newInput)
837
1021
  ? newInput
838
1022
  : [
839
1023
  newInput,
840
1024
  ];
841
- baseRequest = {
842
- ...baseRequest,
843
- input: appendToMessages(this.currentState.messages, inputArray),
844
- };
845
- }
846
- else {
847
- baseRequest = {
848
- ...baseRequest,
849
- input: this.currentState.messages,
850
- };
851
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
+ };
852
1047
  }
853
1048
  // Store resolved request with stream mode
854
1049
  this.resolvedRequest = {
@@ -895,19 +1090,28 @@ export class ModelResult {
895
1090
  numberOfTurns: this.allToolExecutionRounds.length + 1,
896
1091
  // context is handled via contextStore, not on TurnContext
897
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();
898
1096
  // Process approvals - execute the approved tools
899
1097
  for (const callId of this.approvedToolCalls) {
900
1098
  const toolCall = pendingCalls.find((tc) => tc.id === callId);
901
1099
  if (!toolCall) {
902
1100
  continue;
903
1101
  }
904
- const tool = this.options.tools?.find((t) => t.function.name === toolCall.name);
905
- if (!tool || !hasExecuteFunction(tool)) {
1102
+ const tool = this.options.tools?.find((t) => isClientTool(t) && t.function.name === toolCall.name);
1103
+ if (!tool || !isAutoResolvableTool(tool)) {
906
1104
  // Can't execute, create error result
907
1105
  unsentResults.push(createRejectedResult(callId, String(toolCall.name), 'Tool not found or not executable'));
908
1106
  continue;
909
1107
  }
910
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
+ }
911
1115
  if (result.error) {
912
1116
  unsentResults.push(createRejectedResult(callId, String(toolCall.name), result.error.message));
913
1117
  }
@@ -923,15 +1127,32 @@ export class ModelResult {
923
1127
  }
924
1128
  unsentResults.push(createRejectedResult(callId, String(toolCall.name), 'Rejected by user'));
925
1129
  }
926
- // 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.
927
1133
  const processedIds = new Set([
928
1134
  ...this.approvedToolCalls,
929
1135
  ...this.rejectedToolCalls,
930
- ]);
1136
+ ].filter((id) => !hitlPausedIds.has(id)));
931
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
+ }
932
1153
  // Update state - conditionally include optional properties only if they have values
933
1154
  const stateUpdates = {
934
- status: remainingPending.length > 0 ? 'awaiting_approval' : 'in_progress',
1155
+ status: nextStatus,
935
1156
  };
936
1157
  if (remainingPending.length > 0) {
937
1158
  stateUpdates.pendingToolCalls = remainingPending;
@@ -952,8 +1173,8 @@ export class ModelResult {
952
1173
  this.clearOptionalStateProperties(propsToClear);
953
1174
  await this.saveStateSafely();
954
1175
  }
955
- // If we still have pending approvals, stop here
956
- if (remainingPending.length > 0) {
1176
+ // If we are paused (for approval or for HITL), stop here
1177
+ if (nextStatus !== 'in_progress') {
957
1178
  return;
958
1179
  }
959
1180
  // Otherwise, continue with tool execution using unsent results
@@ -972,7 +1193,16 @@ export class ModelResult {
972
1193
  }
973
1194
  // Convert to API format
974
1195
  const toolOutputs = unsentResultsToAPIFormat(unsentResults);
975
- // 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.
976
1206
  const currentMessages = this.currentState.messages;
977
1207
  const newInput = appendToMessages(currentMessages, toolOutputs);
978
1208
  // Clear unsent results from state
@@ -984,12 +1214,11 @@ export class ModelResult {
984
1214
  ]);
985
1215
  await this.saveStateSafely();
986
1216
  // Build request with the updated input
987
- // numberOfTurns represents the current turn number (1-indexed after initial)
988
- const turnContext = {
989
- numberOfTurns: this.allToolExecutionRounds.length + 1,
990
- };
991
1217
  const baseRequest = await this.resolveRequestForContext(turnContext);
992
- // 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.
993
1222
  const request = {
994
1223
  ...baseRequest,
995
1224
  input: newInput,
@@ -1025,8 +1254,12 @@ export class ModelResult {
1025
1254
  // biome-ignore lint: IIFE used for lazy initialization pattern
1026
1255
  this.toolExecutionPromise = (async () => {
1027
1256
  await this.initStream();
1028
- // If resuming from approval and still pending, don't continue
1029
- 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')) {
1030
1263
  return;
1031
1264
  }
1032
1265
  // Get initial response
@@ -1082,16 +1315,46 @@ export class ModelResult {
1082
1315
  // Resolve async functions for this turn
1083
1316
  await this.resolveAsyncFunctionsForTurn(turnContext);
1084
1317
  // Execute tools
1085
- const toolResults = await this.executeToolRound(currentToolCalls, turnContext);
1318
+ const { toolResults, pausedCalls } = await this.executeToolRound(currentToolCalls, turnContext);
1319
+ // Server-tool output items are already-executed results in the
1320
+ // response; collect them so toolResults presents a unified list.
1321
+ const serverToolItems = [];
1322
+ for (const item of currentResponse.output) {
1323
+ if (!hasTypeProperty(item)) {
1324
+ continue;
1325
+ }
1326
+ if (item.type === 'message' ||
1327
+ item.type === 'reasoning' ||
1328
+ item.type === 'function_call') {
1329
+ continue;
1330
+ }
1331
+ // Everything else is a server-tool output item (web_search_call,
1332
+ // image_generation_call, file_search_call, or generic
1333
+ // OutputServerToolItem covering openrouter:datetime and any new
1334
+ // SDK server tool types).
1335
+ if (isServerToolResultItem(item)) {
1336
+ serverToolItems.push(item);
1337
+ }
1338
+ }
1086
1339
  // Track execution round
1087
1340
  this.allToolExecutionRounds.push({
1088
1341
  round: currentRound,
1089
1342
  toolCalls: currentToolCalls,
1090
1343
  response: currentResponse,
1091
- toolResults,
1344
+ toolResults: [
1345
+ ...toolResults,
1346
+ ...serverToolItems,
1347
+ ],
1092
1348
  });
1093
1349
  // Save tool results to state
1094
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
+ }
1095
1358
  // Apply nextTurnParams
1096
1359
  await this.applyNextTurnParams(currentToolCalls);
1097
1360
  currentResponse = await this.makeFollowupRequest(currentResponse, toolResults, turnNumber);
@@ -1208,6 +1471,30 @@ export class ModelResult {
1208
1471
  * - function_call_output: Results from executed tools
1209
1472
  */
1210
1473
  getItemsStream() {
1474
+ // Build the allowed-item-type scope from the tools actually passed to
1475
+ // callModel, mirroring the compile-time rules that produce
1476
+ // StreamableOutputItem<TTools>. A runtime predicate then drops items
1477
+ // whose type isn't reachable in the narrowed union. The predicate's
1478
+ // claim (`item is StreamableOutputItem<TTools>`) is sound because:
1479
+ // - `allowed` is constructed from the same tools that produced TTools
1480
+ // - `OutputServerToolItem.type` is `string` (open), so any non-client
1481
+ // item type is structurally assignable to it, covering generic /
1482
+ // unmapped server-tool outputs.
1483
+ const scope = this.computeItemStreamScope();
1484
+ const isInScope = (item) => {
1485
+ if (scope.acceptAll) {
1486
+ return true;
1487
+ }
1488
+ if (scope.allowed.has(item.type)) {
1489
+ return true;
1490
+ }
1491
+ if (scope.acceptGenericServerItem &&
1492
+ item.type !== 'function_call' &&
1493
+ item.type !== 'function_call_output') {
1494
+ return true;
1495
+ }
1496
+ return false;
1497
+ };
1211
1498
  return async function* () {
1212
1499
  await this.initStream();
1213
1500
  if (!this.reusableStream && !this.finalResponse) {
@@ -1216,7 +1503,11 @@ export class ModelResult {
1216
1503
  // No tools — stream single turn directly (no broadcaster needed)
1217
1504
  if (!this.options.tools?.length) {
1218
1505
  if (this.reusableStream) {
1219
- yield* buildItemsStream(this.reusableStream);
1506
+ for await (const item of buildItemsStream(this.reusableStream)) {
1507
+ if (isInScope(item)) {
1508
+ yield item;
1509
+ }
1510
+ }
1220
1511
  }
1221
1512
  return;
1222
1513
  }
@@ -1228,7 +1519,9 @@ export class ModelResult {
1228
1519
  for await (const event of consumer) {
1229
1520
  // Tool call outputs → yield directly as function_call_output items
1230
1521
  if (isToolCallOutputEvent(event)) {
1231
- yield event.output;
1522
+ if (isInScope(event.output)) {
1523
+ yield event.output;
1524
+ }
1232
1525
  continue;
1233
1526
  }
1234
1527
  // Stream termination → reset items map for next turn
@@ -1243,7 +1536,7 @@ export class ModelResult {
1243
1536
  const handler = itemsStreamHandlers[event.type];
1244
1537
  if (handler) {
1245
1538
  const result = handler(event, itemsInProgress);
1246
- if (result) {
1539
+ if (result && isInScope(result)) {
1247
1540
  yield result;
1248
1541
  }
1249
1542
  }
@@ -1252,6 +1545,98 @@ export class ModelResult {
1252
1545
  await executionPromise;
1253
1546
  }.call(this);
1254
1547
  }
1548
+ /**
1549
+ * Compute the runtime allow-list of item types that `getItemsStream()`
1550
+ * may yield, derived from the tools actually passed to callModel. The
1551
+ * three return modes correspond to the compile-time narrowing:
1552
+ *
1553
+ * - `acceptAll: true` — no tools or fully-unconstrained TTools; the
1554
+ * yielded union is the widest `StreamableOutputItem`.
1555
+ * - Specific `allowed` set — client tools contribute
1556
+ * `function_call` / `function_call_output`; mapped server tools
1557
+ * contribute their SDK output item type literal
1558
+ * (`web_search_call`, `file_search_call`, `image_generation_call`).
1559
+ * - `acceptGenericServerItem: true` — at least one server tool has a
1560
+ * type the agent SDK does not have a dedicated output mapping for
1561
+ * (e.g. `openrouter:datetime`, `mcp`, new SDK additions). Any
1562
+ * non-client item type is accepted because these items pass through
1563
+ * as `OutputServerToolItem`, whose `type` field is an open `string`.
1564
+ */
1565
+ computeItemStreamScope() {
1566
+ const tools = this.options.tools ?? [];
1567
+ if (tools.length === 0) {
1568
+ // No tools passed: runtime only emits message/reasoning, but the
1569
+ // widest StreamableOutputItem<readonly Tool[]> includes every item
1570
+ // type. Accept all so the default unconstrained case matches its
1571
+ // compile-time union.
1572
+ return {
1573
+ acceptAll: true,
1574
+ allowed: new Set(),
1575
+ acceptGenericServerItem: false,
1576
+ };
1577
+ }
1578
+ const allowed = new Set([
1579
+ 'message',
1580
+ 'reasoning',
1581
+ ]);
1582
+ let acceptGenericServerItem = false;
1583
+ for (const tool of tools) {
1584
+ if (isClientTool(tool)) {
1585
+ allowed.add('function_call');
1586
+ allowed.add('function_call_output');
1587
+ continue;
1588
+ }
1589
+ if (!isServerTool(tool)) {
1590
+ continue;
1591
+ }
1592
+ const requestType = tool.config.type;
1593
+ switch (requestType) {
1594
+ case 'web_search':
1595
+ case 'web_search_2025_08_26':
1596
+ case 'web_search_preview':
1597
+ case 'web_search_preview_2025_03_11':
1598
+ allowed.add('web_search_call');
1599
+ break;
1600
+ case 'openrouter:web_search':
1601
+ // Defensive: OpenRouter's web_search variant may emit either the
1602
+ // standard OutputWebSearchCallItem (type='web_search_call') OR be
1603
+ // wrapped in OutputServerToolItem with type='openrouter:web_search'.
1604
+ // Accept both literals so the runtime filter doesn't silently drop
1605
+ // valid items. Do NOT set acceptGenericServerItem — we know the
1606
+ // tool type and want the filter narrow.
1607
+ allowed.add('web_search_call');
1608
+ allowed.add('openrouter:web_search');
1609
+ break;
1610
+ case 'file_search':
1611
+ allowed.add('file_search_call');
1612
+ break;
1613
+ case 'image_generation':
1614
+ allowed.add('image_generation_call');
1615
+ break;
1616
+ case 'openrouter:datetime':
1617
+ // Known server tool whose SDK output item uses the same literal
1618
+ // as the request type. Mirrors `KnownServerToolOutputs` in
1619
+ // stream-transformers.ts so the runtime filter stays as narrow
1620
+ // as the compile-time union (no acceptGenericServerItem widening).
1621
+ allowed.add('openrouter:datetime');
1622
+ break;
1623
+ default:
1624
+ // Unknown / generic server tool — at runtime its output items
1625
+ // pass through as the request-type literal or as the SDK's
1626
+ // OutputServerToolItem wrapper. Accept the literal plus the
1627
+ // generic fallback. See `StreamableOutputItem` narrowing in
1628
+ // stream-transformers.ts for the matching type-level rules.
1629
+ allowed.add(requestType);
1630
+ acceptGenericServerItem = true;
1631
+ break;
1632
+ }
1633
+ }
1634
+ return {
1635
+ acceptAll: false,
1636
+ allowed,
1637
+ acceptGenericServerItem,
1638
+ };
1639
+ }
1255
1640
  /**
1256
1641
  * @deprecated Use `getItemsStream()` instead. This method only streams messages,
1257
1642
  * while `getItemsStream()` streams all output item types (messages, function_calls,
@@ -1286,9 +1671,12 @@ export class ModelResult {
1286
1671
  yield item;
1287
1672
  }
1288
1673
  }
1289
- // Then yield the function_call_output results
1674
+ // Then yield the function_call_output results (client tools only;
1675
+ // server-tool output items are surfaced through getItemsStream).
1290
1676
  for (const toolResult of round.toolResults) {
1291
- yield toolResult;
1677
+ if (isFunctionCallOutputItem(toolResult)) {
1678
+ yield toolResult;
1679
+ }
1292
1680
  }
1293
1681
  }
1294
1682
  // Yield manual tool function_call items from finalResponse, skipping duplicates
@@ -1489,13 +1877,16 @@ export class ModelResult {
1489
1877
  // Multi-Turn Conversation State Methods
1490
1878
  // =========================================================================
1491
1879
  /**
1492
- * Check if the conversation requires human approval to continue.
1493
- * 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.
1494
1885
  */
1495
1886
  async requiresApproval() {
1496
1887
  await this.initStream();
1497
- // If we have pending tool calls in state, approval is required
1498
- if (this.currentState?.status === 'awaiting_approval') {
1888
+ const status = this.currentState?.status;
1889
+ if (status === 'awaiting_approval' || status === 'awaiting_hitl') {
1499
1890
  return true;
1500
1891
  }
1501
1892
  // Also check if pendingToolCalls is populated