@rudderjs/ai 1.3.0 → 1.4.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 CHANGED
@@ -231,7 +231,7 @@ new Researcher().asTool({
231
231
  })
232
232
  ```
233
233
 
234
- The wrapped subagent runs via `prompt()` (non-streaming) regardless of how the parent was invoked. Token deltas from the subagent are not surfaced as `tool-update` chunks in the parent stream if you need that, write the wrapping tool by hand and drive `agent.stream(...)` yourself.
234
+ The wrapped subagent runs via `prompt()` (non-streaming) by default to surface inner-agent progress as `tool-update` chunks in the parent stream, pass `streaming: true` (or a custom `(chunk) => SubAgentUpdate | null` projector). When the sub-agent's model emits a *client* tool call, opt into the suspend/resume protocol with `suspendable: { runStore }` — the parent loop halts with the inner agent's `pendingClientToolCalls`, the snapshot persists in the run store, and the host resumes via `Agent.resumeAsTool(subRunId, browserResults, { runStore, agent })`. See `docs/guide/ai.md` for the full flow. `InMemorySubAgentRunStore` works for tests; `CachedSubAgentRunStore` plugs into `@rudderjs/cache` for cross-process persistence. Suspend without streaming throws at builder time.
235
235
 
236
236
  ### Tool execution context
237
237
 
@@ -252,7 +252,7 @@ const myTool = toolDefinition({
252
252
  })
253
253
  ```
254
254
 
255
- The primary consumer is `@rudderjs/panels`'s `runAgentTool`, which uses
255
+ The primary consumer is `@pilotiq-pro/ai`'s `runAgentTool`, which uses
256
256
  `ctx.toolCallId` to correlate sub-agent suspensions with the parent's
257
257
  `run_agent` call (see "Pausing the loop from a server tool" below).
258
258
 
@@ -604,6 +604,21 @@ const response = await agent('You are helpful.').prompt('Follow up question', {
604
604
 
605
605
  Works with both `.prompt()` and `.stream()`. History messages are prepended after the system prompt, before the current user message.
606
606
 
607
+ ### Auto-persist conversations
608
+
609
+ Override `conversational()` on an agent class to auto-load and auto-save threads without threading user ids through every call site:
610
+
611
+ ```ts
612
+ class ChatAgent extends Agent {
613
+ conversational() { return { user: Auth.user()?.id } }
614
+ }
615
+
616
+ await new ChatAgent().prompt('Hi') // auto-loads + auto-saves
617
+ await new ChatAgent().prompt('Continue?') // resumes same thread (per user + class)
618
+ ```
619
+
620
+ Returning `false` (the default) keeps the agent stateless. Async returns are awaited; an optional `historyLimit` caps loaded messages. Per-call escape hatches: `prompt(input, { conversation: false })` or `agent.forUser(id).prompt()` / `agent.continue(id).prompt()` — explicit always wins. See `docs/guide/ai.md` for the full precedence chain.
621
+
607
622
  ### Model Selection
608
623
 
609
624
  Configure available models for user selection (used by `@rudderjs/panels` chat UI):
@@ -91,7 +91,7 @@ class Planner extends Agent implements HasTools {
91
91
  }
92
92
  ```
93
93
 
94
- The subagent runs via `prompt()` (non-streaming); for `tool-update` chunks from a streaming subagent, write the wrapping tool by hand.
94
+ By default the subagent runs via `prompt()` (non-streaming). Pass `streaming: true` to surface inner progress as `tool-update` chunks (default projection emits `agent_start` / `tool_call` / `agent_done`); pass `(chunk) => SubAgentUpdate | null` for a custom projector. To propagate inner client-tool calls upward through the parent loop, also pass `suspendable: { runStore }` (suspend without streaming throws at builder time) — the host's continuation calls `Agent.resumeAsTool(subRunId, results, { runStore, agent })` to resume the inner agent with the browser's results. `InMemorySubAgentRunStore` works for tests; `CachedSubAgentRunStore` plugs into `@rudderjs/cache` for multi-worker persistence.
95
95
 
96
96
  ### Middleware
97
97
 
@@ -135,6 +135,18 @@ const response = await myAgent.forUser('user-123').prompt('Hello') // creates c
135
135
  const follow = await myAgent.continue(response.conversationId).prompt('Follow up')
136
136
  ```
137
137
 
138
+ For chat agents that should always auto-persist for the active user, override `conversational()` on the class — `agent.prompt(input)` then auto-loads + auto-saves without each caller passing the user id:
139
+
140
+ ```ts
141
+ class ChatAgent extends Agent {
142
+ conversational() { return { user: Auth.user()?.id } } // null user → opt-out
143
+ }
144
+ await new ChatAgent().prompt('Hi') // auto-loads thread
145
+ await new ChatAgent().prompt('still you?') // resumes per (user, class)
146
+ ```
147
+
148
+ Returning `false` (default) keeps the agent stateless. Optional `historyLimit: N` caps loaded messages. Per-call `{ conversation: false }` opts out; `forUser`/`continue` always win.
149
+
138
150
  ### Streaming
139
151
 
140
152
  Use `.stream()` for real-time token delivery:
package/dist/agent.d.ts CHANGED
@@ -1,7 +1,8 @@
1
1
  import { z } from 'zod';
2
2
  import type { ServerToolBuilder } from './tool.js';
3
3
  import { QueuedPromptBuilder } from './queue-job.js';
4
- import type { AgentPromptOptions, AiMessage, AiMiddleware, AgentResponse, AgentStep, AgentStreamResponse, AnyTool, CacheableConfig, ConversationStore, HasMiddleware, HasTools, PrepareStepResult, StopCondition } from './types.js';
4
+ import type { SubAgentRunStore } from './sub-agent-run-store.js';
5
+ import type { AgentPromptOptions, AiMessage, AiMiddleware, AgentResponse, AgentStep, AgentStreamResponse, AnyTool, CacheableConfig, ConversationalSpec, ConversationStore, SubAgentUpdate, HasMiddleware, HasTools, PrepareStepResult, StopCondition, StreamChunk } from './types.js';
5
6
  /** Stop after N steps */
6
7
  export declare function stepCountIs(n: number): StopCondition;
7
8
  /** Stop when a specific tool is called in the latest step */
@@ -51,6 +52,34 @@ export declare abstract class Agent {
51
52
  * }
52
53
  */
53
54
  cacheable(): CacheableConfig | undefined;
55
+ /**
56
+ * Opt into auto-persisted conversation behavior. Override on a subclass
57
+ * to declare *which* user owns the thread and (optionally) which
58
+ * specific thread, and the framework will load history before each
59
+ * `prompt()`/`stream()` call and append the new turn after it — without
60
+ * any caller having to remember `forUser()` / `continue()`.
61
+ *
62
+ * Returning `false` (the default) disables auto-persist; the agent runs
63
+ * stateless. Returning a {@link ConversationalSpec} opts in:
64
+ *
65
+ * @example
66
+ * class ChatAgent extends Agent {
67
+ * conversational() {
68
+ * return { user: Auth.user()?.id } // null user → falsy → opt-out
69
+ * }
70
+ * }
71
+ *
72
+ * await new ChatAgent().prompt('Hi') // auto-loads + auto-saves
73
+ *
74
+ * **Precedence (high → low):**
75
+ * 1. Explicit `agent.forUser(id).prompt()` / `agent.continue(id).prompt()`
76
+ * 2. Per-call `prompt(input, { conversation: false | {...} })`
77
+ * 3. This method's return value
78
+ *
79
+ * Async returns are supported — useful when the user identity is fetched
80
+ * from an async DI binding.
81
+ */
82
+ conversational(): false | ConversationalSpec | Promise<false | ConversationalSpec>;
54
83
  /**
55
84
  * Default for `AgentPromptOptions.parallelTools`. When `true` (default),
56
85
  * multiple tool calls within a single step run their `execute()` functions
@@ -102,15 +131,57 @@ export declare abstract class Agent {
102
131
  inputSchema: TInput;
103
132
  prompt: (input: z.infer<TInput>) => string;
104
133
  modelOutput?: (response: AgentResponse) => string | Promise<string>;
134
+ streaming?: AsToolStreamingOption;
135
+ suspendable?: AsToolSuspendableOption;
105
136
  }): ServerToolBuilder<z.infer<TInput>, AgentResponse>;
106
137
  asTool(options: {
107
138
  name: string;
108
139
  description: string;
109
140
  modelOutput?: (response: AgentResponse) => string | Promise<string>;
141
+ streaming?: AsToolStreamingOption;
142
+ suspendable?: AsToolSuspendableOption;
110
143
  }): ServerToolBuilder<{
111
144
  prompt: string;
112
145
  }, AgentResponse>;
146
+ /**
147
+ * Resume a sub-agent run that previously paused with
148
+ * `pauseForClientTools` (typically from {@link Agent.asTool} with
149
+ * `suspendable: { runStore }` set). Loads the snapshot, validates the
150
+ * incoming tool-result ids against the pending set, and re-runs the
151
+ * inner loop with those results appended.
152
+ *
153
+ * Returns either a `'completed'` result (the inner agent finished) or
154
+ * a `'paused'` continuation pointing at a fresh `subRunId` for the
155
+ * next round-trip.
156
+ *
157
+ * @example
158
+ * const r = await Agent.resumeAsTool(subRunId, browserResults, { runStore, agent: subAgent })
159
+ * if (r.kind === 'completed') {
160
+ * feedToolResultBackToParent(r.response.text)
161
+ * } else {
162
+ * emitPendingClientToolsSse(r.subRunId, r.pendingToolCallIds)
163
+ * }
164
+ */
165
+ static resumeAsTool(subRunId: string, clientToolResults: ReadonlyArray<{
166
+ toolCallId: string;
167
+ result: unknown;
168
+ }>, options: {
169
+ runStore: SubAgentRunStore;
170
+ agent: Agent;
171
+ }): Promise<{
172
+ kind: 'completed';
173
+ response: AgentResponse;
174
+ } | {
175
+ kind: 'paused';
176
+ subRunId: string;
177
+ pendingToolCallIds: string[];
178
+ }>;
113
179
  }
180
+ type ChunkProjector = (chunk: StreamChunk) => SubAgentUpdate | null;
181
+ type AsToolStreamingOption = boolean | ChunkProjector;
182
+ type AsToolSuspendableOption = {
183
+ runStore: SubAgentRunStore;
184
+ };
114
185
  /**
115
186
  * Wraps an Agent to add conversation memory.
116
187
  * Created via `agent.forUser(id)` or `agent.continue(id)`.
@@ -124,6 +195,13 @@ export declare class ConversableAgent {
124
195
  continue(conversationId: string): this;
125
196
  prompt(input: string, options?: AgentPromptOptions): Promise<AgentResponse>;
126
197
  stream(input: string, options?: AgentPromptOptions): AgentStreamResponse;
198
+ /**
199
+ * Translate the wrapper's explicit-form state (`forUser` / `continue`)
200
+ * into a {@link ConversationalSpec}. The explicit chain bypasses the
201
+ * agent's `conversational()` declaration entirely — `forUser` always
202
+ * wins over class defaults.
203
+ */
204
+ private toSpec;
127
205
  }
128
206
  /**
129
207
  * Create an anonymous agent inline.
@@ -160,4 +238,5 @@ export interface InvalidToolArgumentsError {
160
238
  message: string;
161
239
  }>;
162
240
  }
241
+ export {};
163
242
  //# sourceMappingURL=agent.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"agent.d.ts","sourceRoot":"","sources":["../src/agent.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"agent.d.ts","sourceRoot":"","sources":["../src/agent.ts"],"names":[],"mappings":"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"}
package/dist/agent.js CHANGED
@@ -1,8 +1,9 @@
1
1
  import { z } from 'zod';
2
2
  import { AiRegistry } from './registry.js';
3
- import { isPauseForClientToolsChunk, toolDefinition, toolToSchema } from './tool.js';
3
+ import { isPauseForClientToolsChunk, pauseForClientTools, toolDefinition, toolToSchema } from './tool.js';
4
4
  import { attachmentsToContentParts, getMessageText } from './attachment.js';
5
5
  import { QueuedPromptBuilder } from './queue-job.js';
6
+ import { resolveAutoPersistSpec, runWithPersistence, runWithPersistenceStreaming, } from './conversation-persistence.js';
6
7
  import { runOnConfig, runOnChunk, runOnBeforeToolCall, runOnAfterToolCall, runSequential, runOnUsage, runOnAbort, runOnError, } from './middleware.js';
7
8
  // ─── AI Observer (lazy accessor) ─────────────────────────
8
9
  function _getAiObservers() {
@@ -79,6 +80,36 @@ export class Agent {
79
80
  * }
80
81
  */
81
82
  cacheable() { return undefined; }
83
+ /**
84
+ * Opt into auto-persisted conversation behavior. Override on a subclass
85
+ * to declare *which* user owns the thread and (optionally) which
86
+ * specific thread, and the framework will load history before each
87
+ * `prompt()`/`stream()` call and append the new turn after it — without
88
+ * any caller having to remember `forUser()` / `continue()`.
89
+ *
90
+ * Returning `false` (the default) disables auto-persist; the agent runs
91
+ * stateless. Returning a {@link ConversationalSpec} opts in:
92
+ *
93
+ * @example
94
+ * class ChatAgent extends Agent {
95
+ * conversational() {
96
+ * return { user: Auth.user()?.id } // null user → falsy → opt-out
97
+ * }
98
+ * }
99
+ *
100
+ * await new ChatAgent().prompt('Hi') // auto-loads + auto-saves
101
+ *
102
+ * **Precedence (high → low):**
103
+ * 1. Explicit `agent.forUser(id).prompt()` / `agent.continue(id).prompt()`
104
+ * 2. Per-call `prompt(input, { conversation: false | {...} })`
105
+ * 3. This method's return value
106
+ *
107
+ * Async returns are supported — useful when the user identity is fetched
108
+ * from an async DI binding.
109
+ */
110
+ conversational() {
111
+ return false;
112
+ }
82
113
  /**
83
114
  * Default for `AgentPromptOptions.parallelTools`. When `true` (default),
84
115
  * multiple tool calls within a single step run their `execute()` functions
@@ -88,11 +119,15 @@ export class Agent {
88
119
  parallelTools() { return true; }
89
120
  /** Run the agent with a prompt (non-streaming) */
90
121
  async prompt(input, options) {
122
+ const spec = await resolveAutoPersistSpec(() => this.conversational(), options?.conversation);
123
+ if (spec) {
124
+ return runWithPersistence(spec, this.constructor.name, resolveConversationStore, input, options, (effOptions) => runAgentLoop(this, input, effOptions));
125
+ }
91
126
  return runAgentLoop(this, input, options);
92
127
  }
93
128
  /** Run the agent with a prompt (streaming) */
94
129
  stream(input, options) {
95
- return runAgentLoopStreaming(this, input, options);
130
+ return runStreamWithMaybeAutoPersist(this, input, options);
96
131
  }
97
132
  /** Queue the prompt for background execution */
98
133
  queue(input, options) {
@@ -107,17 +142,201 @@ export class Agent {
107
142
  return new ConversableAgent(this).continue(conversationId);
108
143
  }
109
144
  asTool(options) {
145
+ if (options.suspendable && !options.streaming) {
146
+ throw new Error('[RudderJS AI] asTool: `suspendable` requires `streaming: true` (or a projector). Silent suspend would leave the parent UI with no progress signal between sub-agent invocations.');
147
+ }
110
148
  const schema = options.inputSchema ?? z.object({ prompt: z.string() });
111
149
  const promptOf = options.prompt ?? ((input) => input.prompt);
112
150
  const modelOutput = options.modelOutput ?? ((response) => response.text);
151
+ if (!options.streaming) {
152
+ // 1.2.0 zero-config path — single prompt() call, single AgentResponse out.
153
+ return toolDefinition({
154
+ name: options.name,
155
+ description: options.description,
156
+ inputSchema: schema,
157
+ })
158
+ .server((input) => this.prompt(promptOf(input)))
159
+ .modelOutput(modelOutput);
160
+ }
161
+ const project = options.streaming === true ? defaultSubAgentProjector : options.streaming;
162
+ const innerAgent = this; // eslint-disable-line @typescript-eslint/no-this-alias
163
+ const agentName = options.name;
164
+ const suspendable = options.suspendable;
165
+ const generatorExecute = async function* (input) {
166
+ const userPrompt = promptOf(input);
167
+ yield { kind: 'agent_start', agentName };
168
+ const streamOpts = suspendable
169
+ ? { toolCallStreamingMode: 'stop-on-client-tool' }
170
+ : undefined;
171
+ const { stream, response } = innerAgent.stream(userPrompt, streamOpts);
172
+ for await (const chunk of stream) {
173
+ const update = project(chunk);
174
+ if (update)
175
+ yield update;
176
+ }
177
+ const result = await response;
178
+ if (suspendable &&
179
+ result.finishReason === 'client_tool_calls' &&
180
+ result.pendingClientToolCalls?.length) {
181
+ const subRunId = generateSubRunId();
182
+ const snapshot = {
183
+ messages: buildSubAgentSnapshotMessages(userPrompt, result),
184
+ pendingToolCallIds: result.pendingClientToolCalls.map((tc) => tc.id),
185
+ stepsSoFar: result.steps.length,
186
+ tokensSoFar: result.usage?.totalTokens ?? 0,
187
+ };
188
+ await suspendable.runStore.store(subRunId, snapshot);
189
+ yield { kind: 'subagent_paused', subRunId, pendingToolCallIds: snapshot.pendingToolCallIds };
190
+ yield pauseForClientTools(result.pendingClientToolCalls, subRunId);
191
+ // Unreachable — the parent loop halts iteration after the pause chunk.
192
+ return undefined;
193
+ }
194
+ yield {
195
+ kind: 'agent_done',
196
+ steps: result.steps.length,
197
+ tokens: result.usage?.totalTokens ?? 0,
198
+ };
199
+ return result;
200
+ };
113
201
  return toolDefinition({
114
202
  name: options.name,
115
203
  description: options.description,
116
204
  inputSchema: schema,
117
205
  })
118
- .server((input) => this.prompt(promptOf(input)))
206
+ .server(generatorExecute)
119
207
  .modelOutput(modelOutput);
120
208
  }
209
+ /**
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.
215
+ *
216
+ * Returns either a `'completed'` result (the inner agent finished) or
217
+ * a `'paused'` continuation pointing at a fresh `subRunId` for the
218
+ * next round-trip.
219
+ *
220
+ * @example
221
+ * 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
+ * }
227
+ */
228
+ static async resumeAsTool(subRunId, clientToolResults, options) {
229
+ const snapshot = await options.runStore.consume(subRunId);
230
+ if (!snapshot) {
231
+ throw new Error(`[RudderJS AI] resumeAsTool: subRunId "${subRunId}" expired or never existed.`);
232
+ }
233
+ // Forgery guard — every incoming tool-result id must be in the pending set.
234
+ 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.`);
239
+ }
240
+ if (seen.has(r.toolCallId)) {
241
+ throw new Error(`[RudderJS AI] resumeAsTool: duplicate result for toolCallId "${r.toolCallId}".`);
242
+ }
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
+ });
258
+ if (result.finishReason === 'client_tool_calls' &&
259
+ result.pendingClientToolCalls?.length) {
260
+ const newSubRunId = generateSubRunId();
261
+ const newSnapshot = {
262
+ messages: buildResumeSnapshotMessages(messages, result),
263
+ pendingToolCallIds: result.pendingClientToolCalls.map((tc) => tc.id),
264
+ stepsSoFar: snapshot.stepsSoFar + result.steps.length,
265
+ tokensSoFar: snapshot.tokensSoFar + (result.usage?.totalTokens ?? 0),
266
+ ...(snapshot.meta !== undefined ? { meta: snapshot.meta } : {}),
267
+ };
268
+ await options.runStore.store(newSubRunId, newSnapshot);
269
+ return {
270
+ kind: 'paused',
271
+ subRunId: newSubRunId,
272
+ pendingToolCallIds: newSnapshot.pendingToolCallIds,
273
+ };
274
+ }
275
+ return { kind: 'completed', response: result };
276
+ }
277
+ }
278
+ /**
279
+ * Default projection from inner-agent stream chunks to {@link SubAgentUpdate}
280
+ * events. Emits one `tool_call` per inner `tool-call` chunk; everything
281
+ * else is suppressed (the wrapping execute emits the `agent_start` /
282
+ * `agent_done` bookends and the suspend path emits `subagent_paused`).
283
+ *
284
+ * Hosts wanting different cadence (e.g. surfacing `text-delta` previews
285
+ * or per-step usage) pass `streaming: chunk => …` and own the discriminator.
286
+ */
287
+ function defaultSubAgentProjector(chunk) {
288
+ if (chunk.type === 'tool-call' && chunk.toolCall?.name) {
289
+ return {
290
+ kind: 'tool_call',
291
+ tool: chunk.toolCall.name,
292
+ ...(chunk.toolCall.arguments ? { args: chunk.toolCall.arguments } : {}),
293
+ };
294
+ }
295
+ return null;
296
+ }
297
+ /**
298
+ * Reconstruct the inner-agent message history at the point the loop
299
+ * paused, so a subsequent {@link Agent.resumeAsTool} can rerun the loop
300
+ * with the appended client tool results. The shape is `[user, …(message
301
+ * + serverToolResults)*]` — system messages are omitted because the
302
+ * `messages` mode of the agent loop prepends `system` itself.
303
+ *
304
+ * Each step's `message` includes ALL `toolCalls` (server + client).
305
+ * Server-side `toolResults` are interleaved; client-side calls remain
306
+ * unfulfilled until resume appends their results.
307
+ */
308
+ function buildSubAgentSnapshotMessages(userPrompt, response) {
309
+ const out = [{ role: 'user', content: userPrompt }];
310
+ for (const step of response.steps) {
311
+ out.push(step.message);
312
+ for (const tr of step.toolResults) {
313
+ const resultStr = typeof tr.result === 'string' ? tr.result : JSON.stringify(tr.result);
314
+ out.push({ role: 'tool', content: resultStr, toolCallId: tr.toolCallId });
315
+ }
316
+ }
317
+ return out;
318
+ }
319
+ /**
320
+ * Snapshot reconstruction for a resume-time pause. The `priorMessages`
321
+ * already include the original user prompt + every step prior to the
322
+ * resume call. Append the freshly-completed steps' messages and any
323
+ * server-side tool results so the next resume sees the full history.
324
+ */
325
+ function buildResumeSnapshotMessages(priorMessages, response) {
326
+ const out = [...priorMessages];
327
+ for (const step of response.steps) {
328
+ out.push(step.message);
329
+ for (const tr of step.toolResults) {
330
+ const resultStr = typeof tr.result === 'string' ? tr.result : JSON.stringify(tr.result);
331
+ out.push({ role: 'tool', content: resultStr, toolCallId: tr.toolCallId });
332
+ }
333
+ }
334
+ return out;
335
+ }
336
+ function generateSubRunId() {
337
+ if (typeof globalThis.crypto?.randomUUID === 'function')
338
+ return globalThis.crypto.randomUUID();
339
+ return `sub-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 12)}`;
121
340
  }
122
341
  // ─── Conversable Agent (conversation persistence) ───────
123
342
  /**
@@ -140,84 +359,35 @@ export class ConversableAgent {
140
359
  return this;
141
360
  }
142
361
  async prompt(input, options) {
143
- const store = resolveConversationStore();
144
- if (!store)
145
- throw new Error('[RudderJS AI] No ConversationStore registered. Register one via the DI container with key "ai.conversations".');
146
- // Load or create conversation
147
- let history = options?.history ?? [];
148
- if (this._conversationId) {
149
- history = [...(await store.load(this._conversationId)), ...history];
150
- }
151
- else {
152
- const meta = this._userId ? { userId: this._userId } : undefined;
153
- this._conversationId = await store.create(undefined, meta);
154
- }
155
- const response = await runAgentLoop(this.agent, input, { ...options, history });
156
- // Persist messages
157
- const newMessages = [
158
- { role: 'user', content: input },
159
- ...response.steps.flatMap(s => {
160
- const msgs = [s.message];
161
- for (const tr of s.toolResults) {
162
- const resultStr = typeof tr.result === 'string' ? tr.result : JSON.stringify(tr.result);
163
- msgs.push({ role: 'tool', content: resultStr, toolCallId: tr.toolCallId });
164
- }
165
- return msgs;
166
- }),
167
- ];
168
- await store.append(this._conversationId, newMessages);
169
- return { text: response.text, steps: response.steps, usage: response.usage, conversationId: this._conversationId };
362
+ const spec = this.toSpec();
363
+ return runWithPersistence(spec, this.agent.constructor.name, resolveConversationStore, input, options, (effOptions) => runAgentLoop(this.agent, input, effOptions)).then((r) => {
364
+ // Track the resolved id back on the wrapper so a subsequent
365
+ // `wrapper.prompt()` call resumes the same thread.
366
+ if (r.conversationId)
367
+ this._conversationId = r.conversationId;
368
+ return r;
369
+ });
170
370
  }
171
371
  stream(input, options) {
172
- const store = resolveConversationStore();
173
- if (!store)
174
- throw new Error('[RudderJS AI] No ConversationStore registered. Register one via the DI container with key "ai.conversations".');
175
- // We need to handle async setup, so wrap the streaming
176
- let resolveReady;
177
- const ready = new Promise(r => { resolveReady = r; });
178
- let loadedHistory = [];
179
- let convId = this._conversationId;
180
- // Kick off async setup
181
- const setupPromise = (async () => {
182
- if (convId) {
183
- loadedHistory = await store.load(convId);
184
- }
185
- else {
186
- const meta = this._userId ? { userId: this._userId } : undefined;
187
- convId = await store.create(undefined, meta);
188
- this._conversationId = convId;
189
- }
190
- resolveReady();
191
- })();
192
- let resolveResponse;
193
- const responsePromise = new Promise(r => { resolveResponse = r; });
194
- const self = this; // eslint-disable-line @typescript-eslint/no-this-alias
195
- const storeRef = store;
196
- async function* generateStream() {
197
- await setupPromise;
198
- const history = [...loadedHistory, ...(options?.history ?? [])];
199
- const inner = runAgentLoopStreaming(self.agent, input, { ...options, history });
200
- for await (const chunk of inner.stream) {
201
- yield chunk;
202
- }
203
- const response = await inner.response;
204
- // Persist messages
205
- const newMessages = [
206
- { role: 'user', content: input },
207
- ...response.steps.flatMap(s => {
208
- const msgs = [s.message];
209
- for (const tr of s.toolResults) {
210
- const resultStr = typeof tr.result === 'string' ? tr.result : JSON.stringify(tr.result);
211
- msgs.push({ role: 'tool', content: resultStr, toolCallId: tr.toolCallId });
212
- }
213
- return msgs;
214
- }),
215
- ];
216
- await storeRef.append(convId, newMessages);
217
- const result = { text: response.text, steps: response.steps, usage: response.usage, conversationId: convId };
218
- resolveResponse(result);
219
- }
220
- return { stream: generateStream(), response: responsePromise };
372
+ const spec = this.toSpec();
373
+ const persisted = runWithPersistenceStreaming(spec, this.agent.constructor.name, resolveConversationStore, input, options, (effOptions) => runAgentLoopStreaming(this.agent, input, effOptions));
374
+ // Update the wrapper's id once the run completes.
375
+ persisted.response.then((r) => { if (r.conversationId)
376
+ this._conversationId = r.conversationId; }, () => { });
377
+ return persisted;
378
+ }
379
+ /**
380
+ * Translate the wrapper's explicit-form state (`forUser` / `continue`)
381
+ * into a {@link ConversationalSpec}. The explicit chain bypasses the
382
+ * agent's `conversational()` declaration entirely — `forUser` always
383
+ * wins over class defaults.
384
+ */
385
+ toSpec() {
386
+ if (this._conversationId)
387
+ return { user: this._userId ?? '', id: this._conversationId };
388
+ if (this._userId)
389
+ return { user: this._userId };
390
+ throw new Error('[RudderJS AI] ConversableAgent requires forUser() or continue() to be called before prompt().');
221
391
  }
222
392
  }
223
393
  // ─── Anonymous Agent ─────────────────────────────────────
@@ -267,6 +437,76 @@ export function setConversationStore(store) {
267
437
  function resolveConversationStore() {
268
438
  return _conversationStore;
269
439
  }
440
+ /**
441
+ * Streaming counterpart of `Agent.prompt`'s auto-persist branch. The spec
442
+ * resolution is async (since `conversational()` may return a Promise), so
443
+ * we defer the decision into the outer wrapper that handles the inner
444
+ * stream's setup the same way `runWithPersistenceStreaming` does for the
445
+ * persisted path.
446
+ */
447
+ 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)));
454
+ if (isFast) {
455
+ return runAgentLoopStreaming(a, input, options);
456
+ }
457
+ // Async path — resolve the spec, then dispatch to the persisted or plain stream.
458
+ let resolveResp;
459
+ let rejectResp;
460
+ const responsePromise = new Promise((res, rej) => { resolveResp = res; rejectResp = rej; });
461
+ async function* outer() {
462
+ let spec;
463
+ try {
464
+ spec = await resolveAutoPersistSpec(() => a.conversational(), options?.conversation);
465
+ }
466
+ catch (err) {
467
+ rejectResp(err);
468
+ throw err;
469
+ }
470
+ if (!spec) {
471
+ const inner = runAgentLoopStreaming(a, input, options);
472
+ try {
473
+ for await (const chunk of inner.stream)
474
+ yield chunk;
475
+ }
476
+ catch (err) {
477
+ rejectResp(err);
478
+ throw err;
479
+ }
480
+ try {
481
+ const r = await inner.response;
482
+ resolveResp(r);
483
+ }
484
+ catch (err) {
485
+ rejectResp(err);
486
+ throw err;
487
+ }
488
+ return;
489
+ }
490
+ const persisted = runWithPersistenceStreaming(spec, a.constructor.name, resolveConversationStore, input, options, (effOptions) => runAgentLoopStreaming(a, input, effOptions));
491
+ try {
492
+ for await (const chunk of persisted.stream)
493
+ yield chunk;
494
+ }
495
+ catch (err) {
496
+ rejectResp(err);
497
+ throw err;
498
+ }
499
+ try {
500
+ const r = await persisted.response;
501
+ resolveResp(r);
502
+ }
503
+ catch (err) {
504
+ rejectResp(err);
505
+ throw err;
506
+ }
507
+ }
508
+ return { stream: outer(), response: responsePromise };
509
+ }
270
510
  // ─── Helpers ─────────────────────────────────────────────
271
511
  function getTools(a) {
272
512
  return 'tools' in a && typeof a.tools === 'function'