@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.
- package/esm/index.d.ts +2 -2
- package/esm/index.d.ts.map +1 -1
- package/esm/index.js +1 -1
- package/esm/index.js.map +1 -1
- package/esm/inner-loop/call-model.d.ts +2 -1
- package/esm/inner-loop/call-model.d.ts.map +1 -1
- package/esm/inner-loop/call-model.js +2 -1
- package/esm/inner-loop/call-model.js.map +1 -1
- package/esm/lib/conversation-state.d.ts +5 -0
- package/esm/lib/conversation-state.d.ts.map +1 -1
- package/esm/lib/conversation-state.js +1 -1
- package/esm/lib/conversation-state.js.map +1 -1
- package/esm/lib/model-result.d.ts +75 -7
- package/esm/lib/model-result.d.ts.map +1 -1
- package/esm/lib/model-result.js +318 -97
- package/esm/lib/model-result.js.map +1 -1
- package/esm/lib/tool-executor.d.ts +38 -3
- package/esm/lib/tool-executor.d.ts.map +1 -1
- package/esm/lib/tool-executor.js +237 -3
- package/esm/lib/tool-executor.js.map +1 -1
- package/esm/lib/tool-orchestrator.js +6 -6
- package/esm/lib/tool-orchestrator.js.map +1 -1
- package/esm/lib/tool-types.d.ts +55 -6
- package/esm/lib/tool-types.d.ts.map +1 -1
- package/esm/lib/tool-types.js +19 -2
- package/esm/lib/tool-types.js.map +1 -1
- package/esm/lib/tool.d.ts +35 -1
- package/esm/lib/tool.d.ts.map +1 -1
- package/esm/lib/tool.js +37 -0
- package/esm/lib/tool.js.map +1 -1
- package/package.json +8 -14
package/esm/lib/model-result.js
CHANGED
|
@@ -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
|
|
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 {
|
|
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
|
-
*
|
|
26
|
-
*
|
|
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
|
-
|
|
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.
|
|
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
|
|
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
|
-
*
|
|
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
|
|
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
|
|
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
|
|
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 &&
|
|
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 && !
|
|
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 || !
|
|
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
|
|
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 || !
|
|
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
|
-
|
|
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
|
|
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
|
|
805
|
-
|
|
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
|
-
//
|
|
852
|
-
|
|
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
|
-
|
|
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
|
-
|
|
858
|
-
|
|
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 || !
|
|
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:
|
|
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
|
|
978
|
-
if (
|
|
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
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
|
1663
|
-
* Returns true
|
|
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
|
-
|
|
1668
|
-
if (
|
|
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
|