@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/dist/agent.js CHANGED
@@ -1,14 +1,16 @@
1
1
  import { z } from 'zod';
2
2
  import { AiRegistry } from './registry.js';
3
- import { isPauseForApprovalChunk, isPauseForClientToolsChunk, pauseForApproval, pauseForClientTools, toolDefinition, toolToSchema } from './tool.js';
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, runOnBeforeToolCall, runOnAfterToolCall, runSequential, runOnUsage, runOnAbort, runOnError, } from './middleware.js';
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 'tools' in a && typeof a.tools === 'function'
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 = 'middleware' in a && typeof a.middleware === 'function'
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 { type: 'pending-approval', toolCall: loopCtx.pendingApprovalToolCall.toolCall, isClientTool: loopCtx.pendingApprovalToolCall.isClientTool };
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