@rudderjs/ai 1.6.0 → 1.6.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +83 -4
- package/boost/skills/ai-agents/SKILL.md +7 -0
- package/boost/skills/ai-tools/SKILL.md +7 -0
- package/dist/agent.d.ts +44 -13
- package/dist/agent.d.ts.map +1 -1
- package/dist/agent.js +19 -819
- package/dist/agent.js.map +1 -1
- package/dist/handoffs-driver.d.ts +58 -0
- package/dist/handoffs-driver.d.ts.map +1 -0
- package/dist/handoffs-driver.js +103 -0
- package/dist/handoffs-driver.js.map +1 -0
- package/dist/resume-approval.d.ts +30 -0
- package/dist/resume-approval.d.ts.map +1 -0
- package/dist/resume-approval.js +98 -0
- package/dist/resume-approval.js.map +1 -0
- package/dist/server/provider.d.ts.map +1 -1
- package/dist/server/provider.js +96 -99
- package/dist/server/provider.js.map +1 -1
- package/dist/tool-execution.d.ts +16 -0
- package/dist/tool-execution.d.ts.map +1 -0
- package/dist/tool-execution.js +498 -0
- package/dist/tool-execution.js.map +1 -0
- package/dist/tool-helpers.d.ts +77 -0
- package/dist/tool-helpers.d.ts.map +1 -0
- package/dist/tool-helpers.js +117 -0
- package/dist/tool-helpers.js.map +1 -0
- package/package.json +3 -3
package/dist/agent.js
CHANGED
|
@@ -1,14 +1,16 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
2
|
import { AiRegistry } from './registry.js';
|
|
3
|
-
import {
|
|
4
|
-
import { isHandoffTool } from './handoff.js';
|
|
3
|
+
import { pauseForApproval, pauseForClientTools, toolDefinition, toolToSchema } from './tool.js';
|
|
5
4
|
import { attachmentsToContentParts, getMessageText } from './attachment.js';
|
|
6
5
|
import { QueuedPromptBuilder } from './queue-job.js';
|
|
7
6
|
import { resolveAutoPersistSpec, runWithPersistence, runWithPersistenceStreaming, } from './conversation-persistence.js';
|
|
8
7
|
import { resolveRemembersSpec } from './memory.js';
|
|
9
8
|
import { withMemoryInject } from './memory-inject.js';
|
|
10
9
|
import { withMemoryExtract } from './memory-extract.js';
|
|
11
|
-
import { runOnConfig, runOnChunk,
|
|
10
|
+
import { runOnConfig, runOnChunk, runSequential, runOnUsage, runOnAbort, runOnError, } from './middleware.js';
|
|
11
|
+
import { executeToolPhase } from './tool-execution.js';
|
|
12
|
+
import { resumePendingToolCalls } from './resume-approval.js';
|
|
13
|
+
import { buildHandoffChildOptions, driveHandoffs, MAX_HANDOFFS, mergeFinalHandoff, stripInternal, } from './handoffs-driver.js';
|
|
12
14
|
// ─── AI Observer (lazy accessor) ─────────────────────────
|
|
13
15
|
function _getAiObservers() {
|
|
14
16
|
return globalThis['__rudderjs_ai_observers__'] ?? null;
|
|
@@ -669,10 +671,11 @@ function runStreamWithMaybeAutoPersist(a, input, options) {
|
|
|
669
671
|
return { stream: outer(), response: responsePromise };
|
|
670
672
|
}
|
|
671
673
|
// ─── Helpers ─────────────────────────────────────────────
|
|
674
|
+
function hasToolsMethod(a) {
|
|
675
|
+
return 'tools' in a && typeof a.tools === 'function';
|
|
676
|
+
}
|
|
672
677
|
function getTools(a) {
|
|
673
|
-
return
|
|
674
|
-
? a.tools()
|
|
675
|
-
: [];
|
|
678
|
+
return hasToolsMethod(a) ? a.tools() : [];
|
|
676
679
|
}
|
|
677
680
|
/**
|
|
678
681
|
* Internal symbol used to plumb auto-installed middlewares (today:
|
|
@@ -682,10 +685,11 @@ function getTools(a) {
|
|
|
682
685
|
* just appends them to the user's `agent.middleware()` array.
|
|
683
686
|
*/
|
|
684
687
|
const EXTRA_MIDDLEWARES = Symbol.for('rudderjs.ai.extraMiddlewares');
|
|
688
|
+
function hasMiddlewareMethod(a) {
|
|
689
|
+
return 'middleware' in a && typeof a.middleware === 'function';
|
|
690
|
+
}
|
|
685
691
|
function getMiddleware(a, options) {
|
|
686
|
-
const own =
|
|
687
|
-
? a.middleware()
|
|
688
|
-
: [];
|
|
692
|
+
const own = hasMiddlewareMethod(a) ? a.middleware() : [];
|
|
689
693
|
const extras = options?.[EXTRA_MIDDLEWARES] ?? [];
|
|
690
694
|
return extras.length > 0 ? [...own, ...extras] : own;
|
|
691
695
|
}
|
|
@@ -965,499 +969,6 @@ function buildAgentResponse(loopCtx) {
|
|
|
965
969
|
}
|
|
966
970
|
return result;
|
|
967
971
|
}
|
|
968
|
-
/**
|
|
969
|
-
* Execute the tool phase for a single agent step. Yields the same
|
|
970
|
-
* `StreamChunk` sequence (`tool-call` → `tool-update*` → `tool-result`) that
|
|
971
|
-
* the streaming caller surfaces to consumers. Non-streaming callers iterate
|
|
972
|
-
* via `.next()` and discard yields — the side effects (message pushes,
|
|
973
|
-
* pending-state mutations on `loopCtx`) are identical regardless of whether
|
|
974
|
-
* the chunks reach a consumer.
|
|
975
|
-
*
|
|
976
|
-
* Returns the step's `ToolResult[]`. The caller passes the assistant message
|
|
977
|
-
* to push before iteration so the AgentStep shape (response.message) and the
|
|
978
|
-
* final `messages` array stay in sync with the loop variant.
|
|
979
|
-
*/
|
|
980
|
-
async function* executeToolPhase(loopCtx, toolCalls, assistantMessage) {
|
|
981
|
-
const { messages, middlewares, options, ctx } = loopCtx;
|
|
982
|
-
const toolResults = [];
|
|
983
|
-
messages.push(assistantMessage);
|
|
984
|
-
// Resolve parallelism setting. Per-call option wins; falls back to the
|
|
985
|
-
// agent-level override which defaults to `true`. Single-tool batches
|
|
986
|
-
// route through the serial path either way (no parallelism to gain, and
|
|
987
|
-
// serial preserves live `tool-update` streaming for that one tool).
|
|
988
|
-
//
|
|
989
|
-
// Handoffs always force serial dispatch — the parent loop has to halt
|
|
990
|
-
// immediately on the first handoff and synthesize "skipped" results for
|
|
991
|
-
// any sibling calls. Handling that across the parallel classify/replay
|
|
992
|
-
// phases is doable but adds complexity for negligible benefit (the model
|
|
993
|
-
// rarely emits parallel siblings alongside a handoff, and even then,
|
|
994
|
-
// running them while the agent is being torn down is wasted work).
|
|
995
|
-
const hasHandoff = toolCalls.some(tc => isHandoffTool(loopCtx.toolMap.get(tc.name)));
|
|
996
|
-
const parallel = (options?.parallelTools ?? loopCtx.agent.parallelTools()) && toolCalls.length > 1 && !hasHandoff;
|
|
997
|
-
if (parallel) {
|
|
998
|
-
yield* runToolPhaseParallel(loopCtx, toolCalls, toolResults);
|
|
999
|
-
}
|
|
1000
|
-
else {
|
|
1001
|
-
yield* runToolPhaseSerial(loopCtx, toolCalls, toolResults);
|
|
1002
|
-
}
|
|
1003
|
-
// onToolPhaseComplete
|
|
1004
|
-
if (middlewares.length > 0)
|
|
1005
|
-
await runSequential(middlewares, 'onToolPhaseComplete', ctx);
|
|
1006
|
-
return toolResults;
|
|
1007
|
-
}
|
|
1008
|
-
/**
|
|
1009
|
-
* Serial tool execution — the original behavior. Runs each tool call's
|
|
1010
|
-
* prelude (approval, before-middleware, validation) and `execute()`
|
|
1011
|
-
* one-after-another, streaming `tool-update` chunks live as the tool
|
|
1012
|
-
* emits them.
|
|
1013
|
-
*/
|
|
1014
|
-
async function* runToolPhaseSerial(loopCtx, toolCalls, toolResults) {
|
|
1015
|
-
const { messages, middlewares, toolMap, options, ctx } = loopCtx;
|
|
1016
|
-
for (const tc of toolCalls) {
|
|
1017
|
-
const tool = toolMap.get(tc.name);
|
|
1018
|
-
if (!tool) {
|
|
1019
|
-
const unknownResult = `Error: Unknown tool "${tc.name}"`;
|
|
1020
|
-
toolResults.push({ toolCallId: tc.id, result: unknownResult });
|
|
1021
|
-
messages.push({ role: 'tool', content: unknownResult, toolCallId: tc.id });
|
|
1022
|
-
yield { type: 'tool-result', toolCall: tc, result: unknownResult };
|
|
1023
|
-
continue;
|
|
1024
|
-
}
|
|
1025
|
-
// Handoff — detected before the no-execute (client tool) branch because
|
|
1026
|
-
// a handoff tool also has no `execute`, but it has wholly different
|
|
1027
|
-
// semantics: pivot control to a new agent instead of pausing for the
|
|
1028
|
-
// browser. The first handoff in a step wins; any subsequent tool calls
|
|
1029
|
-
// in the same step are skipped with a synthetic "skipped: handed off"
|
|
1030
|
-
// tool result so the message log stays well-formed for replay.
|
|
1031
|
-
if (loopCtx.stopForHandoff) {
|
|
1032
|
-
const skippedResult = 'Skipped: parent agent handed off to another agent.';
|
|
1033
|
-
toolResults.push({ toolCallId: tc.id, result: skippedResult });
|
|
1034
|
-
messages.push({ role: 'tool', content: skippedResult, toolCallId: tc.id });
|
|
1035
|
-
yield { type: 'tool-call', toolCall: tc };
|
|
1036
|
-
yield { type: 'tool-result', toolCall: tc, result: skippedResult };
|
|
1037
|
-
continue;
|
|
1038
|
-
}
|
|
1039
|
-
if (isHandoffTool(tool)) {
|
|
1040
|
-
const spec = tool.__handoffSpec;
|
|
1041
|
-
const validation = validateToolArgs(tool, tc.arguments);
|
|
1042
|
-
// Handoff payload defaults to `{ message: string }`; custom schemas
|
|
1043
|
-
// are accepted but the loop only uses `args.message` (string) as the
|
|
1044
|
-
// transition prompt. Anything else surfaces in the conversation as
|
|
1045
|
-
// the args of the synthetic tool-call.
|
|
1046
|
-
const args = validation.ok ? validation.value : tc.arguments;
|
|
1047
|
-
const transitionMessage = typeof args['message'] === 'string' ? args['message'] : '';
|
|
1048
|
-
const handoffResult = `Handed off to ${spec.AgentClass.name}.`;
|
|
1049
|
-
toolResults.push({ toolCallId: tc.id, result: handoffResult });
|
|
1050
|
-
messages.push({ role: 'tool', content: handoffResult, toolCallId: tc.id });
|
|
1051
|
-
yield { type: 'tool-call', toolCall: tc };
|
|
1052
|
-
yield { type: 'tool-result', toolCall: tc, result: handoffResult };
|
|
1053
|
-
yield {
|
|
1054
|
-
type: 'handoff',
|
|
1055
|
-
handoff: {
|
|
1056
|
-
from: loopCtx.agent.constructor.name,
|
|
1057
|
-
to: spec.AgentClass.name,
|
|
1058
|
-
...(transitionMessage ? { message: transitionMessage } : {}),
|
|
1059
|
-
},
|
|
1060
|
-
};
|
|
1061
|
-
loopCtx.pendingHandoff = { spec, transitionMessage, parentToolCallId: tc.id };
|
|
1062
|
-
loopCtx.stopForHandoff = true;
|
|
1063
|
-
// Do NOT break — keep iterating so any sibling tool calls in this
|
|
1064
|
-
// step get their synthetic "skipped" tool results before the loop
|
|
1065
|
-
// exits. This preserves message-log invariants for downstream
|
|
1066
|
-
// persistence.
|
|
1067
|
-
continue;
|
|
1068
|
-
}
|
|
1069
|
-
if (!tool.execute) {
|
|
1070
|
-
// Client tool — no server-side handler.
|
|
1071
|
-
if (options?.toolCallStreamingMode === 'stop-on-client-tool') {
|
|
1072
|
-
loopCtx.pendingClientToolCalls.push(tc);
|
|
1073
|
-
loopCtx.loopFinishReason = 'client_tool_calls';
|
|
1074
|
-
loopCtx.stopForClientTools = true;
|
|
1075
|
-
yield { type: 'tool-call', toolCall: tc };
|
|
1076
|
-
continue;
|
|
1077
|
-
}
|
|
1078
|
-
const placeholder = '[client tool — execute on client]';
|
|
1079
|
-
toolResults.push({ toolCallId: tc.id, result: placeholder });
|
|
1080
|
-
messages.push({ role: 'tool', content: placeholder, toolCallId: tc.id });
|
|
1081
|
-
yield { type: 'tool-call', toolCall: tc };
|
|
1082
|
-
yield { type: 'tool-result', toolCall: tc, result: placeholder };
|
|
1083
|
-
continue;
|
|
1084
|
-
}
|
|
1085
|
-
// needsApproval enforcement
|
|
1086
|
-
const approvalDecision = await evaluateApproval(tool, tc, options);
|
|
1087
|
-
if (approvalDecision === 'rejected') {
|
|
1088
|
-
const rejectionResult = { rejected: true, reason: 'User rejected this tool call' };
|
|
1089
|
-
toolResults.push({ toolCallId: tc.id, result: rejectionResult });
|
|
1090
|
-
messages.push({ role: 'tool', content: JSON.stringify(rejectionResult), toolCallId: tc.id });
|
|
1091
|
-
yield { type: 'tool-result', toolCall: tc, result: rejectionResult };
|
|
1092
|
-
continue;
|
|
1093
|
-
}
|
|
1094
|
-
if (approvalDecision === 'pending') {
|
|
1095
|
-
loopCtx.pendingApprovalToolCall = { toolCall: tc, isClientTool: false };
|
|
1096
|
-
loopCtx.loopFinishReason = 'tool_approval_required';
|
|
1097
|
-
loopCtx.stopForApproval = true;
|
|
1098
|
-
yield { type: 'tool-call', toolCall: tc };
|
|
1099
|
-
break;
|
|
1100
|
-
}
|
|
1101
|
-
// onBeforeToolCall
|
|
1102
|
-
let toolArgs = tc.arguments;
|
|
1103
|
-
if (middlewares.length > 0) {
|
|
1104
|
-
const beforeResult = await runOnBeforeToolCall(middlewares, ctx, tc.name, toolArgs);
|
|
1105
|
-
if (beforeResult) {
|
|
1106
|
-
if (beforeResult.type === 'skip') {
|
|
1107
|
-
const resultStr = typeof beforeResult.result === 'string' ? beforeResult.result : JSON.stringify(beforeResult.result);
|
|
1108
|
-
toolResults.push({ toolCallId: tc.id, result: beforeResult.result });
|
|
1109
|
-
messages.push({ role: 'tool', content: resultStr, toolCallId: tc.id });
|
|
1110
|
-
yield { type: 'tool-result', toolCall: tc, result: beforeResult.result };
|
|
1111
|
-
await runOnAfterToolCall(middlewares, ctx, tc.name, toolArgs, beforeResult.result);
|
|
1112
|
-
continue;
|
|
1113
|
-
}
|
|
1114
|
-
if (beforeResult.type === 'abort') {
|
|
1115
|
-
await runOnAbort(middlewares, ctx, beforeResult.reason);
|
|
1116
|
-
break;
|
|
1117
|
-
}
|
|
1118
|
-
if (beforeResult.type === 'transformArgs') {
|
|
1119
|
-
toolArgs = beforeResult.args;
|
|
1120
|
-
}
|
|
1121
|
-
}
|
|
1122
|
-
}
|
|
1123
|
-
// Validate args against the tool's inputSchema. Runs after middleware
|
|
1124
|
-
// transforms so transforms can reshape malformed model output before
|
|
1125
|
-
// it is judged. The tool-call chunk is emitted even on validation
|
|
1126
|
-
// failure so streaming UIs see a paired tool-call → tool-result(error)
|
|
1127
|
-
// sequence; non-streaming callers discard the chunk.
|
|
1128
|
-
const validation = validateToolArgs(tool, toolArgs);
|
|
1129
|
-
if (!validation.ok) {
|
|
1130
|
-
yield { type: 'tool-call', toolCall: tc };
|
|
1131
|
-
toolResults.push({ toolCallId: tc.id, result: validation.error });
|
|
1132
|
-
messages.push({ role: 'tool', content: JSON.stringify(validation.error), toolCallId: tc.id });
|
|
1133
|
-
yield { type: 'tool-result', toolCall: tc, result: validation.error };
|
|
1134
|
-
if (middlewares.length > 0)
|
|
1135
|
-
await runOnAfterToolCall(middlewares, ctx, tc.name, toolArgs, validation.error);
|
|
1136
|
-
continue;
|
|
1137
|
-
}
|
|
1138
|
-
const validatedArgs = validation.value;
|
|
1139
|
-
const toolStart = performance.now();
|
|
1140
|
-
try {
|
|
1141
|
-
// Emit the tool-call marker before execution so streaming UIs see
|
|
1142
|
-
// tool-call → tool-update* → tool-result in order. Async-generator
|
|
1143
|
-
// executes stream their yields as tool-update chunks live; plain
|
|
1144
|
-
// executes yield nothing here.
|
|
1145
|
-
//
|
|
1146
|
-
// Pause detection: a yielded `pause_for_client_tools` control chunk
|
|
1147
|
-
// halts iteration, propagates the nested calls to the parent's
|
|
1148
|
-
// pending list, and SKIPS the tool_result emission — the yielding
|
|
1149
|
-
// tool's own call stays orphaned in the parent message history
|
|
1150
|
-
// until the caller resolves it on resume.
|
|
1151
|
-
yield { type: 'tool-call', toolCall: tc };
|
|
1152
|
-
const execGen = executeMaybeStreaming(tool, validatedArgs, { toolCallId: tc.id });
|
|
1153
|
-
let result;
|
|
1154
|
-
let paused = false;
|
|
1155
|
-
while (true) {
|
|
1156
|
-
const step = await execGen.next();
|
|
1157
|
-
if (step.done) {
|
|
1158
|
-
result = step.value;
|
|
1159
|
-
break;
|
|
1160
|
-
}
|
|
1161
|
-
if (isPauseForClientToolsChunk(step.value)) {
|
|
1162
|
-
for (const pending of step.value.toolCalls) {
|
|
1163
|
-
loopCtx.pendingClientToolCalls.push(pending);
|
|
1164
|
-
}
|
|
1165
|
-
loopCtx.loopFinishReason = 'client_tool_calls';
|
|
1166
|
-
loopCtx.stopForClientTools = true;
|
|
1167
|
-
paused = true;
|
|
1168
|
-
break;
|
|
1169
|
-
}
|
|
1170
|
-
if (isPauseForApprovalChunk(step.value)) {
|
|
1171
|
-
loopCtx.pendingApprovalToolCall = {
|
|
1172
|
-
toolCall: step.value.toolCall,
|
|
1173
|
-
isClientTool: step.value.isClientTool,
|
|
1174
|
-
};
|
|
1175
|
-
loopCtx.loopFinishReason = 'tool_approval_required';
|
|
1176
|
-
loopCtx.stopForApproval = true;
|
|
1177
|
-
paused = true;
|
|
1178
|
-
break;
|
|
1179
|
-
}
|
|
1180
|
-
const updateChunk = { type: 'tool-update', toolCall: tc, update: step.value };
|
|
1181
|
-
if (middlewares.length > 0) {
|
|
1182
|
-
const transformed = runOnChunk(middlewares, ctx, updateChunk);
|
|
1183
|
-
if (transformed)
|
|
1184
|
-
yield transformed;
|
|
1185
|
-
}
|
|
1186
|
-
else {
|
|
1187
|
-
yield updateChunk;
|
|
1188
|
-
}
|
|
1189
|
-
}
|
|
1190
|
-
if (paused)
|
|
1191
|
-
continue; // skip tool_result emission + message push for this tc
|
|
1192
|
-
const duration = performance.now() - toolStart;
|
|
1193
|
-
// toolResults preserves the ORIGINAL value; only the message content
|
|
1194
|
-
// pushed onto `messages` (next-step model input) is narrowed by
|
|
1195
|
-
// toModelOutput. The streamed `tool-result` chunk also carries the
|
|
1196
|
-
// ORIGINAL value.
|
|
1197
|
-
toolResults.push({ toolCallId: tc.id, result, duration });
|
|
1198
|
-
const resultStr = await applyToModelOutput(tool, result, middlewares.length > 0 ? (e) => runOnError(middlewares, ctx, e) : undefined);
|
|
1199
|
-
messages.push({ role: 'tool', content: resultStr, toolCallId: tc.id });
|
|
1200
|
-
yield { type: 'tool-result', toolCall: tc, result };
|
|
1201
|
-
// onAfterToolCall
|
|
1202
|
-
if (middlewares.length > 0)
|
|
1203
|
-
await runOnAfterToolCall(middlewares, ctx, tc.name, toolArgs, result);
|
|
1204
|
-
}
|
|
1205
|
-
catch (err) {
|
|
1206
|
-
const duration = performance.now() - toolStart;
|
|
1207
|
-
const msg = err instanceof Error ? err.message : String(err);
|
|
1208
|
-
const errResult = `Error: ${msg}`;
|
|
1209
|
-
toolResults.push({ toolCallId: tc.id, result: errResult, duration });
|
|
1210
|
-
messages.push({ role: 'tool', content: errResult, toolCallId: tc.id });
|
|
1211
|
-
yield { type: 'tool-result', toolCall: tc, result: errResult };
|
|
1212
|
-
// onAfterToolCall (error case)
|
|
1213
|
-
if (middlewares.length > 0)
|
|
1214
|
-
await runOnAfterToolCall(middlewares, ctx, tc.name, toolArgs, errResult);
|
|
1215
|
-
}
|
|
1216
|
-
}
|
|
1217
|
-
}
|
|
1218
|
-
/**
|
|
1219
|
-
* Parallel tool execution — three phases:
|
|
1220
|
-
*
|
|
1221
|
-
* 1. **Prelude (serial, in tool-call order):** classify each call. Approval
|
|
1222
|
-
* decisions, `onBeforeToolCall` middleware, and arg validation all
|
|
1223
|
-
* resolve here; the next phase only sees calls that cleared every
|
|
1224
|
-
* gate. `pending-approval` and `mw-abort` short-circuit the prelude
|
|
1225
|
-
* exactly as they do in serial mode — later calls are never dispatched.
|
|
1226
|
-
*
|
|
1227
|
-
* 2. **Execution (parallel):** for every `ready` outcome, drive
|
|
1228
|
-
* `executeMaybeStreaming` to completion concurrently. `tool-update`
|
|
1229
|
-
* chunks (and any pause-for-client-tools mutations to `loopCtx`) are
|
|
1230
|
-
* captured per-call into a buffer.
|
|
1231
|
-
*
|
|
1232
|
-
* 3. **Replay (serial, in tool-call order):** for each outcome, emit its
|
|
1233
|
-
* chunks (including buffered `tool-update`s for ready calls), push
|
|
1234
|
-
* tool messages, and run `onAfterToolCall`. This is the only phase
|
|
1235
|
-
* that yields chunks to consumers, so streamed output stays
|
|
1236
|
-
* deterministic regardless of which `execute()` finished first.
|
|
1237
|
-
*/
|
|
1238
|
-
async function* runToolPhaseParallel(loopCtx, toolCalls, toolResults) {
|
|
1239
|
-
const { messages, middlewares, ctx } = loopCtx;
|
|
1240
|
-
// ─── Phase 1: prelude ──────────────────────────────────
|
|
1241
|
-
const outcomes = await classifyToolCalls(loopCtx, toolCalls);
|
|
1242
|
-
// ─── Phase 2: dispatch ready executions concurrently ──
|
|
1243
|
-
const ready = outcomes.filter((o) => o.kind === 'ready');
|
|
1244
|
-
const executions = await Promise.all(ready.map(o => runToolExecution(loopCtx, o)));
|
|
1245
|
-
const executionByCallId = new Map();
|
|
1246
|
-
for (let i = 0; i < ready.length; i++) {
|
|
1247
|
-
executionByCallId.set(ready[i].tc.id, executions[i]);
|
|
1248
|
-
}
|
|
1249
|
-
// ─── Phase 3: replay chunks + side-effects in order ───
|
|
1250
|
-
for (const outcome of outcomes) {
|
|
1251
|
-
if (outcome.kind === 'unknown-tool') {
|
|
1252
|
-
toolResults.push({ toolCallId: outcome.tc.id, result: outcome.result });
|
|
1253
|
-
messages.push({ role: 'tool', content: outcome.result, toolCallId: outcome.tc.id });
|
|
1254
|
-
yield { type: 'tool-result', toolCall: outcome.tc, result: outcome.result };
|
|
1255
|
-
continue;
|
|
1256
|
-
}
|
|
1257
|
-
if (outcome.kind === 'client-tool-stop') {
|
|
1258
|
-
// loopCtx mutations already applied during the prelude.
|
|
1259
|
-
yield { type: 'tool-call', toolCall: outcome.tc };
|
|
1260
|
-
continue;
|
|
1261
|
-
}
|
|
1262
|
-
if (outcome.kind === 'client-tool-placeholder') {
|
|
1263
|
-
toolResults.push({ toolCallId: outcome.tc.id, result: outcome.result });
|
|
1264
|
-
messages.push({ role: 'tool', content: outcome.result, toolCallId: outcome.tc.id });
|
|
1265
|
-
yield { type: 'tool-call', toolCall: outcome.tc };
|
|
1266
|
-
yield { type: 'tool-result', toolCall: outcome.tc, result: outcome.result };
|
|
1267
|
-
continue;
|
|
1268
|
-
}
|
|
1269
|
-
if (outcome.kind === 'rejected') {
|
|
1270
|
-
toolResults.push({ toolCallId: outcome.tc.id, result: outcome.result });
|
|
1271
|
-
messages.push({ role: 'tool', content: JSON.stringify(outcome.result), toolCallId: outcome.tc.id });
|
|
1272
|
-
yield { type: 'tool-result', toolCall: outcome.tc, result: outcome.result };
|
|
1273
|
-
continue;
|
|
1274
|
-
}
|
|
1275
|
-
if (outcome.kind === 'pending-approval') {
|
|
1276
|
-
// loopCtx mutations already applied during the prelude.
|
|
1277
|
-
yield { type: 'tool-call', toolCall: outcome.tc };
|
|
1278
|
-
// Phase 1 stops classifying after pending-approval, so this is the
|
|
1279
|
-
// last outcome — but `break` keeps the intent explicit.
|
|
1280
|
-
break;
|
|
1281
|
-
}
|
|
1282
|
-
if (outcome.kind === 'mw-skip') {
|
|
1283
|
-
const resultStr = typeof outcome.result === 'string' ? outcome.result : JSON.stringify(outcome.result);
|
|
1284
|
-
toolResults.push({ toolCallId: outcome.tc.id, result: outcome.result });
|
|
1285
|
-
messages.push({ role: 'tool', content: resultStr, toolCallId: outcome.tc.id });
|
|
1286
|
-
yield { type: 'tool-result', toolCall: outcome.tc, result: outcome.result };
|
|
1287
|
-
if (middlewares.length > 0)
|
|
1288
|
-
await runOnAfterToolCall(middlewares, ctx, outcome.tc.name, outcome.toolArgs, outcome.result);
|
|
1289
|
-
continue;
|
|
1290
|
-
}
|
|
1291
|
-
if (outcome.kind === 'validation-error') {
|
|
1292
|
-
yield { type: 'tool-call', toolCall: outcome.tc };
|
|
1293
|
-
toolResults.push({ toolCallId: outcome.tc.id, result: outcome.error });
|
|
1294
|
-
messages.push({ role: 'tool', content: JSON.stringify(outcome.error), toolCallId: outcome.tc.id });
|
|
1295
|
-
yield { type: 'tool-result', toolCall: outcome.tc, result: outcome.error };
|
|
1296
|
-
if (middlewares.length > 0)
|
|
1297
|
-
await runOnAfterToolCall(middlewares, ctx, outcome.tc.name, outcome.toolArgs, outcome.error);
|
|
1298
|
-
continue;
|
|
1299
|
-
}
|
|
1300
|
-
// outcome.kind === 'ready'
|
|
1301
|
-
const exec = executionByCallId.get(outcome.tc.id);
|
|
1302
|
-
yield { type: 'tool-call', toolCall: outcome.tc };
|
|
1303
|
-
for (const chunk of exec.updates)
|
|
1304
|
-
yield chunk;
|
|
1305
|
-
if (exec.kind === 'paused') {
|
|
1306
|
-
// Pause-for-client-tools propagated its calls onto `loopCtx` during
|
|
1307
|
-
// execution. Skip tool_result emission + message push — the call
|
|
1308
|
-
// stays orphaned until resume.
|
|
1309
|
-
continue;
|
|
1310
|
-
}
|
|
1311
|
-
if (exec.kind === 'error') {
|
|
1312
|
-
const errResult = `Error: ${exec.error.message}`;
|
|
1313
|
-
toolResults.push({ toolCallId: outcome.tc.id, result: errResult, duration: exec.duration });
|
|
1314
|
-
messages.push({ role: 'tool', content: errResult, toolCallId: outcome.tc.id });
|
|
1315
|
-
yield { type: 'tool-result', toolCall: outcome.tc, result: errResult };
|
|
1316
|
-
if (middlewares.length > 0)
|
|
1317
|
-
await runOnAfterToolCall(middlewares, ctx, outcome.tc.name, outcome.toolArgs, errResult);
|
|
1318
|
-
continue;
|
|
1319
|
-
}
|
|
1320
|
-
// exec.kind === 'ok'
|
|
1321
|
-
toolResults.push({ toolCallId: outcome.tc.id, result: exec.result, duration: exec.duration });
|
|
1322
|
-
const resultStr = await applyToModelOutput(outcome.tool, exec.result, middlewares.length > 0 ? (e) => runOnError(middlewares, ctx, e) : undefined);
|
|
1323
|
-
messages.push({ role: 'tool', content: resultStr, toolCallId: outcome.tc.id });
|
|
1324
|
-
yield { type: 'tool-result', toolCall: outcome.tc, result: exec.result };
|
|
1325
|
-
if (middlewares.length > 0)
|
|
1326
|
-
await runOnAfterToolCall(middlewares, ctx, outcome.tc.name, outcome.toolArgs, exec.result);
|
|
1327
|
-
}
|
|
1328
|
-
}
|
|
1329
|
-
/**
|
|
1330
|
-
* Walk `toolCalls` in order and decide each call's fate. Mutations to
|
|
1331
|
-
* `loopCtx` for client-tool-stop, pending-approval, and middleware-abort
|
|
1332
|
-
* happen here so the rest of the parallel flow sees the same state the
|
|
1333
|
-
* serial path would. `pending-approval` and `mw-abort` stop the walk —
|
|
1334
|
-
* later calls are not classified and are silently dropped.
|
|
1335
|
-
*/
|
|
1336
|
-
async function classifyToolCalls(loopCtx, toolCalls) {
|
|
1337
|
-
const { middlewares, toolMap, options, ctx } = loopCtx;
|
|
1338
|
-
const outcomes = [];
|
|
1339
|
-
for (const tc of toolCalls) {
|
|
1340
|
-
const tool = toolMap.get(tc.name);
|
|
1341
|
-
if (!tool) {
|
|
1342
|
-
outcomes.push({ kind: 'unknown-tool', tc, result: `Error: Unknown tool "${tc.name}"` });
|
|
1343
|
-
continue;
|
|
1344
|
-
}
|
|
1345
|
-
if (!tool.execute) {
|
|
1346
|
-
if (options?.toolCallStreamingMode === 'stop-on-client-tool') {
|
|
1347
|
-
loopCtx.pendingClientToolCalls.push(tc);
|
|
1348
|
-
loopCtx.loopFinishReason = 'client_tool_calls';
|
|
1349
|
-
loopCtx.stopForClientTools = true;
|
|
1350
|
-
outcomes.push({ kind: 'client-tool-stop', tc });
|
|
1351
|
-
continue;
|
|
1352
|
-
}
|
|
1353
|
-
outcomes.push({ kind: 'client-tool-placeholder', tc, result: '[client tool — execute on client]' });
|
|
1354
|
-
continue;
|
|
1355
|
-
}
|
|
1356
|
-
const approvalDecision = await evaluateApproval(tool, tc, options);
|
|
1357
|
-
if (approvalDecision === 'rejected') {
|
|
1358
|
-
outcomes.push({ kind: 'rejected', tc, result: { rejected: true, reason: 'User rejected this tool call' } });
|
|
1359
|
-
continue;
|
|
1360
|
-
}
|
|
1361
|
-
if (approvalDecision === 'pending') {
|
|
1362
|
-
loopCtx.pendingApprovalToolCall = { toolCall: tc, isClientTool: false };
|
|
1363
|
-
loopCtx.loopFinishReason = 'tool_approval_required';
|
|
1364
|
-
loopCtx.stopForApproval = true;
|
|
1365
|
-
outcomes.push({ kind: 'pending-approval', tc });
|
|
1366
|
-
break;
|
|
1367
|
-
}
|
|
1368
|
-
let toolArgs = tc.arguments;
|
|
1369
|
-
if (middlewares.length > 0) {
|
|
1370
|
-
const beforeResult = await runOnBeforeToolCall(middlewares, ctx, tc.name, toolArgs);
|
|
1371
|
-
if (beforeResult) {
|
|
1372
|
-
if (beforeResult.type === 'skip') {
|
|
1373
|
-
outcomes.push({ kind: 'mw-skip', tc, toolArgs, result: beforeResult.result });
|
|
1374
|
-
continue;
|
|
1375
|
-
}
|
|
1376
|
-
if (beforeResult.type === 'abort') {
|
|
1377
|
-
await runOnAbort(middlewares, ctx, beforeResult.reason);
|
|
1378
|
-
// Drop any prior outcomes too? No — serial mode emits prior
|
|
1379
|
-
// outcomes' chunks before hitting abort, so we keep them in the
|
|
1380
|
-
// outcomes list and Phase 3 emits them up to (but not including)
|
|
1381
|
-
// this call. Stop classifying further.
|
|
1382
|
-
break;
|
|
1383
|
-
}
|
|
1384
|
-
if (beforeResult.type === 'transformArgs') {
|
|
1385
|
-
toolArgs = beforeResult.args;
|
|
1386
|
-
}
|
|
1387
|
-
}
|
|
1388
|
-
}
|
|
1389
|
-
const validation = validateToolArgs(tool, toolArgs);
|
|
1390
|
-
if (!validation.ok) {
|
|
1391
|
-
outcomes.push({ kind: 'validation-error', tc, toolArgs, error: validation.error });
|
|
1392
|
-
continue;
|
|
1393
|
-
}
|
|
1394
|
-
outcomes.push({ kind: 'ready', tc, tool, toolArgs, validatedArgs: validation.value });
|
|
1395
|
-
}
|
|
1396
|
-
return outcomes;
|
|
1397
|
-
}
|
|
1398
|
-
/**
|
|
1399
|
-
* Drive a single tool's `executeMaybeStreaming` to completion. Buffers
|
|
1400
|
-
* `tool-update` chunks for replay in tool-call order; pause-for-client-tools
|
|
1401
|
-
* mutations to `loopCtx` apply immediately and the call returns `paused`.
|
|
1402
|
-
*
|
|
1403
|
-
* `ctx` is shared across concurrent invocations. Middleware that writes
|
|
1404
|
-
* through `ctx` during `runOnChunk` (uncommon — most use it read-only for
|
|
1405
|
-
* telemetry) may observe interleaved updates from sibling tool calls;
|
|
1406
|
-
* apps with such middleware should opt out via `parallelTools: false`.
|
|
1407
|
-
*/
|
|
1408
|
-
async function runToolExecution(loopCtx, outcome) {
|
|
1409
|
-
const { middlewares, ctx } = loopCtx;
|
|
1410
|
-
const updates = [];
|
|
1411
|
-
const toolStart = performance.now();
|
|
1412
|
-
try {
|
|
1413
|
-
const execGen = executeMaybeStreaming(outcome.tool, outcome.validatedArgs, { toolCallId: outcome.tc.id });
|
|
1414
|
-
let result;
|
|
1415
|
-
let paused = false;
|
|
1416
|
-
while (true) {
|
|
1417
|
-
const step = await execGen.next();
|
|
1418
|
-
if (step.done) {
|
|
1419
|
-
result = step.value;
|
|
1420
|
-
break;
|
|
1421
|
-
}
|
|
1422
|
-
if (isPauseForClientToolsChunk(step.value)) {
|
|
1423
|
-
for (const pending of step.value.toolCalls) {
|
|
1424
|
-
loopCtx.pendingClientToolCalls.push(pending);
|
|
1425
|
-
}
|
|
1426
|
-
loopCtx.loopFinishReason = 'client_tool_calls';
|
|
1427
|
-
loopCtx.stopForClientTools = true;
|
|
1428
|
-
paused = true;
|
|
1429
|
-
break;
|
|
1430
|
-
}
|
|
1431
|
-
if (isPauseForApprovalChunk(step.value)) {
|
|
1432
|
-
loopCtx.pendingApprovalToolCall = {
|
|
1433
|
-
toolCall: step.value.toolCall,
|
|
1434
|
-
isClientTool: step.value.isClientTool,
|
|
1435
|
-
};
|
|
1436
|
-
loopCtx.loopFinishReason = 'tool_approval_required';
|
|
1437
|
-
loopCtx.stopForApproval = true;
|
|
1438
|
-
paused = true;
|
|
1439
|
-
break;
|
|
1440
|
-
}
|
|
1441
|
-
const updateChunk = { type: 'tool-update', toolCall: outcome.tc, update: step.value };
|
|
1442
|
-
if (middlewares.length > 0) {
|
|
1443
|
-
const transformed = runOnChunk(middlewares, ctx, updateChunk);
|
|
1444
|
-
if (transformed)
|
|
1445
|
-
updates.push(transformed);
|
|
1446
|
-
}
|
|
1447
|
-
else {
|
|
1448
|
-
updates.push(updateChunk);
|
|
1449
|
-
}
|
|
1450
|
-
}
|
|
1451
|
-
const duration = performance.now() - toolStart;
|
|
1452
|
-
if (paused)
|
|
1453
|
-
return { kind: 'paused', updates, duration };
|
|
1454
|
-
return { kind: 'ok', result, updates, duration };
|
|
1455
|
-
}
|
|
1456
|
-
catch (err) {
|
|
1457
|
-
const duration = performance.now() - toolStart;
|
|
1458
|
-
return { kind: 'error', error: err instanceof Error ? err : new Error(String(err)), updates, duration };
|
|
1459
|
-
}
|
|
1460
|
-
}
|
|
1461
972
|
/**
|
|
1462
973
|
* Build the shared `LoopContext` for a `prompt()` / `stream()` call, run
|
|
1463
974
|
* approval-resume, and fire `onConfig(init)` + `onStart`. After this returns,
|
|
@@ -1572,14 +1083,6 @@ async function runIterationPrelude(loopCtx, iteration) {
|
|
|
1572
1083
|
return { currentModel };
|
|
1573
1084
|
}
|
|
1574
1085
|
// ─── Agent Loop (non-streaming) ──────────────────────────
|
|
1575
|
-
/**
|
|
1576
|
-
* Hard ceiling for the number of agent-to-agent handoffs in a single
|
|
1577
|
-
* `prompt()` / `stream()` call. Most workflows hop once or twice (triage →
|
|
1578
|
-
* specialist). Anything beyond this almost certainly means the agents are
|
|
1579
|
-
* cycling — surfacing a clear error beats silently looping until token
|
|
1580
|
-
* budgets explode.
|
|
1581
|
-
*/
|
|
1582
|
-
const MAX_HANDOFFS = 5;
|
|
1583
1086
|
/**
|
|
1584
1087
|
* Public entry point for the non-streaming agent loop. Drives
|
|
1585
1088
|
* {@link runAgentLoopOnce} once, then — if the model called a {@link handoff}
|
|
@@ -1593,7 +1096,7 @@ async function runAgentLoop(a, input, options) {
|
|
|
1593
1096
|
if (!onceResult._pendingHandoff) {
|
|
1594
1097
|
return stripInternal(onceResult);
|
|
1595
1098
|
}
|
|
1596
|
-
const merged = await driveHandoffs(a.constructor.name, onceResult, onceResult._pendingHandoff, onceResult._carriedMessages ?? [], options, 0);
|
|
1099
|
+
const merged = await driveHandoffs(a.constructor.name, onceResult, onceResult._pendingHandoff, onceResult._carriedMessages ?? [], options, 0, runAgentLoopOnce);
|
|
1597
1100
|
return merged;
|
|
1598
1101
|
}
|
|
1599
1102
|
/**
|
|
@@ -1665,101 +1168,6 @@ function runAgentLoopStreaming(a, input, options) {
|
|
|
1665
1168
|
response: responsePromise,
|
|
1666
1169
|
};
|
|
1667
1170
|
}
|
|
1668
|
-
/**
|
|
1669
|
-
* Iteratively drive pending handoffs, carrying steps + usage forward.
|
|
1670
|
-
* Used by the non-streaming path. (Streaming has its own iterative driver
|
|
1671
|
-
* inline in {@link runAgentLoopStreaming} so chunks can flow as each hop's
|
|
1672
|
-
* loop runs.)
|
|
1673
|
-
*/
|
|
1674
|
-
async function driveHandoffs(rootName, rootResult, pending, carriedMessages, origOptions, startHopCount) {
|
|
1675
|
-
const mergedSteps = [...rootResult.steps];
|
|
1676
|
-
const mergedUsage = { promptTokens: 0, completionTokens: 0, totalTokens: 0 };
|
|
1677
|
-
addUsage(mergedUsage, rootResult.usage);
|
|
1678
|
-
const handoffPath = [rootName];
|
|
1679
|
-
let currentPending = pending;
|
|
1680
|
-
let currentCarried = carriedMessages;
|
|
1681
|
-
let hopCount = startHopCount;
|
|
1682
|
-
for (;;) {
|
|
1683
|
-
if (hopCount >= MAX_HANDOFFS) {
|
|
1684
|
-
throw new Error(`[RudderJS AI] Exceeded max handoffs (${MAX_HANDOFFS}). Likely a cycle between agents.`);
|
|
1685
|
-
}
|
|
1686
|
-
const ChildClass = currentPending.spec.AgentClass;
|
|
1687
|
-
handoffPath.push(ChildClass.name);
|
|
1688
|
-
const child = new ChildClass();
|
|
1689
|
-
const childOpts = buildHandoffChildOptions(origOptions, currentCarried);
|
|
1690
|
-
const childOnce = await runAgentLoopOnce(child, currentPending.transitionMessage, childOpts);
|
|
1691
|
-
mergedSteps.push(...childOnce.steps);
|
|
1692
|
-
addUsage(mergedUsage, childOnce.usage);
|
|
1693
|
-
if (childOnce._pendingHandoff) {
|
|
1694
|
-
currentPending = childOnce._pendingHandoff;
|
|
1695
|
-
currentCarried = childOnce._carriedMessages ?? [];
|
|
1696
|
-
hopCount++;
|
|
1697
|
-
continue;
|
|
1698
|
-
}
|
|
1699
|
-
return {
|
|
1700
|
-
...stripInternal(childOnce),
|
|
1701
|
-
steps: mergedSteps,
|
|
1702
|
-
usage: mergedUsage,
|
|
1703
|
-
handoffPath,
|
|
1704
|
-
};
|
|
1705
|
-
}
|
|
1706
|
-
}
|
|
1707
|
-
/** Merge the terminal hop's response with carried steps / usage / path. */
|
|
1708
|
-
function mergeFinalHandoff(terminal, mergedSteps, mergedUsage, pathPrefix, terminalName) {
|
|
1709
|
-
return {
|
|
1710
|
-
...terminal,
|
|
1711
|
-
steps: mergedSteps,
|
|
1712
|
-
usage: mergedUsage,
|
|
1713
|
-
handoffPath: [...pathPrefix, terminalName],
|
|
1714
|
-
};
|
|
1715
|
-
}
|
|
1716
|
-
/**
|
|
1717
|
-
* Build the {@link AgentPromptOptions} for a child agent invoked via
|
|
1718
|
-
* handoff. The parent's carried message log replaces the child's input
|
|
1719
|
-
* (so the child sees the full conversation up to the handoff point) but
|
|
1720
|
-
* the child still prepends its own `instructions()` as the system message
|
|
1721
|
-
* during {@link initializeLoop}, so we drop the parent's leading system
|
|
1722
|
-
* message to avoid double-prefixing.
|
|
1723
|
-
*
|
|
1724
|
-
* Per-call options that make sense to carry across (signal, attachments,
|
|
1725
|
-
* tool/middleware overrides) are preserved; `messages` and `history` are
|
|
1726
|
-
* deliberately overridden.
|
|
1727
|
-
*/
|
|
1728
|
-
function buildHandoffChildOptions(parentOptions, carriedMessages) {
|
|
1729
|
-
const stripped = carriedMessages.length > 0 && carriedMessages[0]?.role === 'system'
|
|
1730
|
-
? carriedMessages.slice(1)
|
|
1731
|
-
: carriedMessages;
|
|
1732
|
-
// We append the model's transition message as the next user message so
|
|
1733
|
-
// the child has something concrete to respond to (it's also passed as
|
|
1734
|
-
// `currentInput` below — but feeding it via `messages` mode keeps the
|
|
1735
|
-
// history coherent and prevents `initializeLoop` from also prepending
|
|
1736
|
-
// an `input` user message).
|
|
1737
|
-
return {
|
|
1738
|
-
...(parentOptions ?? {}),
|
|
1739
|
-
messages: stripped,
|
|
1740
|
-
};
|
|
1741
|
-
}
|
|
1742
|
-
/** Strip the internal `_pendingHandoff` / `_carriedMessages` fields before surfacing the response to public callers. */
|
|
1743
|
-
function stripInternal(r) {
|
|
1744
|
-
const out = {
|
|
1745
|
-
text: r.text,
|
|
1746
|
-
steps: r.steps,
|
|
1747
|
-
usage: r.usage,
|
|
1748
|
-
};
|
|
1749
|
-
if (r.conversationId !== undefined)
|
|
1750
|
-
out.conversationId = r.conversationId;
|
|
1751
|
-
if (r.finishReason !== undefined)
|
|
1752
|
-
out.finishReason = r.finishReason;
|
|
1753
|
-
if (r.pendingClientToolCalls !== undefined)
|
|
1754
|
-
out.pendingClientToolCalls = r.pendingClientToolCalls;
|
|
1755
|
-
if (r.pendingApprovalToolCall !== undefined)
|
|
1756
|
-
out.pendingApprovalToolCall = r.pendingApprovalToolCall;
|
|
1757
|
-
if (r.resumedToolMessages !== undefined)
|
|
1758
|
-
out.resumedToolMessages = r.resumedToolMessages;
|
|
1759
|
-
if (r.handoffPath !== undefined)
|
|
1760
|
-
out.handoffPath = r.handoffPath;
|
|
1761
|
-
return out;
|
|
1762
|
-
}
|
|
1763
1171
|
async function runAgentLoopOnce(a, input, options) {
|
|
1764
1172
|
const { loopCtx, stopConditions } = await initializeLoop(a, input, options);
|
|
1765
1173
|
const { ctx, middlewares, messages, steps, totalUsage } = loopCtx;
|
|
@@ -1960,7 +1368,11 @@ function runAgentLoopStreamingOnce(a, input, options) {
|
|
|
1960
1368
|
yield { type: 'pending-client-tools', toolCalls: loopCtx.pendingClientToolCalls };
|
|
1961
1369
|
}
|
|
1962
1370
|
if (loopCtx.pendingApprovalToolCall) {
|
|
1963
|
-
yield {
|
|
1371
|
+
yield {
|
|
1372
|
+
type: 'pending-approval',
|
|
1373
|
+
toolCall: loopCtx.pendingApprovalToolCall.toolCall,
|
|
1374
|
+
isClientTool: loopCtx.pendingApprovalToolCall.isClientTool,
|
|
1375
|
+
};
|
|
1964
1376
|
}
|
|
1965
1377
|
const result = buildAgentResponse(loopCtx);
|
|
1966
1378
|
emitObserverCompleted(loopCtx, result, true);
|
|
@@ -1987,216 +1399,4 @@ function runAgentLoopStreamingOnce(a, input, options) {
|
|
|
1987
1399
|
function normalizeStopConditions(cond) {
|
|
1988
1400
|
return Array.isArray(cond) ? cond : [cond];
|
|
1989
1401
|
}
|
|
1990
|
-
/**
|
|
1991
|
-
* When continuing a chat after a stop-on-approval round-trip, the supplied
|
|
1992
|
-
* `messages` array ends with an `assistant` message whose `toolCalls` were
|
|
1993
|
-
* never fulfilled (the loop paused before executing them). Most providers
|
|
1994
|
-
* (Anthropic in particular) reject such conversations because every
|
|
1995
|
-
* `tool_use` block must be followed by a matching `tool_result`.
|
|
1996
|
-
*
|
|
1997
|
-
* This helper detects that case, executes the pending **server** tool calls
|
|
1998
|
-
* (honoring `approvedToolCallIds` / `rejectedToolCallIds`), appends the
|
|
1999
|
-
* resulting tool messages to `messages` in place, and returns them. The
|
|
2000
|
-
* caller can attach the returned list to `AgentResponse.resumedToolMessages`
|
|
2001
|
-
* so that the panels dispatcher persists them in the conversation store.
|
|
2002
|
-
*
|
|
2003
|
-
* Client tools (no `execute`) must come back from the browser with their
|
|
2004
|
-
* tool result already in the conversation, so the trailing assistant message
|
|
2005
|
-
* will not have unmatched `toolCalls` for them — they're handled outside.
|
|
2006
|
-
*/
|
|
2007
|
-
async function resumePendingToolCalls(deps) {
|
|
2008
|
-
const { messages, toolMap, options } = deps;
|
|
2009
|
-
const last = messages[messages.length - 1];
|
|
2010
|
-
if (!last || last.role !== 'assistant' || !last.toolCalls || last.toolCalls.length === 0) {
|
|
2011
|
-
return { resumed: [], approvalStillRequired: undefined };
|
|
2012
|
-
}
|
|
2013
|
-
const resumed = [];
|
|
2014
|
-
let approvalStillRequired;
|
|
2015
|
-
for (const tc of last.toolCalls) {
|
|
2016
|
-
const tool = toolMap.get(tc.name);
|
|
2017
|
-
if (!tool) {
|
|
2018
|
-
const err = `Error: Unknown tool "${tc.name}"`;
|
|
2019
|
-
const m = { role: 'tool', content: err, toolCallId: tc.id };
|
|
2020
|
-
messages.push(m);
|
|
2021
|
-
resumed.push(m);
|
|
2022
|
-
continue;
|
|
2023
|
-
}
|
|
2024
|
-
if (!tool.execute) {
|
|
2025
|
-
// Client tool whose result is missing from the supplied messages.
|
|
2026
|
-
// Surface an error so the model can recover instead of hanging.
|
|
2027
|
-
const err = `Error: client tool "${tc.name}" was not executed by the browser`;
|
|
2028
|
-
const m = { role: 'tool', content: err, toolCallId: tc.id };
|
|
2029
|
-
messages.push(m);
|
|
2030
|
-
resumed.push(m);
|
|
2031
|
-
continue;
|
|
2032
|
-
}
|
|
2033
|
-
const decision = await evaluateApproval(tool, tc, options);
|
|
2034
|
-
if (decision === 'rejected') {
|
|
2035
|
-
const rej = { rejected: true, reason: 'User rejected this tool call' };
|
|
2036
|
-
const m = { role: 'tool', content: JSON.stringify(rej), toolCallId: tc.id };
|
|
2037
|
-
messages.push(m);
|
|
2038
|
-
resumed.push(m);
|
|
2039
|
-
continue;
|
|
2040
|
-
}
|
|
2041
|
-
if (decision === 'pending') {
|
|
2042
|
-
// Still pending — the user has not yet approved this call. Re-emit
|
|
2043
|
-
// the pending state and stop processing further tools.
|
|
2044
|
-
approvalStillRequired = { toolCall: tc, isClientTool: false };
|
|
2045
|
-
break;
|
|
2046
|
-
}
|
|
2047
|
-
// Validate args before executing on resume. Approval-resume bypasses
|
|
2048
|
-
// middleware so we use the raw tc.arguments. On failure, feed the
|
|
2049
|
-
// structured error to the model so it can correct itself.
|
|
2050
|
-
const validation = validateToolArgs(tool, tc.arguments);
|
|
2051
|
-
if (!validation.ok) {
|
|
2052
|
-
const m = { role: 'tool', content: JSON.stringify(validation.error), toolCallId: tc.id };
|
|
2053
|
-
messages.push(m);
|
|
2054
|
-
resumed.push(m);
|
|
2055
|
-
continue;
|
|
2056
|
-
}
|
|
2057
|
-
try {
|
|
2058
|
-
// Drain generator yields silently — approval-resume runs outside the
|
|
2059
|
-
// stream, so any preliminary updates are discarded; only the final
|
|
2060
|
-
// return value is captured.
|
|
2061
|
-
const execGen = executeMaybeStreaming(tool, validation.value, { toolCallId: tc.id });
|
|
2062
|
-
let result;
|
|
2063
|
-
while (true) {
|
|
2064
|
-
const step = await execGen.next();
|
|
2065
|
-
if (step.done) {
|
|
2066
|
-
result = step.value;
|
|
2067
|
-
break;
|
|
2068
|
-
}
|
|
2069
|
-
}
|
|
2070
|
-
// Approval-resume has no middleware context here, so toModelOutput
|
|
2071
|
-
// errors fall back silently to default stringification (R6).
|
|
2072
|
-
const content = await applyToModelOutput(tool, result);
|
|
2073
|
-
const m = { role: 'tool', content, toolCallId: tc.id };
|
|
2074
|
-
messages.push(m);
|
|
2075
|
-
resumed.push(m);
|
|
2076
|
-
}
|
|
2077
|
-
catch (err) {
|
|
2078
|
-
const errMsg = `Error: ${err instanceof Error ? err.message : String(err)}`;
|
|
2079
|
-
const m = { role: 'tool', content: errMsg, toolCallId: tc.id };
|
|
2080
|
-
messages.push(m);
|
|
2081
|
-
resumed.push(m);
|
|
2082
|
-
}
|
|
2083
|
-
}
|
|
2084
|
-
return { resumed, approvalStillRequired };
|
|
2085
|
-
}
|
|
2086
|
-
/**
|
|
2087
|
-
* Detect an async generator (the value returned by `async function*` or any
|
|
2088
|
-
* object implementing the AsyncGenerator protocol). We use a structural check
|
|
2089
|
-
* because the executor may not be authored as a literal `async function*`
|
|
2090
|
-
* (e.g. wrapped or returned from a factory).
|
|
2091
|
-
*/
|
|
2092
|
-
function isAsyncGenerator(value) {
|
|
2093
|
-
if (value === null || typeof value !== 'object')
|
|
2094
|
-
return false;
|
|
2095
|
-
const v = value;
|
|
2096
|
-
return typeof v.next === 'function'
|
|
2097
|
-
&& typeof v.return === 'function'
|
|
2098
|
-
&& typeof v[Symbol.asyncIterator] === 'function';
|
|
2099
|
-
}
|
|
2100
|
-
/**
|
|
2101
|
-
* Uniformly iterate a tool's `execute`, whether it returns a value, a
|
|
2102
|
-
* promise, or an async generator.
|
|
2103
|
-
*
|
|
2104
|
-
* The helper is itself an async generator: each `yield` is a preliminary
|
|
2105
|
-
* tool-update payload (only generator-style executes produce these), and the
|
|
2106
|
-
* generator's `return` value is the final tool result.
|
|
2107
|
-
*
|
|
2108
|
-
* Streaming callers iterate and emit `tool-update` chunks live as updates
|
|
2109
|
-
* arrive. Non-streaming callers iterate and discard yields, capturing only
|
|
2110
|
-
* the final return value — same tool definition works in both modes.
|
|
2111
|
-
*/
|
|
2112
|
-
async function* executeMaybeStreaming(tool, args, ctx) {
|
|
2113
|
-
const execute = tool.execute;
|
|
2114
|
-
if (!execute) {
|
|
2115
|
-
throw new Error('Tool has no execute function');
|
|
2116
|
-
}
|
|
2117
|
-
const ret = execute(args, ctx);
|
|
2118
|
-
if (isAsyncGenerator(ret)) {
|
|
2119
|
-
while (true) {
|
|
2120
|
-
const step = await ret.next();
|
|
2121
|
-
if (step.done)
|
|
2122
|
-
return step.value;
|
|
2123
|
-
yield step.value;
|
|
2124
|
-
}
|
|
2125
|
-
}
|
|
2126
|
-
return await ret;
|
|
2127
|
-
}
|
|
2128
|
-
/**
|
|
2129
|
-
* Validate a tool call's arguments against the tool's `inputSchema`. On
|
|
2130
|
-
* success, the parsed value is returned — zod transforms (`.transform`,
|
|
2131
|
-
* `.default`, type coercion) are applied, so `execute` receives the
|
|
2132
|
-
* canonical shape the schema describes. On failure, a structured error
|
|
2133
|
-
* suitable for feeding back to the model is returned.
|
|
2134
|
-
*/
|
|
2135
|
-
function validateToolArgs(tool, args) {
|
|
2136
|
-
const parsed = tool.definition.inputSchema.safeParse(args);
|
|
2137
|
-
if (parsed.success) {
|
|
2138
|
-
return { ok: true, value: parsed.data };
|
|
2139
|
-
}
|
|
2140
|
-
return {
|
|
2141
|
-
ok: false,
|
|
2142
|
-
error: {
|
|
2143
|
-
error: 'invalid_arguments',
|
|
2144
|
-
message: `Tool "${tool.definition.name}" received arguments that did not match its inputSchema.`,
|
|
2145
|
-
issues: parsed.error.issues.map(i => ({
|
|
2146
|
-
path: i.path.map(seg => String(seg)).join('.'),
|
|
2147
|
-
message: i.message,
|
|
2148
|
-
})),
|
|
2149
|
-
},
|
|
2150
|
-
};
|
|
2151
|
-
}
|
|
2152
|
-
/**
|
|
2153
|
-
* Default stringification used for the `tool` role message content when a
|
|
2154
|
-
* tool has no `toModelOutput` transform: pass through strings, JSON-encode
|
|
2155
|
-
* everything else.
|
|
2156
|
-
*/
|
|
2157
|
-
function defaultStringify(value) {
|
|
2158
|
-
return typeof value === 'string' ? value : JSON.stringify(value);
|
|
2159
|
-
}
|
|
2160
|
-
/**
|
|
2161
|
-
* Convert a tool's structured `result` into the string the **model** will
|
|
2162
|
-
* see on its next step. Honors `tool.toModelOutput` when present, falling
|
|
2163
|
-
* back to {@link defaultStringify}.
|
|
2164
|
-
*
|
|
2165
|
-
* Per R6 in the ai-loop-parity plan: a throwing `toModelOutput` MUST NOT
|
|
2166
|
-
* crash the loop. We swallow the error, route it through `onError`
|
|
2167
|
-
* middleware so it stays observable, and use the default stringification
|
|
2168
|
-
* as a safety net.
|
|
2169
|
-
*/
|
|
2170
|
-
async function applyToModelOutput(tool, result, onError) {
|
|
2171
|
-
if (tool.toModelOutput) {
|
|
2172
|
-
try {
|
|
2173
|
-
return await tool.toModelOutput(result);
|
|
2174
|
-
}
|
|
2175
|
-
catch (err) {
|
|
2176
|
-
if (onError)
|
|
2177
|
-
await onError(err);
|
|
2178
|
-
}
|
|
2179
|
-
}
|
|
2180
|
-
return defaultStringify(result);
|
|
2181
|
-
}
|
|
2182
|
-
/**
|
|
2183
|
-
* Resolve `needsApproval` for a tool call, taking into account the
|
|
2184
|
-
* client-supplied `approvedToolCallIds` / `rejectedToolCallIds` lists.
|
|
2185
|
-
*
|
|
2186
|
-
* Returns:
|
|
2187
|
-
* - `'allow'` — execute the tool normally (default; also when approved)
|
|
2188
|
-
* - `'pending'` — needsApproval is truthy and the call has not been approved
|
|
2189
|
-
* - `'rejected'` — the call appears in `rejectedToolCallIds`
|
|
2190
|
-
*/
|
|
2191
|
-
async function evaluateApproval(tool, tc, options) {
|
|
2192
|
-
const needs = tool.definition.needsApproval;
|
|
2193
|
-
const requires = typeof needs === 'function' ? await needs(tc.arguments) : !!needs;
|
|
2194
|
-
if (!requires)
|
|
2195
|
-
return 'allow';
|
|
2196
|
-
if (options?.rejectedToolCallIds?.includes(tc.id))
|
|
2197
|
-
return 'rejected';
|
|
2198
|
-
if (options?.approvedToolCallIds?.includes(tc.id))
|
|
2199
|
-
return 'allow';
|
|
2200
|
-
return 'pending';
|
|
2201
|
-
}
|
|
2202
1402
|
//# sourceMappingURL=agent.js.map
|