@rudderjs/ai 1.4.0 → 1.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (179) hide show
  1. package/README.md +484 -7
  2. package/boost/guidelines.md +62 -2
  3. package/boost/skills/ai-tools/SKILL.md +14 -5
  4. package/dist/agent.d.ts +66 -15
  5. package/dist/agent.d.ts.map +1 -1
  6. package/dist/agent.js +529 -58
  7. package/dist/agent.js.map +1 -1
  8. package/dist/budget/pricing.d.ts +124 -0
  9. package/dist/budget/pricing.d.ts.map +1 -0
  10. package/dist/budget/pricing.js +175 -0
  11. package/dist/budget/pricing.js.map +1 -0
  12. package/dist/budget/storage.d.ts +104 -0
  13. package/dist/budget/storage.d.ts.map +1 -0
  14. package/dist/budget/storage.js +0 -0
  15. package/dist/budget/storage.js.map +1 -0
  16. package/dist/budget/with-budget.d.ts +119 -0
  17. package/dist/budget/with-budget.d.ts.map +1 -0
  18. package/dist/budget/with-budget.js +175 -0
  19. package/dist/budget/with-budget.js.map +1 -0
  20. package/dist/budget-orm/index.d.ts +96 -0
  21. package/dist/budget-orm/index.d.ts.map +1 -0
  22. package/dist/budget-orm/index.js +177 -0
  23. package/dist/budget-orm/index.js.map +1 -0
  24. package/dist/commands/ai-eval.d.ts +93 -0
  25. package/dist/commands/ai-eval.d.ts.map +1 -0
  26. package/dist/commands/ai-eval.js +378 -0
  27. package/dist/commands/ai-eval.js.map +1 -0
  28. package/dist/computer-use/actions.d.ts +214 -0
  29. package/dist/computer-use/actions.d.ts.map +1 -0
  30. package/dist/computer-use/actions.js +48 -0
  31. package/dist/computer-use/actions.js.map +1 -0
  32. package/dist/computer-use/errors.d.ts +57 -0
  33. package/dist/computer-use/errors.d.ts.map +1 -0
  34. package/dist/computer-use/errors.js +76 -0
  35. package/dist/computer-use/errors.js.map +1 -0
  36. package/dist/computer-use/index.d.ts +53 -0
  37. package/dist/computer-use/index.d.ts.map +1 -0
  38. package/dist/computer-use/index.js +51 -0
  39. package/dist/computer-use/index.js.map +1 -0
  40. package/dist/computer-use/playwright.d.ts +76 -0
  41. package/dist/computer-use/playwright.d.ts.map +1 -0
  42. package/dist/computer-use/playwright.js +270 -0
  43. package/dist/computer-use/playwright.js.map +1 -0
  44. package/dist/computer-use/tool.d.ts +154 -0
  45. package/dist/computer-use/tool.d.ts.map +1 -0
  46. package/dist/computer-use/tool.js +210 -0
  47. package/dist/computer-use/tool.js.map +1 -0
  48. package/dist/eval/fixtures.d.ts +65 -0
  49. package/dist/eval/fixtures.d.ts.map +1 -0
  50. package/dist/eval/fixtures.js +110 -0
  51. package/dist/eval/fixtures.js.map +1 -0
  52. package/dist/eval/html-reporter.d.ts +25 -0
  53. package/dist/eval/html-reporter.d.ts.map +1 -0
  54. package/dist/eval/html-reporter.js +209 -0
  55. package/dist/eval/html-reporter.js.map +1 -0
  56. package/dist/eval/index.d.ts +271 -0
  57. package/dist/eval/index.d.ts.map +1 -0
  58. package/dist/eval/index.js +510 -0
  59. package/dist/eval/index.js.map +1 -0
  60. package/dist/eval/json-reporter.d.ts +43 -0
  61. package/dist/eval/json-reporter.d.ts.map +1 -0
  62. package/dist/eval/json-reporter.js +40 -0
  63. package/dist/eval/json-reporter.js.map +1 -0
  64. package/dist/fake.d.ts +36 -1
  65. package/dist/fake.d.ts.map +1 -1
  66. package/dist/fake.js +49 -2
  67. package/dist/fake.js.map +1 -1
  68. package/dist/file-search.d.ts +168 -0
  69. package/dist/file-search.d.ts.map +1 -0
  70. package/dist/file-search.js +158 -0
  71. package/dist/file-search.js.map +1 -0
  72. package/dist/handoff.d.ts +95 -0
  73. package/dist/handoff.d.ts.map +1 -0
  74. package/dist/handoff.js +78 -0
  75. package/dist/handoff.js.map +1 -0
  76. package/dist/index.d.ts +29 -5
  77. package/dist/index.d.ts.map +1 -1
  78. package/dist/index.js +22 -2
  79. package/dist/index.js.map +1 -1
  80. package/dist/mcp/client-tools.d.ts +39 -0
  81. package/dist/mcp/client-tools.d.ts.map +1 -0
  82. package/dist/mcp/client-tools.js +147 -0
  83. package/dist/mcp/client-tools.js.map +1 -0
  84. package/dist/mcp/index.d.ts +16 -0
  85. package/dist/mcp/index.d.ts.map +1 -0
  86. package/dist/mcp/index.js +15 -0
  87. package/dist/mcp/index.js.map +1 -0
  88. package/dist/mcp/server-from-agent.d.ts +24 -0
  89. package/dist/mcp/server-from-agent.d.ts.map +1 -0
  90. package/dist/mcp/server-from-agent.js +113 -0
  91. package/dist/mcp/server-from-agent.js.map +1 -0
  92. package/dist/mcp/types.d.ts +64 -0
  93. package/dist/mcp/types.d.ts.map +1 -0
  94. package/dist/mcp/types.js +6 -0
  95. package/dist/mcp/types.js.map +1 -0
  96. package/dist/memory-embedding/index.d.ts +121 -0
  97. package/dist/memory-embedding/index.d.ts.map +1 -0
  98. package/dist/memory-embedding/index.js +229 -0
  99. package/dist/memory-embedding/index.js.map +1 -0
  100. package/dist/memory-extract.d.ts +60 -0
  101. package/dist/memory-extract.d.ts.map +1 -0
  102. package/dist/memory-extract.js +163 -0
  103. package/dist/memory-extract.js.map +1 -0
  104. package/dist/memory-inject.d.ts +39 -0
  105. package/dist/memory-inject.d.ts.map +1 -0
  106. package/dist/memory-inject.js +135 -0
  107. package/dist/memory-inject.js.map +1 -0
  108. package/dist/memory-orm/index.d.ts +118 -0
  109. package/dist/memory-orm/index.d.ts.map +1 -0
  110. package/dist/memory-orm/index.js +187 -0
  111. package/dist/memory-orm/index.js.map +1 -0
  112. package/dist/memory.d.ts +55 -0
  113. package/dist/memory.d.ts.map +1 -0
  114. package/dist/memory.js +132 -0
  115. package/dist/memory.js.map +1 -0
  116. package/dist/observers.d.ts +22 -0
  117. package/dist/observers.d.ts.map +1 -1
  118. package/dist/observers.js.map +1 -1
  119. package/dist/provider-tools.d.ts +15 -1
  120. package/dist/provider-tools.d.ts.map +1 -1
  121. package/dist/provider-tools.js +21 -1
  122. package/dist/provider-tools.js.map +1 -1
  123. package/dist/providers/anthropic.d.ts +9 -1
  124. package/dist/providers/anthropic.d.ts.map +1 -1
  125. package/dist/providers/anthropic.js +66 -11
  126. package/dist/providers/anthropic.js.map +1 -1
  127. package/dist/providers/bedrock.d.ts +60 -0
  128. package/dist/providers/bedrock.d.ts.map +1 -0
  129. package/dist/providers/bedrock.js +167 -0
  130. package/dist/providers/bedrock.js.map +1 -0
  131. package/dist/providers/elevenlabs.d.ts +98 -0
  132. package/dist/providers/elevenlabs.d.ts.map +1 -0
  133. package/dist/providers/elevenlabs.js +229 -0
  134. package/dist/providers/elevenlabs.js.map +1 -0
  135. package/dist/providers/google.d.ts +83 -1
  136. package/dist/providers/google.d.ts.map +1 -1
  137. package/dist/providers/google.js +491 -8
  138. package/dist/providers/google.js.map +1 -1
  139. package/dist/providers/openai.d.ts +8 -1
  140. package/dist/providers/openai.d.ts.map +1 -1
  141. package/dist/providers/openai.js +215 -5
  142. package/dist/providers/openai.js.map +1 -1
  143. package/dist/providers/openrouter.d.ts +43 -0
  144. package/dist/providers/openrouter.d.ts.map +1 -0
  145. package/dist/providers/openrouter.js +21 -0
  146. package/dist/providers/openrouter.js.map +1 -0
  147. package/dist/providers/voyage.d.ts +91 -0
  148. package/dist/providers/voyage.d.ts.map +1 -0
  149. package/dist/providers/voyage.js +166 -0
  150. package/dist/providers/voyage.js.map +1 -0
  151. package/dist/queue-job.d.ts +69 -4
  152. package/dist/queue-job.d.ts.map +1 -1
  153. package/dist/queue-job.js +114 -11
  154. package/dist/queue-job.js.map +1 -1
  155. package/dist/registry.d.ts +3 -1
  156. package/dist/registry.d.ts.map +1 -1
  157. package/dist/registry.js +10 -0
  158. package/dist/registry.js.map +1 -1
  159. package/dist/server/provider.d.ts.map +1 -1
  160. package/dist/server/provider.js +38 -1
  161. package/dist/server/provider.js.map +1 -1
  162. package/dist/similarity-search.d.ts +163 -0
  163. package/dist/similarity-search.d.ts.map +1 -0
  164. package/dist/similarity-search.js +147 -0
  165. package/dist/similarity-search.js.map +1 -0
  166. package/dist/sub-agent-run-store.d.ts +40 -3
  167. package/dist/sub-agent-run-store.d.ts.map +1 -1
  168. package/dist/sub-agent-run-store.js.map +1 -1
  169. package/dist/tool.d.ts +59 -0
  170. package/dist/tool.d.ts.map +1 -1
  171. package/dist/tool.js +45 -4
  172. package/dist/tool.js.map +1 -1
  173. package/dist/types.d.ts +285 -1
  174. package/dist/types.d.ts.map +1 -1
  175. package/dist/vector-stores/index.d.ts +96 -0
  176. package/dist/vector-stores/index.d.ts.map +1 -0
  177. package/dist/vector-stores/index.js +153 -0
  178. package/dist/vector-stores/index.js.map +1 -0
  179. package/package.json +43 -4
package/dist/agent.js CHANGED
@@ -1,9 +1,13 @@
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';
8
+ import { resolveRemembersSpec } from './memory.js';
9
+ import { withMemoryInject } from './memory-inject.js';
10
+ import { withMemoryExtract } from './memory-extract.js';
7
11
  import { runOnConfig, runOnChunk, runOnBeforeToolCall, runOnAfterToolCall, runSequential, runOnUsage, runOnAbort, runOnError, } from './middleware.js';
8
12
  // ─── AI Observer (lazy accessor) ─────────────────────────
9
13
  function _getAiObservers() {
@@ -110,6 +114,33 @@ export class Agent {
110
114
  conversational() {
111
115
  return false;
112
116
  }
117
+ /**
118
+ * Opt this agent class into per-user memory beyond conversation history
119
+ * (#A4). Returns a {@link RemembersSpec} naming the user whose memory
120
+ * the agent reads/writes, and how injection / extraction should behave.
121
+ * Returning `false` (the default) leaves the agent memory-stateless.
122
+ *
123
+ * Phase 1 wires the declaration + the per-call precedence chain so
124
+ * apps and downstream phases (auto-inject middleware in Phase 2,
125
+ * auto-extract middleware in Phase 3) can read a consistent spec.
126
+ * Calling this method directly today produces no runtime behavior
127
+ * unless application code reads it via `resolveRemembersSpec()`.
128
+ *
129
+ * **Precedence (high → low):**
130
+ * 1. Per-call `prompt(input, { memory: false | {...} })`
131
+ * 2. This method's return value
132
+ *
133
+ * Async returns are supported — useful when the user identity is fetched
134
+ * from an async DI binding.
135
+ *
136
+ * @example
137
+ * class SupportAgent extends Agent {
138
+ * remembers() { return { user: ctx.user.id, inject: 'auto', tags: ['support'] } }
139
+ * }
140
+ */
141
+ remembers() {
142
+ return false;
143
+ }
113
144
  /**
114
145
  * Default for `AgentPromptOptions.parallelTools`. When `true` (default),
115
146
  * multiple tool calls within a single step run their `execute()` functions
@@ -119,11 +150,17 @@ export class Agent {
119
150
  parallelTools() { return true; }
120
151
  /** Run the agent with a prompt (non-streaming) */
121
152
  async prompt(input, options) {
122
- const spec = await resolveAutoPersistSpec(() => this.conversational(), options?.conversation);
153
+ // Memory auto-cascade appends inject (Phase 2) + extract (Phase 3)
154
+ // middlewares when `Agent.remembers()` opts in. Runs BEFORE
155
+ // conversation persistence so the persisted history flows in
156
+ // unchanged: inject only grows the system message; extract only
157
+ // fires onFinish.
158
+ const effOptions = await prepareOptionsWithMemoryAutoCascade(this, options);
159
+ const spec = await resolveAutoPersistSpec(() => this.conversational(), effOptions?.conversation);
123
160
  if (spec) {
124
- return runWithPersistence(spec, this.constructor.name, resolveConversationStore, input, options, (effOptions) => runAgentLoop(this, input, effOptions));
161
+ return runWithPersistence(spec, this.constructor.name, resolveConversationStore, input, effOptions, (innerOptions) => runAgentLoop(this, input, innerOptions));
125
162
  }
126
- return runAgentLoop(this, input, options);
163
+ return runAgentLoop(this, input, effOptions);
127
164
  }
128
165
  /** Run the agent with a prompt (streaming) */
129
166
  stream(input, options) {
@@ -184,6 +221,7 @@ export class Agent {
184
221
  pendingToolCallIds: result.pendingClientToolCalls.map((tc) => tc.id),
185
222
  stepsSoFar: result.steps.length,
186
223
  tokensSoFar: result.usage?.totalTokens ?? 0,
224
+ pauseKind: 'client_tool',
187
225
  };
188
226
  await suspendable.runStore.store(subRunId, snapshot);
189
227
  yield { kind: 'subagent_paused', subRunId, pendingToolCallIds: snapshot.pendingToolCallIds };
@@ -191,6 +229,30 @@ export class Agent {
191
229
  // Unreachable — the parent loop halts iteration after the pause chunk.
192
230
  return undefined;
193
231
  }
232
+ if (suspendable &&
233
+ result.finishReason === 'tool_approval_required' &&
234
+ result.pendingApprovalToolCall) {
235
+ const subRunId = generateSubRunId();
236
+ const { toolCall: pendingCall, isClientTool } = result.pendingApprovalToolCall;
237
+ const snapshot = {
238
+ messages: buildSubAgentSnapshotMessages(userPrompt, result),
239
+ pendingToolCallIds: [pendingCall.id],
240
+ stepsSoFar: result.steps.length,
241
+ tokensSoFar: result.usage?.totalTokens ?? 0,
242
+ pauseKind: 'approval',
243
+ pendingApprovalToolCall: { toolCall: pendingCall, isClientTool },
244
+ };
245
+ await suspendable.runStore.store(subRunId, snapshot);
246
+ yield {
247
+ kind: 'subagent_paused_approval',
248
+ subRunId,
249
+ toolCall: pendingCall,
250
+ isClientTool,
251
+ };
252
+ yield pauseForApproval(pendingCall, isClientTool, subRunId);
253
+ // Unreachable — the parent loop halts iteration after the pause chunk.
254
+ return undefined;
255
+ }
194
256
  yield {
195
257
  kind: 'agent_done',
196
258
  steps: result.steps.length,
@@ -207,54 +269,96 @@ export class Agent {
207
269
  .modelOutput(modelOutput);
208
270
  }
209
271
  /**
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.
272
+ * Resume a sub-agent run that previously paused with either
273
+ * `pauseForClientTools` (client-tool pause) or `pauseForApproval`
274
+ * (approval pause), typically from {@link Agent.asTool} with
275
+ * `suspendable: { runStore }` set. The snapshot's `pauseKind`
276
+ * (default `'client_tool'`) selects the resume contract:
215
277
  *
216
- * Returns either a `'completed'` result (the inner agent finished) or
278
+ * - **`client_tool`** `clientToolResults` must carry one entry per
279
+ * id in the snapshot's `pendingToolCallIds`. Results are appended
280
+ * to the inner-agent message history and the loop re-runs.
281
+ * - **`approval`** — `approvedToolCallIds` and/or
282
+ * `rejectedToolCallIds` must reference the single pending id.
283
+ * `clientToolResults` must be empty; the loop re-runs with the
284
+ * approval decision injected via `AgentPromptOptions`.
285
+ *
286
+ * Returns either a `'completed'` result (the inner agent finished),
217
287
  * a `'paused'` continuation pointing at a fresh `subRunId` for the
218
- * next round-trip.
288
+ * next round-trip, or stays `'paused'` if the inner loop hits another
289
+ * gate. The resume can pause on a different kind than it started on
290
+ * (e.g. an approval pause that, once approved, hits a client-tool
291
+ * pause on the next step).
219
292
  *
220
- * @example
293
+ * @example Client-tool resume
221
294
  * 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
- * }
295
+ *
296
+ * @example Approval resume
297
+ * const r = await Agent.resumeAsTool(subRunId, [], {
298
+ * runStore, agent: subAgent,
299
+ * approvedToolCallIds: ['inner-call-id'],
300
+ * })
227
301
  */
228
302
  static async resumeAsTool(subRunId, clientToolResults, options) {
229
303
  const snapshot = await options.runStore.consume(subRunId);
230
304
  if (!snapshot) {
231
305
  throw new Error(`[RudderJS AI] resumeAsTool: subRunId "${subRunId}" expired or never existed.`);
232
306
  }
233
- // Forgery guard every incoming tool-result id must be in the pending set.
307
+ const pauseKind = snapshot.pauseKind ?? 'client_tool';
234
308
  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.`);
309
+ let messages;
310
+ const promptOpts = { toolCallStreamingMode: 'stop-on-client-tool' };
311
+ if (pauseKind === 'client_tool') {
312
+ // Forgery guard every incoming tool-result id must be in the pending set.
313
+ const seen = new Set();
314
+ for (const r of clientToolResults) {
315
+ if (!pending.has(r.toolCallId)) {
316
+ throw new Error(`[RudderJS AI] resumeAsTool: toolCallId "${r.toolCallId}" was not in the pending set.`);
317
+ }
318
+ if (seen.has(r.toolCallId)) {
319
+ throw new Error(`[RudderJS AI] resumeAsTool: duplicate result for toolCallId "${r.toolCallId}".`);
320
+ }
321
+ seen.add(r.toolCallId);
239
322
  }
240
- if (seen.has(r.toolCallId)) {
241
- throw new Error(`[RudderJS AI] resumeAsTool: duplicate result for toolCallId "${r.toolCallId}".`);
323
+ // Append client tool-result messages to the snapshot, in incoming order.
324
+ messages = [...snapshot.messages];
325
+ for (const r of clientToolResults) {
326
+ messages.push({
327
+ role: 'tool',
328
+ content: typeof r.result === 'string' ? r.result : JSON.stringify(r.result),
329
+ toolCallId: r.toolCallId,
330
+ });
242
331
  }
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
- });
332
+ }
333
+ else {
334
+ // Approval-pause resume clientToolResults must be empty; either an
335
+ // approval or a rejection must be supplied for the pending id.
336
+ if (clientToolResults.length > 0) {
337
+ throw new Error('[RudderJS AI] resumeAsTool: snapshot.pauseKind === "approval" but clientToolResults was non-empty. Pass `approvedToolCallIds` or `rejectedToolCallIds` instead.');
338
+ }
339
+ const approved = options.approvedToolCallIds ?? [];
340
+ const rejected = options.rejectedToolCallIds ?? [];
341
+ for (const id of approved) {
342
+ if (!pending.has(id)) {
343
+ throw new Error(`[RudderJS AI] resumeAsTool: approvedToolCallId "${id}" was not in the pending set.`);
344
+ }
345
+ }
346
+ for (const id of rejected) {
347
+ if (!pending.has(id)) {
348
+ throw new Error(`[RudderJS AI] resumeAsTool: rejectedToolCallId "${id}" was not in the pending set.`);
349
+ }
350
+ }
351
+ if (approved.length === 0 && rejected.length === 0) {
352
+ throw new Error('[RudderJS AI] resumeAsTool: snapshot.pauseKind === "approval" requires `approvedToolCallIds` or `rejectedToolCallIds`.');
353
+ }
354
+ messages = [...snapshot.messages];
355
+ if (approved.length > 0)
356
+ promptOpts.approvedToolCallIds = approved;
357
+ if (rejected.length > 0)
358
+ promptOpts.rejectedToolCallIds = rejected;
359
+ }
360
+ promptOpts.messages = messages;
361
+ const result = await options.agent.prompt('', promptOpts);
258
362
  if (result.finishReason === 'client_tool_calls' &&
259
363
  result.pendingClientToolCalls?.length) {
260
364
  const newSubRunId = generateSubRunId();
@@ -263,13 +367,38 @@ export class Agent {
263
367
  pendingToolCallIds: result.pendingClientToolCalls.map((tc) => tc.id),
264
368
  stepsSoFar: snapshot.stepsSoFar + result.steps.length,
265
369
  tokensSoFar: snapshot.tokensSoFar + (result.usage?.totalTokens ?? 0),
370
+ pauseKind: 'client_tool',
371
+ ...(snapshot.meta !== undefined ? { meta: snapshot.meta } : {}),
372
+ };
373
+ await options.runStore.store(newSubRunId, newSnapshot);
374
+ return {
375
+ kind: 'paused',
376
+ subRunId: newSubRunId,
377
+ pauseKind: 'client_tool',
378
+ pendingToolCallIds: newSnapshot.pendingToolCallIds,
379
+ };
380
+ }
381
+ if (result.finishReason === 'tool_approval_required' &&
382
+ result.pendingApprovalToolCall) {
383
+ const newSubRunId = generateSubRunId();
384
+ const { toolCall: pendingCall, isClientTool } = result.pendingApprovalToolCall;
385
+ const newSnapshot = {
386
+ messages: buildResumeSnapshotMessages(messages, result),
387
+ pendingToolCallIds: [pendingCall.id],
388
+ stepsSoFar: snapshot.stepsSoFar + result.steps.length,
389
+ tokensSoFar: snapshot.tokensSoFar + (result.usage?.totalTokens ?? 0),
390
+ pauseKind: 'approval',
391
+ pendingApprovalToolCall: { toolCall: pendingCall, isClientTool },
266
392
  ...(snapshot.meta !== undefined ? { meta: snapshot.meta } : {}),
267
393
  };
268
394
  await options.runStore.store(newSubRunId, newSnapshot);
269
395
  return {
270
396
  kind: 'paused',
271
397
  subRunId: newSubRunId,
398
+ pauseKind: 'approval',
272
399
  pendingToolCallIds: newSnapshot.pendingToolCallIds,
400
+ toolCall: pendingCall,
401
+ isClientTool,
273
402
  };
274
403
  }
275
404
  return { kind: 'completed', response: result };
@@ -277,9 +406,11 @@ export class Agent {
277
406
  }
278
407
  /**
279
408
  * Default projection from inner-agent stream chunks to {@link SubAgentUpdate}
280
- * events. Emits one `tool_call` per inner `tool-call` chunk; everything
409
+ * events. Emits one `tool_call` per inner `tool-call` chunk and
410
+ * `agent_pending_approval` per inner `pending-approval` chunk; everything
281
411
  * else is suppressed (the wrapping execute emits the `agent_start` /
282
- * `agent_done` bookends and the suspend path emits `subagent_paused`).
412
+ * `agent_done` bookends and the suspend paths emit `subagent_paused` /
413
+ * `subagent_paused_approval`).
283
414
  *
284
415
  * Hosts wanting different cadence (e.g. surfacing `text-delta` previews
285
416
  * or per-step usage) pass `streaming: chunk => …` and own the discriminator.
@@ -292,6 +423,13 @@ function defaultSubAgentProjector(chunk) {
292
423
  ...(chunk.toolCall.arguments ? { args: chunk.toolCall.arguments } : {}),
293
424
  };
294
425
  }
426
+ if (chunk.type === 'pending-approval' && chunk.toolCall && chunk.toolCall.id && chunk.toolCall.name) {
427
+ return {
428
+ kind: 'agent_pending_approval',
429
+ toolCall: chunk.toolCall,
430
+ isClientTool: !!chunk.isClientTool,
431
+ };
432
+ }
295
433
  return null;
296
434
  }
297
435
  /**
@@ -437,6 +575,21 @@ export function setConversationStore(store) {
437
575
  function resolveConversationStore() {
438
576
  return _conversationStore;
439
577
  }
578
+ // ─── User Memory Registry (#A4) ──────────────────────────
579
+ let _userMemory;
580
+ /**
581
+ * Set the global {@link UserMemory} (called by `AiProvider` from
582
+ * `AiConfig.memory`, or manually for tests / standalone setups).
583
+ * Phase 2/3 middleware reads it via `resolveUserMemory()` —
584
+ * imported by the persistence layer the same way
585
+ * `resolveConversationStore` is wired today.
586
+ */
587
+ export function setUserMemory(memory) {
588
+ _userMemory = memory;
589
+ }
590
+ export function resolveUserMemory() {
591
+ return _userMemory;
592
+ }
440
593
  /**
441
594
  * Streaming counterpart of `Agent.prompt`'s auto-persist branch. The spec
442
595
  * resolution is async (since `conversational()` may return a Promise), so
@@ -445,30 +598,38 @@ function resolveConversationStore() {
445
598
  * persisted path.
446
599
  */
447
600
  function runStreamWithMaybeAutoPersist(a, input, options) {
448
- // Synchronous fast path — most agents don't override `conversational()`,
449
- // so we'd pay an extra microtask boundary on every streaming call. Bail
450
- // out cheaply when we can prove the call is stateless.
451
- const declared = a.conversational();
452
- const isFast = (options?.conversation === false ||
453
- (declared === false && (options?.conversation === undefined)));
601
+ // Synchronous fast path — most agents override neither `conversational()`
602
+ // nor `remembers()`. Skip the async outer entirely when we can prove
603
+ // both are no-ops, sparing a microtask boundary per streaming call.
604
+ const declaredConv = a.conversational();
605
+ const declaredMem = a.remembers();
606
+ const isFast = ((options?.conversation === false ||
607
+ (declaredConv === false && options?.conversation === undefined))
608
+ && (options?.memory === false ||
609
+ (declaredMem === false && options?.memory === undefined) ||
610
+ options?.messages !== undefined));
454
611
  if (isFast) {
455
612
  return runAgentLoopStreaming(a, input, options);
456
613
  }
457
- // Async path — resolve the spec, then dispatch to the persisted or plain stream.
614
+ // Async path — resolve memory + conversation specs, then dispatch.
458
615
  let resolveResp;
459
616
  let rejectResp;
460
617
  const responsePromise = new Promise((res, rej) => { resolveResp = res; rejectResp = rej; });
461
618
  async function* outer() {
619
+ let effOptions;
462
620
  let spec;
463
621
  try {
464
- spec = await resolveAutoPersistSpec(() => a.conversational(), options?.conversation);
622
+ // Memory auto-cascade BEFORE conversation persistence — same
623
+ // ordering as the non-streaming `Agent.prompt` path.
624
+ effOptions = await prepareOptionsWithMemoryAutoCascade(a, options);
625
+ spec = await resolveAutoPersistSpec(() => a.conversational(), effOptions?.conversation);
465
626
  }
466
627
  catch (err) {
467
628
  rejectResp(err);
468
629
  throw err;
469
630
  }
470
631
  if (!spec) {
471
- const inner = runAgentLoopStreaming(a, input, options);
632
+ const inner = runAgentLoopStreaming(a, input, effOptions);
472
633
  try {
473
634
  for await (const chunk of inner.stream)
474
635
  yield chunk;
@@ -487,7 +648,7 @@ function runStreamWithMaybeAutoPersist(a, input, options) {
487
648
  }
488
649
  return;
489
650
  }
490
- const persisted = runWithPersistenceStreaming(spec, a.constructor.name, resolveConversationStore, input, options, (effOptions) => runAgentLoopStreaming(a, input, effOptions));
651
+ const persisted = runWithPersistenceStreaming(spec, a.constructor.name, resolveConversationStore, input, effOptions, (innerOptions) => runAgentLoopStreaming(a, input, innerOptions));
491
652
  try {
492
653
  for await (const chunk of persisted.stream)
493
654
  yield chunk;
@@ -513,10 +674,53 @@ function getTools(a) {
513
674
  ? a.tools()
514
675
  : [];
515
676
  }
516
- function getMiddleware(a) {
517
- return 'middleware' in a && typeof a.middleware === 'function'
677
+ /**
678
+ * Internal symbol used to plumb auto-installed middlewares (today:
679
+ * memory-inject; future: budget-tracker, etc.) through the public
680
+ * `AgentPromptOptions` without polluting its surface. Resolution
681
+ * happens at the `Agent.prompt` / `Agent.stream` boundary; the loop
682
+ * just appends them to the user's `agent.middleware()` array.
683
+ */
684
+ const EXTRA_MIDDLEWARES = Symbol.for('rudderjs.ai.extraMiddlewares');
685
+ function getMiddleware(a, options) {
686
+ const own = 'middleware' in a && typeof a.middleware === 'function'
518
687
  ? a.middleware()
519
688
  : [];
689
+ const extras = options?.[EXTRA_MIDDLEWARES] ?? [];
690
+ return extras.length > 0 ? [...own, ...extras] : own;
691
+ }
692
+ /**
693
+ * Resolve the effective `remembers()` spec and append the appropriate
694
+ * memory middlewares (inject for Phase 2, extract for Phase 3) to the
695
+ * options' hidden extras list. Skips entirely on:
696
+ * - continuation calls (`options.messages` set) — the system message
697
+ * was already augmented on the original `prompt()`, re-injecting
698
+ * would duplicate the block on every tool round-trip; re-extracting
699
+ * would also double-write the same facts on every round-trip.
700
+ * - specs where neither `inject === 'auto'` nor `extract === 'auto'`
701
+ * apply.
702
+ *
703
+ * Returns options unchanged when no auto-cascade is needed so the
704
+ * downstream conversational/loop path sees the original reference.
705
+ */
706
+ async function prepareOptionsWithMemoryAutoCascade(a, options) {
707
+ if (options?.messages)
708
+ return options;
709
+ const spec = await resolveRemembersSpec(() => a.remembers(), options?.memory);
710
+ if (!spec)
711
+ return options;
712
+ const installed = [];
713
+ if (spec.inject === 'auto')
714
+ installed.push(withMemoryInject(spec));
715
+ if (spec.extract === 'auto' && spec.extractWith)
716
+ installed.push(withMemoryExtract(spec));
717
+ if (installed.length === 0)
718
+ return options;
719
+ const current = options?.[EXTRA_MIDDLEWARES] ?? [];
720
+ return {
721
+ ...options,
722
+ [EXTRA_MIDDLEWARES]: [...current, ...installed],
723
+ };
520
724
  }
521
725
  function createMiddlewareContext(messages, model, tools, iteration) {
522
726
  const [provider] = AiRegistry.parseModelString(model);
@@ -753,6 +957,12 @@ function buildAgentResponse(loopCtx) {
753
957
  result.pendingApprovalToolCall = loopCtx.pendingApprovalToolCall;
754
958
  if (loopCtx.resumedToolMessages.length > 0)
755
959
  result.resumedToolMessages = loopCtx.resumedToolMessages;
960
+ // Internal — consumed by the handoff-aware wrapper, then stripped before
961
+ // surfacing to public callers.
962
+ if (loopCtx.pendingHandoff) {
963
+ result._pendingHandoff = loopCtx.pendingHandoff;
964
+ result._carriedMessages = loopCtx.messages;
965
+ }
756
966
  return result;
757
967
  }
758
968
  /**
@@ -775,7 +985,15 @@ async function* executeToolPhase(loopCtx, toolCalls, assistantMessage) {
775
985
  // agent-level override which defaults to `true`. Single-tool batches
776
986
  // route through the serial path either way (no parallelism to gain, and
777
987
  // serial preserves live `tool-update` streaming for that one tool).
778
- const parallel = (options?.parallelTools ?? loopCtx.agent.parallelTools()) && toolCalls.length > 1;
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;
779
997
  if (parallel) {
780
998
  yield* runToolPhaseParallel(loopCtx, toolCalls, toolResults);
781
999
  }
@@ -804,6 +1022,50 @@ async function* runToolPhaseSerial(loopCtx, toolCalls, toolResults) {
804
1022
  yield { type: 'tool-result', toolCall: tc, result: unknownResult };
805
1023
  continue;
806
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
+ }
807
1069
  if (!tool.execute) {
808
1070
  // Client tool — no server-side handler.
809
1071
  if (options?.toolCallStreamingMode === 'stop-on-client-tool') {
@@ -905,6 +1167,16 @@ async function* runToolPhaseSerial(loopCtx, toolCalls, toolResults) {
905
1167
  paused = true;
906
1168
  break;
907
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
+ }
908
1180
  const updateChunk = { type: 'tool-update', toolCall: tc, update: step.value };
909
1181
  if (middlewares.length > 0) {
910
1182
  const transformed = runOnChunk(middlewares, ctx, updateChunk);
@@ -1156,6 +1428,16 @@ async function runToolExecution(loopCtx, outcome) {
1156
1428
  paused = true;
1157
1429
  break;
1158
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
+ }
1159
1441
  const updateChunk = { type: 'tool-update', toolCall: outcome.tc, update: step.value };
1160
1442
  if (middlewares.length > 0) {
1161
1443
  const transformed = runOnChunk(middlewares, ctx, updateChunk);
@@ -1190,7 +1472,7 @@ async function initializeLoop(a, input, options) {
1190
1472
  const modelString = a.model() ?? AiRegistry.getDefault();
1191
1473
  const [providerName] = AiRegistry.parseModelString(modelString);
1192
1474
  const tools = getTools(a);
1193
- const middlewares = getMiddleware(a);
1475
+ const middlewares = getMiddleware(a, options);
1194
1476
  const toolSchemas = buildToolSchemas(tools);
1195
1477
  const toolMap = buildToolMap(tools);
1196
1478
  const messages = options?.messages
@@ -1228,6 +1510,7 @@ async function initializeLoop(a, input, options) {
1228
1510
  stopForApproval: false,
1229
1511
  resumedToolMessages: [],
1230
1512
  failoverAttempts: 0,
1513
+ stopForHandoff: false,
1231
1514
  };
1232
1515
  // Resume server tools left pending by a previous approval round-trip.
1233
1516
  {
@@ -1289,7 +1572,195 @@ async function runIterationPrelude(loopCtx, iteration) {
1289
1572
  return { currentModel };
1290
1573
  }
1291
1574
  // ─── 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
+ /**
1584
+ * Public entry point for the non-streaming agent loop. Drives
1585
+ * {@link runAgentLoopOnce} once, then — if the model called a {@link handoff}
1586
+ * tool — constructs the target agent, carries the conversation forward, and
1587
+ * recurses. Steps and usage from each hop are merged; the final `text` and
1588
+ * `finishReason` come from the agent that produced the terminal answer.
1589
+ * `handoffPath` records the chain of class names traversed.
1590
+ */
1292
1591
  async function runAgentLoop(a, input, options) {
1592
+ const onceResult = await runAgentLoopOnce(a, input, options);
1593
+ if (!onceResult._pendingHandoff) {
1594
+ return stripInternal(onceResult);
1595
+ }
1596
+ const merged = await driveHandoffs(a.constructor.name, onceResult, onceResult._pendingHandoff, onceResult._carriedMessages ?? [], options, 0);
1597
+ return merged;
1598
+ }
1599
+ /**
1600
+ * Streaming counterpart to {@link runAgentLoop}. Iterates handoffs and
1601
+ * pivots the stream to the next agent each time the parent ends with a
1602
+ * pending handoff. Chunks from every hop flow through the same returned
1603
+ * `AsyncIterable`; the resolved `response` carries the merged final state.
1604
+ */
1605
+ function runAgentLoopStreaming(a, input, options) {
1606
+ let resolveResponse;
1607
+ let rejectResponse;
1608
+ const responsePromise = new Promise((resolve, reject) => {
1609
+ resolveResponse = resolve;
1610
+ rejectResponse = reject;
1611
+ });
1612
+ async function* generateStream() {
1613
+ let currentAgent = a;
1614
+ let currentInput = input;
1615
+ let currentOpts = options;
1616
+ const mergedSteps = [];
1617
+ const mergedUsage = { promptTokens: 0, completionTokens: 0, totalTokens: 0 };
1618
+ const handoffPath = [];
1619
+ let finalResponse;
1620
+ for (let hop = 0; hop <= MAX_HANDOFFS; hop++) {
1621
+ const onceStream = runAgentLoopStreamingOnce(currentAgent, currentInput, currentOpts);
1622
+ // Attach a no-op handler so a rejection from the inner response
1623
+ // promise (e.g. caller-supplied AbortSignal firing mid-stream) is
1624
+ // already observed by the time the `for await` re-throws — without
1625
+ // this, Node logs an unhandledRejection between the stream's throw
1626
+ // and our outer `withRejectOnError`'s catch.
1627
+ onceStream.response.catch(() => { });
1628
+ for await (const chunk of onceStream.stream)
1629
+ yield chunk;
1630
+ const r = await onceStream.response;
1631
+ mergedSteps.push(...r.steps);
1632
+ addUsage(mergedUsage, r.usage);
1633
+ if (r._pendingHandoff && hop < MAX_HANDOFFS) {
1634
+ handoffPath.push(currentAgent.constructor.name);
1635
+ const ChildClass = r._pendingHandoff.spec.AgentClass;
1636
+ currentAgent = new ChildClass();
1637
+ currentInput = r._pendingHandoff.transitionMessage;
1638
+ currentOpts = buildHandoffChildOptions(options, r._carriedMessages ?? []);
1639
+ continue;
1640
+ }
1641
+ if (r._pendingHandoff) {
1642
+ throw new Error(`[RudderJS AI] Exceeded max handoffs (${MAX_HANDOFFS}). Likely a cycle between agents.`);
1643
+ }
1644
+ finalResponse = handoffPath.length === 0
1645
+ ? stripInternal(r)
1646
+ : mergeFinalHandoff(stripInternal(r), mergedSteps, mergedUsage, handoffPath, currentAgent.constructor.name);
1647
+ break;
1648
+ }
1649
+ if (!finalResponse) {
1650
+ throw new Error(`[RudderJS AI] Exceeded max handoffs (${MAX_HANDOFFS}). Likely a cycle between agents.`);
1651
+ }
1652
+ resolveResponse(finalResponse);
1653
+ }
1654
+ async function* withRejectOnError() {
1655
+ try {
1656
+ yield* generateStream();
1657
+ }
1658
+ catch (err) {
1659
+ rejectResponse(err);
1660
+ throw err;
1661
+ }
1662
+ }
1663
+ return {
1664
+ stream: withRejectOnError(),
1665
+ response: responsePromise,
1666
+ };
1667
+ }
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
+ async function runAgentLoopOnce(a, input, options) {
1293
1764
  const { loopCtx, stopConditions } = await initializeLoop(a, input, options);
1294
1765
  const { ctx, middlewares, messages, steps, totalUsage } = loopCtx;
1295
1766
  try {
@@ -1333,7 +1804,7 @@ async function runAgentLoop(a, input, options) {
1333
1804
  };
1334
1805
  steps.push(step);
1335
1806
  emitObserverStepCompleted(loopCtx, iteration, false);
1336
- if (loopCtx.stopForClientTools || loopCtx.stopForApproval)
1807
+ if (loopCtx.stopForClientTools || loopCtx.stopForApproval || loopCtx.stopForHandoff)
1337
1808
  break;
1338
1809
  const shouldStop = stopConditions.some(cond => cond({ steps, iteration, lastMessage: response.message }));
1339
1810
  if (shouldStop || response.finishReason !== 'tool_calls') {
@@ -1357,7 +1828,7 @@ async function runAgentLoop(a, input, options) {
1357
1828
  return result;
1358
1829
  }
1359
1830
  // ─── Agent Loop (streaming) ──────────────────────────────
1360
- function runAgentLoopStreaming(a, input, options) {
1831
+ function runAgentLoopStreamingOnce(a, input, options) {
1361
1832
  let resolveResponse;
1362
1833
  let rejectResponse;
1363
1834
  const responsePromise = new Promise((resolve, reject) => {
@@ -1463,7 +1934,7 @@ function runAgentLoopStreaming(a, input, options) {
1463
1934
  };
1464
1935
  steps.push(step);
1465
1936
  emitObserverStepCompleted(loopCtx, iteration, true);
1466
- if (loopCtx.stopForClientTools || loopCtx.stopForApproval)
1937
+ if (loopCtx.stopForClientTools || loopCtx.stopForApproval || loopCtx.stopForHandoff)
1467
1938
  break;
1468
1939
  const shouldStop = stopConditions.some(cond => cond({ steps, iteration, lastMessage: step.message }));
1469
1940
  if (shouldStop || finishReason !== 'tool_calls')