@rudderjs/ai 1.4.0 → 1.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/README.md +85 -7
  2. package/boost/guidelines.md +2 -2
  3. package/boost/skills/ai-tools/SKILL.md +14 -5
  4. package/dist/agent.d.ts +32 -15
  5. package/dist/agent.d.ts.map +1 -1
  6. package/dist/agent.js +411 -42
  7. package/dist/agent.js.map +1 -1
  8. package/dist/handoff.d.ts +95 -0
  9. package/dist/handoff.d.ts.map +1 -0
  10. package/dist/handoff.js +78 -0
  11. package/dist/handoff.js.map +1 -0
  12. package/dist/index.d.ts +8 -4
  13. package/dist/index.d.ts.map +1 -1
  14. package/dist/index.js +5 -1
  15. package/dist/index.js.map +1 -1
  16. package/dist/providers/anthropic.d.ts +9 -1
  17. package/dist/providers/anthropic.d.ts.map +1 -1
  18. package/dist/providers/anthropic.js +5 -5
  19. package/dist/providers/anthropic.js.map +1 -1
  20. package/dist/providers/bedrock.d.ts +60 -0
  21. package/dist/providers/bedrock.d.ts.map +1 -0
  22. package/dist/providers/bedrock.js +167 -0
  23. package/dist/providers/bedrock.js.map +1 -0
  24. package/dist/providers/openai.d.ts +5 -0
  25. package/dist/providers/openai.d.ts.map +1 -1
  26. package/dist/providers/openai.js +6 -0
  27. package/dist/providers/openai.js.map +1 -1
  28. package/dist/providers/openrouter.d.ts +43 -0
  29. package/dist/providers/openrouter.d.ts.map +1 -0
  30. package/dist/providers/openrouter.js +21 -0
  31. package/dist/providers/openrouter.js.map +1 -0
  32. package/dist/server/provider.d.ts.map +1 -1
  33. package/dist/server/provider.js +15 -0
  34. package/dist/server/provider.js.map +1 -1
  35. package/dist/sub-agent-run-store.d.ts +40 -3
  36. package/dist/sub-agent-run-store.d.ts.map +1 -1
  37. package/dist/sub-agent-run-store.js.map +1 -1
  38. package/dist/tool.d.ts +59 -0
  39. package/dist/tool.d.ts.map +1 -1
  40. package/dist/tool.js +32 -0
  41. package/dist/tool.js.map +1 -1
  42. package/dist/types.d.ts +39 -1
  43. package/dist/types.d.ts.map +1 -1
  44. package/package.json +3 -2
package/dist/agent.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import { z } from 'zod';
2
2
  import { AiRegistry } from './registry.js';
3
- import { isPauseForClientToolsChunk, pauseForClientTools, toolDefinition, toolToSchema } from './tool.js';
3
+ import { isPauseForApprovalChunk, isPauseForClientToolsChunk, pauseForApproval, pauseForClientTools, toolDefinition, toolToSchema } from './tool.js';
4
+ import { isHandoffTool } from './handoff.js';
4
5
  import { attachmentsToContentParts, getMessageText } from './attachment.js';
5
6
  import { QueuedPromptBuilder } from './queue-job.js';
6
7
  import { resolveAutoPersistSpec, runWithPersistence, runWithPersistenceStreaming, } from './conversation-persistence.js';
@@ -184,6 +185,7 @@ export class Agent {
184
185
  pendingToolCallIds: result.pendingClientToolCalls.map((tc) => tc.id),
185
186
  stepsSoFar: result.steps.length,
186
187
  tokensSoFar: result.usage?.totalTokens ?? 0,
188
+ pauseKind: 'client_tool',
187
189
  };
188
190
  await suspendable.runStore.store(subRunId, snapshot);
189
191
  yield { kind: 'subagent_paused', subRunId, pendingToolCallIds: snapshot.pendingToolCallIds };
@@ -191,6 +193,30 @@ export class Agent {
191
193
  // Unreachable — the parent loop halts iteration after the pause chunk.
192
194
  return undefined;
193
195
  }
196
+ if (suspendable &&
197
+ result.finishReason === 'tool_approval_required' &&
198
+ result.pendingApprovalToolCall) {
199
+ const subRunId = generateSubRunId();
200
+ const { toolCall: pendingCall, isClientTool } = result.pendingApprovalToolCall;
201
+ const snapshot = {
202
+ messages: buildSubAgentSnapshotMessages(userPrompt, result),
203
+ pendingToolCallIds: [pendingCall.id],
204
+ stepsSoFar: result.steps.length,
205
+ tokensSoFar: result.usage?.totalTokens ?? 0,
206
+ pauseKind: 'approval',
207
+ pendingApprovalToolCall: { toolCall: pendingCall, isClientTool },
208
+ };
209
+ await suspendable.runStore.store(subRunId, snapshot);
210
+ yield {
211
+ kind: 'subagent_paused_approval',
212
+ subRunId,
213
+ toolCall: pendingCall,
214
+ isClientTool,
215
+ };
216
+ yield pauseForApproval(pendingCall, isClientTool, subRunId);
217
+ // Unreachable — the parent loop halts iteration after the pause chunk.
218
+ return undefined;
219
+ }
194
220
  yield {
195
221
  kind: 'agent_done',
196
222
  steps: result.steps.length,
@@ -207,54 +233,96 @@ export class Agent {
207
233
  .modelOutput(modelOutput);
208
234
  }
209
235
  /**
210
- * Resume a sub-agent run that previously paused with
211
- * `pauseForClientTools` (typically from {@link Agent.asTool} with
212
- * `suspendable: { runStore }` set). Loads the snapshot, validates the
213
- * incoming tool-result ids against the pending set, and re-runs the
214
- * inner loop with those results appended.
236
+ * Resume a sub-agent run that previously paused with either
237
+ * `pauseForClientTools` (client-tool pause) or `pauseForApproval`
238
+ * (approval pause), typically from {@link Agent.asTool} with
239
+ * `suspendable: { runStore }` set. The snapshot's `pauseKind`
240
+ * (default `'client_tool'`) selects the resume contract:
215
241
  *
216
- * Returns either a `'completed'` result (the inner agent finished) or
242
+ * - **`client_tool`** `clientToolResults` must carry one entry per
243
+ * id in the snapshot's `pendingToolCallIds`. Results are appended
244
+ * to the inner-agent message history and the loop re-runs.
245
+ * - **`approval`** — `approvedToolCallIds` and/or
246
+ * `rejectedToolCallIds` must reference the single pending id.
247
+ * `clientToolResults` must be empty; the loop re-runs with the
248
+ * approval decision injected via `AgentPromptOptions`.
249
+ *
250
+ * Returns either a `'completed'` result (the inner agent finished),
217
251
  * a `'paused'` continuation pointing at a fresh `subRunId` for the
218
- * next round-trip.
252
+ * next round-trip, or stays `'paused'` if the inner loop hits another
253
+ * gate. The resume can pause on a different kind than it started on
254
+ * (e.g. an approval pause that, once approved, hits a client-tool
255
+ * pause on the next step).
219
256
  *
220
- * @example
257
+ * @example Client-tool resume
221
258
  * const r = await Agent.resumeAsTool(subRunId, browserResults, { runStore, agent: subAgent })
222
- * if (r.kind === 'completed') {
223
- * feedToolResultBackToParent(r.response.text)
224
- * } else {
225
- * emitPendingClientToolsSse(r.subRunId, r.pendingToolCallIds)
226
- * }
259
+ *
260
+ * @example Approval resume
261
+ * const r = await Agent.resumeAsTool(subRunId, [], {
262
+ * runStore, agent: subAgent,
263
+ * approvedToolCallIds: ['inner-call-id'],
264
+ * })
227
265
  */
228
266
  static async resumeAsTool(subRunId, clientToolResults, options) {
229
267
  const snapshot = await options.runStore.consume(subRunId);
230
268
  if (!snapshot) {
231
269
  throw new Error(`[RudderJS AI] resumeAsTool: subRunId "${subRunId}" expired or never existed.`);
232
270
  }
233
- // Forgery guard every incoming tool-result id must be in the pending set.
271
+ const pauseKind = snapshot.pauseKind ?? 'client_tool';
234
272
  const pending = new Set(snapshot.pendingToolCallIds);
235
- const seen = new Set();
236
- for (const r of clientToolResults) {
237
- if (!pending.has(r.toolCallId)) {
238
- throw new Error(`[RudderJS AI] resumeAsTool: toolCallId "${r.toolCallId}" was not in the pending set.`);
273
+ let messages;
274
+ const promptOpts = { toolCallStreamingMode: 'stop-on-client-tool' };
275
+ if (pauseKind === 'client_tool') {
276
+ // Forgery guard every incoming tool-result id must be in the pending set.
277
+ const seen = new Set();
278
+ for (const r of clientToolResults) {
279
+ if (!pending.has(r.toolCallId)) {
280
+ throw new Error(`[RudderJS AI] resumeAsTool: toolCallId "${r.toolCallId}" was not in the pending set.`);
281
+ }
282
+ if (seen.has(r.toolCallId)) {
283
+ throw new Error(`[RudderJS AI] resumeAsTool: duplicate result for toolCallId "${r.toolCallId}".`);
284
+ }
285
+ seen.add(r.toolCallId);
239
286
  }
240
- if (seen.has(r.toolCallId)) {
241
- throw new Error(`[RudderJS AI] resumeAsTool: duplicate result for toolCallId "${r.toolCallId}".`);
287
+ // Append client tool-result messages to the snapshot, in incoming order.
288
+ messages = [...snapshot.messages];
289
+ for (const r of clientToolResults) {
290
+ messages.push({
291
+ role: 'tool',
292
+ content: typeof r.result === 'string' ? r.result : JSON.stringify(r.result),
293
+ toolCallId: r.toolCallId,
294
+ });
242
295
  }
243
- seen.add(r.toolCallId);
244
- }
245
- // Append client tool-result messages to the snapshot, in incoming order.
246
- const messages = [...snapshot.messages];
247
- for (const r of clientToolResults) {
248
- messages.push({
249
- role: 'tool',
250
- content: typeof r.result === 'string' ? r.result : JSON.stringify(r.result),
251
- toolCallId: r.toolCallId,
252
- });
253
- }
254
- const result = await options.agent.prompt('', {
255
- messages,
256
- toolCallStreamingMode: 'stop-on-client-tool',
257
- });
296
+ }
297
+ else {
298
+ // Approval-pause resume clientToolResults must be empty; either an
299
+ // approval or a rejection must be supplied for the pending id.
300
+ if (clientToolResults.length > 0) {
301
+ throw new Error('[RudderJS AI] resumeAsTool: snapshot.pauseKind === "approval" but clientToolResults was non-empty. Pass `approvedToolCallIds` or `rejectedToolCallIds` instead.');
302
+ }
303
+ const approved = options.approvedToolCallIds ?? [];
304
+ const rejected = options.rejectedToolCallIds ?? [];
305
+ for (const id of approved) {
306
+ if (!pending.has(id)) {
307
+ throw new Error(`[RudderJS AI] resumeAsTool: approvedToolCallId "${id}" was not in the pending set.`);
308
+ }
309
+ }
310
+ for (const id of rejected) {
311
+ if (!pending.has(id)) {
312
+ throw new Error(`[RudderJS AI] resumeAsTool: rejectedToolCallId "${id}" was not in the pending set.`);
313
+ }
314
+ }
315
+ if (approved.length === 0 && rejected.length === 0) {
316
+ throw new Error('[RudderJS AI] resumeAsTool: snapshot.pauseKind === "approval" requires `approvedToolCallIds` or `rejectedToolCallIds`.');
317
+ }
318
+ messages = [...snapshot.messages];
319
+ if (approved.length > 0)
320
+ promptOpts.approvedToolCallIds = approved;
321
+ if (rejected.length > 0)
322
+ promptOpts.rejectedToolCallIds = rejected;
323
+ }
324
+ promptOpts.messages = messages;
325
+ const result = await options.agent.prompt('', promptOpts);
258
326
  if (result.finishReason === 'client_tool_calls' &&
259
327
  result.pendingClientToolCalls?.length) {
260
328
  const newSubRunId = generateSubRunId();
@@ -263,23 +331,50 @@ export class Agent {
263
331
  pendingToolCallIds: result.pendingClientToolCalls.map((tc) => tc.id),
264
332
  stepsSoFar: snapshot.stepsSoFar + result.steps.length,
265
333
  tokensSoFar: snapshot.tokensSoFar + (result.usage?.totalTokens ?? 0),
334
+ pauseKind: 'client_tool',
266
335
  ...(snapshot.meta !== undefined ? { meta: snapshot.meta } : {}),
267
336
  };
268
337
  await options.runStore.store(newSubRunId, newSnapshot);
269
338
  return {
270
339
  kind: 'paused',
271
340
  subRunId: newSubRunId,
341
+ pauseKind: 'client_tool',
272
342
  pendingToolCallIds: newSnapshot.pendingToolCallIds,
273
343
  };
274
344
  }
345
+ if (result.finishReason === 'tool_approval_required' &&
346
+ result.pendingApprovalToolCall) {
347
+ const newSubRunId = generateSubRunId();
348
+ const { toolCall: pendingCall, isClientTool } = result.pendingApprovalToolCall;
349
+ const newSnapshot = {
350
+ messages: buildResumeSnapshotMessages(messages, result),
351
+ pendingToolCallIds: [pendingCall.id],
352
+ stepsSoFar: snapshot.stepsSoFar + result.steps.length,
353
+ tokensSoFar: snapshot.tokensSoFar + (result.usage?.totalTokens ?? 0),
354
+ pauseKind: 'approval',
355
+ pendingApprovalToolCall: { toolCall: pendingCall, isClientTool },
356
+ ...(snapshot.meta !== undefined ? { meta: snapshot.meta } : {}),
357
+ };
358
+ await options.runStore.store(newSubRunId, newSnapshot);
359
+ return {
360
+ kind: 'paused',
361
+ subRunId: newSubRunId,
362
+ pauseKind: 'approval',
363
+ pendingToolCallIds: newSnapshot.pendingToolCallIds,
364
+ toolCall: pendingCall,
365
+ isClientTool,
366
+ };
367
+ }
275
368
  return { kind: 'completed', response: result };
276
369
  }
277
370
  }
278
371
  /**
279
372
  * Default projection from inner-agent stream chunks to {@link SubAgentUpdate}
280
- * events. Emits one `tool_call` per inner `tool-call` chunk; everything
373
+ * events. Emits one `tool_call` per inner `tool-call` chunk and
374
+ * `agent_pending_approval` per inner `pending-approval` chunk; everything
281
375
  * else is suppressed (the wrapping execute emits the `agent_start` /
282
- * `agent_done` bookends and the suspend path emits `subagent_paused`).
376
+ * `agent_done` bookends and the suspend paths emit `subagent_paused` /
377
+ * `subagent_paused_approval`).
283
378
  *
284
379
  * Hosts wanting different cadence (e.g. surfacing `text-delta` previews
285
380
  * or per-step usage) pass `streaming: chunk => …` and own the discriminator.
@@ -292,6 +387,13 @@ function defaultSubAgentProjector(chunk) {
292
387
  ...(chunk.toolCall.arguments ? { args: chunk.toolCall.arguments } : {}),
293
388
  };
294
389
  }
390
+ if (chunk.type === 'pending-approval' && chunk.toolCall && chunk.toolCall.id && chunk.toolCall.name) {
391
+ return {
392
+ kind: 'agent_pending_approval',
393
+ toolCall: chunk.toolCall,
394
+ isClientTool: !!chunk.isClientTool,
395
+ };
396
+ }
295
397
  return null;
296
398
  }
297
399
  /**
@@ -753,6 +855,12 @@ function buildAgentResponse(loopCtx) {
753
855
  result.pendingApprovalToolCall = loopCtx.pendingApprovalToolCall;
754
856
  if (loopCtx.resumedToolMessages.length > 0)
755
857
  result.resumedToolMessages = loopCtx.resumedToolMessages;
858
+ // Internal — consumed by the handoff-aware wrapper, then stripped before
859
+ // surfacing to public callers.
860
+ if (loopCtx.pendingHandoff) {
861
+ result._pendingHandoff = loopCtx.pendingHandoff;
862
+ result._carriedMessages = loopCtx.messages;
863
+ }
756
864
  return result;
757
865
  }
758
866
  /**
@@ -775,7 +883,15 @@ async function* executeToolPhase(loopCtx, toolCalls, assistantMessage) {
775
883
  // agent-level override which defaults to `true`. Single-tool batches
776
884
  // route through the serial path either way (no parallelism to gain, and
777
885
  // serial preserves live `tool-update` streaming for that one tool).
778
- const parallel = (options?.parallelTools ?? loopCtx.agent.parallelTools()) && toolCalls.length > 1;
886
+ //
887
+ // Handoffs always force serial dispatch — the parent loop has to halt
888
+ // immediately on the first handoff and synthesize "skipped" results for
889
+ // any sibling calls. Handling that across the parallel classify/replay
890
+ // phases is doable but adds complexity for negligible benefit (the model
891
+ // rarely emits parallel siblings alongside a handoff, and even then,
892
+ // running them while the agent is being torn down is wasted work).
893
+ const hasHandoff = toolCalls.some(tc => isHandoffTool(loopCtx.toolMap.get(tc.name)));
894
+ const parallel = (options?.parallelTools ?? loopCtx.agent.parallelTools()) && toolCalls.length > 1 && !hasHandoff;
779
895
  if (parallel) {
780
896
  yield* runToolPhaseParallel(loopCtx, toolCalls, toolResults);
781
897
  }
@@ -804,6 +920,50 @@ async function* runToolPhaseSerial(loopCtx, toolCalls, toolResults) {
804
920
  yield { type: 'tool-result', toolCall: tc, result: unknownResult };
805
921
  continue;
806
922
  }
923
+ // Handoff — detected before the no-execute (client tool) branch because
924
+ // a handoff tool also has no `execute`, but it has wholly different
925
+ // semantics: pivot control to a new agent instead of pausing for the
926
+ // browser. The first handoff in a step wins; any subsequent tool calls
927
+ // in the same step are skipped with a synthetic "skipped: handed off"
928
+ // tool result so the message log stays well-formed for replay.
929
+ if (loopCtx.stopForHandoff) {
930
+ const skippedResult = 'Skipped: parent agent handed off to another agent.';
931
+ toolResults.push({ toolCallId: tc.id, result: skippedResult });
932
+ messages.push({ role: 'tool', content: skippedResult, toolCallId: tc.id });
933
+ yield { type: 'tool-call', toolCall: tc };
934
+ yield { type: 'tool-result', toolCall: tc, result: skippedResult };
935
+ continue;
936
+ }
937
+ if (isHandoffTool(tool)) {
938
+ const spec = tool.__handoffSpec;
939
+ const validation = validateToolArgs(tool, tc.arguments);
940
+ // Handoff payload defaults to `{ message: string }`; custom schemas
941
+ // are accepted but the loop only uses `args.message` (string) as the
942
+ // transition prompt. Anything else surfaces in the conversation as
943
+ // the args of the synthetic tool-call.
944
+ const args = validation.ok ? validation.value : tc.arguments;
945
+ const transitionMessage = typeof args['message'] === 'string' ? args['message'] : '';
946
+ const handoffResult = `Handed off to ${spec.AgentClass.name}.`;
947
+ toolResults.push({ toolCallId: tc.id, result: handoffResult });
948
+ messages.push({ role: 'tool', content: handoffResult, toolCallId: tc.id });
949
+ yield { type: 'tool-call', toolCall: tc };
950
+ yield { type: 'tool-result', toolCall: tc, result: handoffResult };
951
+ yield {
952
+ type: 'handoff',
953
+ handoff: {
954
+ from: loopCtx.agent.constructor.name,
955
+ to: spec.AgentClass.name,
956
+ ...(transitionMessage ? { message: transitionMessage } : {}),
957
+ },
958
+ };
959
+ loopCtx.pendingHandoff = { spec, transitionMessage, parentToolCallId: tc.id };
960
+ loopCtx.stopForHandoff = true;
961
+ // Do NOT break — keep iterating so any sibling tool calls in this
962
+ // step get their synthetic "skipped" tool results before the loop
963
+ // exits. This preserves message-log invariants for downstream
964
+ // persistence.
965
+ continue;
966
+ }
807
967
  if (!tool.execute) {
808
968
  // Client tool — no server-side handler.
809
969
  if (options?.toolCallStreamingMode === 'stop-on-client-tool') {
@@ -905,6 +1065,16 @@ async function* runToolPhaseSerial(loopCtx, toolCalls, toolResults) {
905
1065
  paused = true;
906
1066
  break;
907
1067
  }
1068
+ if (isPauseForApprovalChunk(step.value)) {
1069
+ loopCtx.pendingApprovalToolCall = {
1070
+ toolCall: step.value.toolCall,
1071
+ isClientTool: step.value.isClientTool,
1072
+ };
1073
+ loopCtx.loopFinishReason = 'tool_approval_required';
1074
+ loopCtx.stopForApproval = true;
1075
+ paused = true;
1076
+ break;
1077
+ }
908
1078
  const updateChunk = { type: 'tool-update', toolCall: tc, update: step.value };
909
1079
  if (middlewares.length > 0) {
910
1080
  const transformed = runOnChunk(middlewares, ctx, updateChunk);
@@ -1156,6 +1326,16 @@ async function runToolExecution(loopCtx, outcome) {
1156
1326
  paused = true;
1157
1327
  break;
1158
1328
  }
1329
+ if (isPauseForApprovalChunk(step.value)) {
1330
+ loopCtx.pendingApprovalToolCall = {
1331
+ toolCall: step.value.toolCall,
1332
+ isClientTool: step.value.isClientTool,
1333
+ };
1334
+ loopCtx.loopFinishReason = 'tool_approval_required';
1335
+ loopCtx.stopForApproval = true;
1336
+ paused = true;
1337
+ break;
1338
+ }
1159
1339
  const updateChunk = { type: 'tool-update', toolCall: outcome.tc, update: step.value };
1160
1340
  if (middlewares.length > 0) {
1161
1341
  const transformed = runOnChunk(middlewares, ctx, updateChunk);
@@ -1228,6 +1408,7 @@ async function initializeLoop(a, input, options) {
1228
1408
  stopForApproval: false,
1229
1409
  resumedToolMessages: [],
1230
1410
  failoverAttempts: 0,
1411
+ stopForHandoff: false,
1231
1412
  };
1232
1413
  // Resume server tools left pending by a previous approval round-trip.
1233
1414
  {
@@ -1289,7 +1470,195 @@ async function runIterationPrelude(loopCtx, iteration) {
1289
1470
  return { currentModel };
1290
1471
  }
1291
1472
  // ─── Agent Loop (non-streaming) ──────────────────────────
1473
+ /**
1474
+ * Hard ceiling for the number of agent-to-agent handoffs in a single
1475
+ * `prompt()` / `stream()` call. Most workflows hop once or twice (triage →
1476
+ * specialist). Anything beyond this almost certainly means the agents are
1477
+ * cycling — surfacing a clear error beats silently looping until token
1478
+ * budgets explode.
1479
+ */
1480
+ const MAX_HANDOFFS = 5;
1481
+ /**
1482
+ * Public entry point for the non-streaming agent loop. Drives
1483
+ * {@link runAgentLoopOnce} once, then — if the model called a {@link handoff}
1484
+ * tool — constructs the target agent, carries the conversation forward, and
1485
+ * recurses. Steps and usage from each hop are merged; the final `text` and
1486
+ * `finishReason` come from the agent that produced the terminal answer.
1487
+ * `handoffPath` records the chain of class names traversed.
1488
+ */
1292
1489
  async function runAgentLoop(a, input, options) {
1490
+ const onceResult = await runAgentLoopOnce(a, input, options);
1491
+ if (!onceResult._pendingHandoff) {
1492
+ return stripInternal(onceResult);
1493
+ }
1494
+ const merged = await driveHandoffs(a.constructor.name, onceResult, onceResult._pendingHandoff, onceResult._carriedMessages ?? [], options, 0);
1495
+ return merged;
1496
+ }
1497
+ /**
1498
+ * Streaming counterpart to {@link runAgentLoop}. Iterates handoffs and
1499
+ * pivots the stream to the next agent each time the parent ends with a
1500
+ * pending handoff. Chunks from every hop flow through the same returned
1501
+ * `AsyncIterable`; the resolved `response` carries the merged final state.
1502
+ */
1503
+ function runAgentLoopStreaming(a, input, options) {
1504
+ let resolveResponse;
1505
+ let rejectResponse;
1506
+ const responsePromise = new Promise((resolve, reject) => {
1507
+ resolveResponse = resolve;
1508
+ rejectResponse = reject;
1509
+ });
1510
+ async function* generateStream() {
1511
+ let currentAgent = a;
1512
+ let currentInput = input;
1513
+ let currentOpts = options;
1514
+ const mergedSteps = [];
1515
+ const mergedUsage = { promptTokens: 0, completionTokens: 0, totalTokens: 0 };
1516
+ const handoffPath = [];
1517
+ let finalResponse;
1518
+ for (let hop = 0; hop <= MAX_HANDOFFS; hop++) {
1519
+ const onceStream = runAgentLoopStreamingOnce(currentAgent, currentInput, currentOpts);
1520
+ // Attach a no-op handler so a rejection from the inner response
1521
+ // promise (e.g. caller-supplied AbortSignal firing mid-stream) is
1522
+ // already observed by the time the `for await` re-throws — without
1523
+ // this, Node logs an unhandledRejection between the stream's throw
1524
+ // and our outer `withRejectOnError`'s catch.
1525
+ onceStream.response.catch(() => { });
1526
+ for await (const chunk of onceStream.stream)
1527
+ yield chunk;
1528
+ const r = await onceStream.response;
1529
+ mergedSteps.push(...r.steps);
1530
+ addUsage(mergedUsage, r.usage);
1531
+ if (r._pendingHandoff && hop < MAX_HANDOFFS) {
1532
+ handoffPath.push(currentAgent.constructor.name);
1533
+ const ChildClass = r._pendingHandoff.spec.AgentClass;
1534
+ currentAgent = new ChildClass();
1535
+ currentInput = r._pendingHandoff.transitionMessage;
1536
+ currentOpts = buildHandoffChildOptions(options, r._carriedMessages ?? []);
1537
+ continue;
1538
+ }
1539
+ if (r._pendingHandoff) {
1540
+ throw new Error(`[RudderJS AI] Exceeded max handoffs (${MAX_HANDOFFS}). Likely a cycle between agents.`);
1541
+ }
1542
+ finalResponse = handoffPath.length === 0
1543
+ ? stripInternal(r)
1544
+ : mergeFinalHandoff(stripInternal(r), mergedSteps, mergedUsage, handoffPath, currentAgent.constructor.name);
1545
+ break;
1546
+ }
1547
+ if (!finalResponse) {
1548
+ throw new Error(`[RudderJS AI] Exceeded max handoffs (${MAX_HANDOFFS}). Likely a cycle between agents.`);
1549
+ }
1550
+ resolveResponse(finalResponse);
1551
+ }
1552
+ async function* withRejectOnError() {
1553
+ try {
1554
+ yield* generateStream();
1555
+ }
1556
+ catch (err) {
1557
+ rejectResponse(err);
1558
+ throw err;
1559
+ }
1560
+ }
1561
+ return {
1562
+ stream: withRejectOnError(),
1563
+ response: responsePromise,
1564
+ };
1565
+ }
1566
+ /**
1567
+ * Iteratively drive pending handoffs, carrying steps + usage forward.
1568
+ * Used by the non-streaming path. (Streaming has its own iterative driver
1569
+ * inline in {@link runAgentLoopStreaming} so chunks can flow as each hop's
1570
+ * loop runs.)
1571
+ */
1572
+ async function driveHandoffs(rootName, rootResult, pending, carriedMessages, origOptions, startHopCount) {
1573
+ const mergedSteps = [...rootResult.steps];
1574
+ const mergedUsage = { promptTokens: 0, completionTokens: 0, totalTokens: 0 };
1575
+ addUsage(mergedUsage, rootResult.usage);
1576
+ const handoffPath = [rootName];
1577
+ let currentPending = pending;
1578
+ let currentCarried = carriedMessages;
1579
+ let hopCount = startHopCount;
1580
+ for (;;) {
1581
+ if (hopCount >= MAX_HANDOFFS) {
1582
+ throw new Error(`[RudderJS AI] Exceeded max handoffs (${MAX_HANDOFFS}). Likely a cycle between agents.`);
1583
+ }
1584
+ const ChildClass = currentPending.spec.AgentClass;
1585
+ handoffPath.push(ChildClass.name);
1586
+ const child = new ChildClass();
1587
+ const childOpts = buildHandoffChildOptions(origOptions, currentCarried);
1588
+ const childOnce = await runAgentLoopOnce(child, currentPending.transitionMessage, childOpts);
1589
+ mergedSteps.push(...childOnce.steps);
1590
+ addUsage(mergedUsage, childOnce.usage);
1591
+ if (childOnce._pendingHandoff) {
1592
+ currentPending = childOnce._pendingHandoff;
1593
+ currentCarried = childOnce._carriedMessages ?? [];
1594
+ hopCount++;
1595
+ continue;
1596
+ }
1597
+ return {
1598
+ ...stripInternal(childOnce),
1599
+ steps: mergedSteps,
1600
+ usage: mergedUsage,
1601
+ handoffPath,
1602
+ };
1603
+ }
1604
+ }
1605
+ /** Merge the terminal hop's response with carried steps / usage / path. */
1606
+ function mergeFinalHandoff(terminal, mergedSteps, mergedUsage, pathPrefix, terminalName) {
1607
+ return {
1608
+ ...terminal,
1609
+ steps: mergedSteps,
1610
+ usage: mergedUsage,
1611
+ handoffPath: [...pathPrefix, terminalName],
1612
+ };
1613
+ }
1614
+ /**
1615
+ * Build the {@link AgentPromptOptions} for a child agent invoked via
1616
+ * handoff. The parent's carried message log replaces the child's input
1617
+ * (so the child sees the full conversation up to the handoff point) but
1618
+ * the child still prepends its own `instructions()` as the system message
1619
+ * during {@link initializeLoop}, so we drop the parent's leading system
1620
+ * message to avoid double-prefixing.
1621
+ *
1622
+ * Per-call options that make sense to carry across (signal, attachments,
1623
+ * tool/middleware overrides) are preserved; `messages` and `history` are
1624
+ * deliberately overridden.
1625
+ */
1626
+ function buildHandoffChildOptions(parentOptions, carriedMessages) {
1627
+ const stripped = carriedMessages.length > 0 && carriedMessages[0]?.role === 'system'
1628
+ ? carriedMessages.slice(1)
1629
+ : carriedMessages;
1630
+ // We append the model's transition message as the next user message so
1631
+ // the child has something concrete to respond to (it's also passed as
1632
+ // `currentInput` below — but feeding it via `messages` mode keeps the
1633
+ // history coherent and prevents `initializeLoop` from also prepending
1634
+ // an `input` user message).
1635
+ return {
1636
+ ...(parentOptions ?? {}),
1637
+ messages: stripped,
1638
+ };
1639
+ }
1640
+ /** Strip the internal `_pendingHandoff` / `_carriedMessages` fields before surfacing the response to public callers. */
1641
+ function stripInternal(r) {
1642
+ const out = {
1643
+ text: r.text,
1644
+ steps: r.steps,
1645
+ usage: r.usage,
1646
+ };
1647
+ if (r.conversationId !== undefined)
1648
+ out.conversationId = r.conversationId;
1649
+ if (r.finishReason !== undefined)
1650
+ out.finishReason = r.finishReason;
1651
+ if (r.pendingClientToolCalls !== undefined)
1652
+ out.pendingClientToolCalls = r.pendingClientToolCalls;
1653
+ if (r.pendingApprovalToolCall !== undefined)
1654
+ out.pendingApprovalToolCall = r.pendingApprovalToolCall;
1655
+ if (r.resumedToolMessages !== undefined)
1656
+ out.resumedToolMessages = r.resumedToolMessages;
1657
+ if (r.handoffPath !== undefined)
1658
+ out.handoffPath = r.handoffPath;
1659
+ return out;
1660
+ }
1661
+ async function runAgentLoopOnce(a, input, options) {
1293
1662
  const { loopCtx, stopConditions } = await initializeLoop(a, input, options);
1294
1663
  const { ctx, middlewares, messages, steps, totalUsage } = loopCtx;
1295
1664
  try {
@@ -1333,7 +1702,7 @@ async function runAgentLoop(a, input, options) {
1333
1702
  };
1334
1703
  steps.push(step);
1335
1704
  emitObserverStepCompleted(loopCtx, iteration, false);
1336
- if (loopCtx.stopForClientTools || loopCtx.stopForApproval)
1705
+ if (loopCtx.stopForClientTools || loopCtx.stopForApproval || loopCtx.stopForHandoff)
1337
1706
  break;
1338
1707
  const shouldStop = stopConditions.some(cond => cond({ steps, iteration, lastMessage: response.message }));
1339
1708
  if (shouldStop || response.finishReason !== 'tool_calls') {
@@ -1357,7 +1726,7 @@ async function runAgentLoop(a, input, options) {
1357
1726
  return result;
1358
1727
  }
1359
1728
  // ─── Agent Loop (streaming) ──────────────────────────────
1360
- function runAgentLoopStreaming(a, input, options) {
1729
+ function runAgentLoopStreamingOnce(a, input, options) {
1361
1730
  let resolveResponse;
1362
1731
  let rejectResponse;
1363
1732
  const responsePromise = new Promise((resolve, reject) => {
@@ -1463,7 +1832,7 @@ function runAgentLoopStreaming(a, input, options) {
1463
1832
  };
1464
1833
  steps.push(step);
1465
1834
  emitObserverStepCompleted(loopCtx, iteration, true);
1466
- if (loopCtx.stopForClientTools || loopCtx.stopForApproval)
1835
+ if (loopCtx.stopForClientTools || loopCtx.stopForApproval || loopCtx.stopForHandoff)
1467
1836
  break;
1468
1837
  const shouldStop = stopConditions.some(cond => cond({ steps, iteration, lastMessage: step.message }));
1469
1838
  if (shouldStop || finishReason !== 'tool_calls')