@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.
- package/README.md +85 -7
- package/boost/guidelines.md +2 -2
- package/boost/skills/ai-tools/SKILL.md +14 -5
- package/dist/agent.d.ts +32 -15
- package/dist/agent.d.ts.map +1 -1
- package/dist/agent.js +411 -42
- package/dist/agent.js.map +1 -1
- package/dist/handoff.d.ts +95 -0
- package/dist/handoff.d.ts.map +1 -0
- package/dist/handoff.js +78 -0
- package/dist/handoff.js.map +1 -0
- package/dist/index.d.ts +8 -4
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +5 -1
- package/dist/index.js.map +1 -1
- package/dist/providers/anthropic.d.ts +9 -1
- package/dist/providers/anthropic.d.ts.map +1 -1
- package/dist/providers/anthropic.js +5 -5
- package/dist/providers/anthropic.js.map +1 -1
- package/dist/providers/bedrock.d.ts +60 -0
- package/dist/providers/bedrock.d.ts.map +1 -0
- package/dist/providers/bedrock.js +167 -0
- package/dist/providers/bedrock.js.map +1 -0
- package/dist/providers/openai.d.ts +5 -0
- package/dist/providers/openai.d.ts.map +1 -1
- package/dist/providers/openai.js +6 -0
- package/dist/providers/openai.js.map +1 -1
- package/dist/providers/openrouter.d.ts +43 -0
- package/dist/providers/openrouter.d.ts.map +1 -0
- package/dist/providers/openrouter.js +21 -0
- package/dist/providers/openrouter.js.map +1 -0
- package/dist/server/provider.d.ts.map +1 -1
- package/dist/server/provider.js +15 -0
- package/dist/server/provider.js.map +1 -1
- package/dist/sub-agent-run-store.d.ts +40 -3
- package/dist/sub-agent-run-store.d.ts.map +1 -1
- package/dist/sub-agent-run-store.js.map +1 -1
- package/dist/tool.d.ts +59 -0
- package/dist/tool.d.ts.map +1 -1
- package/dist/tool.js +32 -0
- package/dist/tool.js.map +1 -1
- package/dist/types.d.ts +39 -1
- package/dist/types.d.ts.map +1 -1
- 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` (
|
|
212
|
-
*
|
|
213
|
-
*
|
|
214
|
-
*
|
|
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
|
-
*
|
|
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
|
-
*
|
|
223
|
-
*
|
|
224
|
-
*
|
|
225
|
-
*
|
|
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
|
-
|
|
271
|
+
const pauseKind = snapshot.pauseKind ?? 'client_tool';
|
|
234
272
|
const pending = new Set(snapshot.pendingToolCallIds);
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
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
|
-
|
|
241
|
-
|
|
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
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
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')
|