@economic/agents 2.1.7 → 2.2.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/dist/v1.mjs ADDED
@@ -0,0 +1,931 @@
1
+ import { n as extractTokenFromConnectRequest, r as verifyJwt, t as createAgentTracer } from "./telemetry-BDxmv_R7.mjs";
2
+ import { Output, convertToModelMessages, generateText, jsonSchema, stepCountIs, streamText, tool } from "ai";
3
+ import { Agent as Agent$1, callable, getCurrentAgent, routeAgentRequest as routeAgentRequest$1 } from "agents";
4
+ import { AIChatAgent } from "@cloudflare/ai-chat";
5
+ //#region src/server/shared/features/skills/index.ts
6
+ const TOOL_NAME_ACTIVATE_SKILL = "activate_skill";
7
+ const TOOL_NAME_LIST_CAPABILITIES = "list_capabilities";
8
+ const SKILL_STATE_SENTINEL = "\n__SKILLS_STATE__:";
9
+ function buildActivateSkillDescription(skills) {
10
+ return [
11
+ "Load additional skills to help with the user's request.",
12
+ "Call this BEFORE attempting actions that need tools from unloaded skills.",
13
+ "",
14
+ "Available skills:",
15
+ skills.map((s) => `• ${s.name} — ${s.description}`).join("\n")
16
+ ].join("\n");
17
+ }
18
+ function buildAvailableSkillList(skills) {
19
+ return skills.map((s) => `**${s.name}**: ${s.description}`).join("\n");
20
+ }
21
+ const LIST_CAPABILITIES_DESCRIPTION = "List all tools currently available to you, which skills are loaded, and which can still be loaded. Call this when the user asks about your capabilities or what you can do.";
22
+ /**
23
+ * Creates a skill loading system for use with the Vercel AI SDK.
24
+ *
25
+ * The agent starts with only its always-on tools active. The LLM can call
26
+ * activate_skill to load skill tools on demand. Which skills are loaded is
27
+ * communicated to the CF layer via a sentinel in the activate_skill result
28
+ * and persisted to DO SQLite — no D1 or message-history parsing required.
29
+ */
30
+ function createSkills(config) {
31
+ const { tools: alwaysOnTools, skills } = config;
32
+ const loadedSkills = new Set(config.initialLoadedSkills ?? []);
33
+ const skillMap = new Map(skills.map((s) => [s.name, s]));
34
+ const availableSkillList = skills.length > 0 ? buildAvailableSkillList(skills) : "";
35
+ const allTools = {};
36
+ Object.assign(allTools, alwaysOnTools);
37
+ for (const skill of skills) Object.assign(allTools, skill.tools);
38
+ function getActiveToolNames() {
39
+ const names = [
40
+ TOOL_NAME_ACTIVATE_SKILL,
41
+ TOOL_NAME_LIST_CAPABILITIES,
42
+ ...Object.keys(alwaysOnTools)
43
+ ];
44
+ for (const skillName of loadedSkills) {
45
+ const skill = skillMap.get(skillName);
46
+ if (!skill) continue;
47
+ for (const toolName of Object.keys(skill.tools)) if (!names.includes(toolName)) names.push(toolName);
48
+ }
49
+ return names;
50
+ }
51
+ function getLoadedGuidance() {
52
+ const sections = [...loadedSkills].flatMap((name) => {
53
+ const skill = skillMap.get(name);
54
+ if (!skill?.guidance) return [];
55
+ return [`**${skill.name}**\n${skill.guidance}`];
56
+ });
57
+ if (sections.length === 0) return "";
58
+ return sections.join("\n\n");
59
+ }
60
+ allTools[TOOL_NAME_ACTIVATE_SKILL] = tool({
61
+ description: buildActivateSkillDescription(skills),
62
+ inputSchema: jsonSchema({
63
+ type: "object",
64
+ properties: { skills: {
65
+ type: "array",
66
+ items: {
67
+ type: "string",
68
+ enum: skills.map((s) => s.name)
69
+ },
70
+ description: "Skills to load"
71
+ } },
72
+ required: ["skills"]
73
+ }),
74
+ execute: async ({ skills: requested }) => {
75
+ const newlyLoaded = [];
76
+ for (const skillName of requested) {
77
+ if (!skillMap.get(skillName)) continue;
78
+ if (loadedSkills.has(skillName)) continue;
79
+ loadedSkills.add(skillName);
80
+ newlyLoaded.push(skillName);
81
+ }
82
+ if (newlyLoaded.length > 0) return `Loaded: ${newlyLoaded.join(", ")}.${SKILL_STATE_SENTINEL}${JSON.stringify([...loadedSkills])}`;
83
+ return "All requested skills were already loaded.";
84
+ }
85
+ });
86
+ allTools[TOOL_NAME_LIST_CAPABILITIES] = tool({
87
+ description: LIST_CAPABILITIES_DESCRIPTION,
88
+ inputSchema: jsonSchema({
89
+ type: "object",
90
+ properties: {},
91
+ required: []
92
+ }),
93
+ execute: async () => {
94
+ const activeNames = getActiveToolNames();
95
+ const loadedNames = [...loadedSkills];
96
+ const unloaded = skills.filter((s) => !loadedSkills.has(s.name)).map((s) => s.name);
97
+ return [
98
+ `Active tools (${activeNames.length}): ${activeNames.join(", ")}`,
99
+ `Loaded skills: ${loadedNames.length > 0 ? loadedNames.join(", ") : "none"}`,
100
+ `Available to load: ${unloaded.length > 0 ? unloaded.join(", ") : "none"}`
101
+ ].join("\n");
102
+ }
103
+ });
104
+ const prepareStep = async () => {
105
+ return { activeTools: getActiveToolNames() };
106
+ };
107
+ return {
108
+ tools: allTools,
109
+ activeTools: getActiveToolNames(),
110
+ prepareStep,
111
+ availableSkillList,
112
+ getLoadedGuidance,
113
+ getLoadedSkills() {
114
+ return [...loadedSkills];
115
+ }
116
+ };
117
+ }
118
+ function isEphemeralSkillToolPart(part) {
119
+ if (!part || typeof part !== "object" || !("toolCallId" in part) || !("type" in part)) return false;
120
+ const { type } = part;
121
+ return type === `tool-list_capabilities` || type === `tool-activate_skill`;
122
+ }
123
+ /** Creates the `skill_state` table in DO SQLite if it does not exist yet. */
124
+ function ensureSkillTable(sql) {
125
+ sql`CREATE TABLE IF NOT EXISTS skill_state (id INTEGER PRIMARY KEY, active_skills TEXT NOT NULL DEFAULT '[]')`;
126
+ }
127
+ /**
128
+ * Reads the persisted list of loaded skill names from DO SQLite.
129
+ * Returns an empty array if the table is missing or the row does not exist.
130
+ */
131
+ function getStoredSkills(sql) {
132
+ try {
133
+ ensureSkillTable(sql);
134
+ const rows = sql`SELECT active_skills FROM skill_state WHERE id = 1`;
135
+ if (rows.length === 0) return [];
136
+ return JSON.parse(rows[0].active_skills);
137
+ } catch {
138
+ return [];
139
+ }
140
+ }
141
+ /**
142
+ * Persists the current list of loaded skill names to DO SQLite.
143
+ * Upserts the single `skill_state` row (id = 1).
144
+ */
145
+ function saveSkillStateFromMessages(sql, messages) {
146
+ let latestSkillState;
147
+ for (const msg of messages) {
148
+ if (msg.role !== "assistant" || !msg.parts) continue;
149
+ for (const part of msg.parts) {
150
+ if (!("toolCallId" in part)) continue;
151
+ if (part.type !== `tool-activate_skill` || typeof part.output !== "string") continue;
152
+ const sentinelIndex = part.output.indexOf(SKILL_STATE_SENTINEL);
153
+ if (sentinelIndex !== -1) try {
154
+ const stateJson = part.output.slice(sentinelIndex + 18);
155
+ latestSkillState = JSON.parse(stateJson);
156
+ } catch {}
157
+ }
158
+ }
159
+ if (latestSkillState == void 0) return;
160
+ ensureSkillTable(sql);
161
+ sql`INSERT OR REPLACE INTO skill_state(id, active_skills) VALUES(1, ${JSON.stringify(latestSkillState)})`;
162
+ }
163
+ //#endregion
164
+ //#region src/server/shared/util/llm.ts
165
+ function buildSystemPromptWithSkills(basePrompt, availableSkillList, loadedGuidance) {
166
+ let prompt = `${basePrompt}
167
+
168
+ ## Tools
169
+
170
+ ### Loading More Tools:
171
+ Use \`activate_skill\` to load these skills. NEVER ask the user for permission to load a skill - just load it silently when relevant. The user should not be aware of skills or tools; activate them autonomously based on the request:
172
+
173
+ ${availableSkillList}`;
174
+ if (loadedGuidance) prompt += `
175
+
176
+ ### Loaded skill instructions:
177
+ The following skills are currently active. Apply their instructions when using the corresponding tools.
178
+
179
+ ${loadedGuidance}`;
180
+ return prompt.trim();
181
+ }
182
+ /**
183
+ * Convention: tools can include a `sources` array in their return value to surface
184
+ * source URLs in the message stream. This transform detects it automatically.
185
+ *
186
+ * ```typescript
187
+ * // In any tool's execute function:
188
+ * return { myContent: "...", sources: [{ url: "https://...", title: "Page title" }] };
189
+ * ```
190
+ */
191
+ function buildSourcesTransform(additional) {
192
+ const sourcesTransform = () => new TransformStream({ transform(chunk, controller) {
193
+ controller.enqueue(chunk);
194
+ if (chunk.type !== "tool-result") return;
195
+ const output = chunk.output;
196
+ if (!output || typeof output !== "object" || Array.isArray(output)) return;
197
+ const sources = output.sources;
198
+ if (!Array.isArray(sources)) return;
199
+ for (const source of sources) {
200
+ if (!source || typeof source !== "object") continue;
201
+ const s = source;
202
+ if (typeof s.url !== "string") continue;
203
+ controller.enqueue({
204
+ type: "source",
205
+ sourceType: "url",
206
+ id: crypto.randomUUID(),
207
+ url: s.url,
208
+ ...typeof s.title === "string" ? { title: s.title } : {}
209
+ });
210
+ }
211
+ } });
212
+ if (!additional) return sourcesTransform;
213
+ return [sourcesTransform, ...Array.isArray(additional) ? additional : [additional]];
214
+ }
215
+ /**
216
+ * Builds the parameter object for a Vercel AI SDK `streamText` or `generateText` call.
217
+ *
218
+ * Handles skill wiring (`activate_skill`, `list_capabilities`, `prepareStep`).
219
+ *
220
+ * The returned object can be spread directly into `streamText` or `generateText`:
221
+ *
222
+ * ```typescript
223
+ * const params = buildLLMParams({ ... });
224
+ * return streamText(params).toUIMessageStreamResponse();
225
+ * ```
226
+ */
227
+ function buildLLMParams(config) {
228
+ const { activeSkills = [], skills, experimental_transform, system, tools = {}, ...rest } = config;
229
+ const composedTransform = buildSourcesTransform(experimental_transform);
230
+ const baseParams = {
231
+ ...rest,
232
+ system,
233
+ tools,
234
+ stopWhen: rest.stopWhen ?? stepCountIs(20),
235
+ experimental_transform: composedTransform
236
+ };
237
+ if (!skills?.length) return baseParams;
238
+ const skillsCtx = createSkills({
239
+ tools,
240
+ skills,
241
+ initialLoadedSkills: activeSkills
242
+ });
243
+ const systemWithSkills = buildSystemPromptWithSkills(typeof system === "string" ? system : void 0, skillsCtx.availableSkillList, skillsCtx.getLoadedGuidance());
244
+ const prepareStep = async (stepOptions) => {
245
+ return {
246
+ activeTools: (await skillsCtx.prepareStep(stepOptions) ?? {}).activeTools ?? [],
247
+ system: systemWithSkills
248
+ };
249
+ };
250
+ return {
251
+ ...baseParams,
252
+ system: systemWithSkills,
253
+ tools: skillsCtx.tools,
254
+ activeTools: skillsCtx.activeTools,
255
+ prepareStep
256
+ };
257
+ }
258
+ //#endregion
259
+ //#region src/server/v1/route-agent-request.ts
260
+ async function routeAgentRequest(request, env, options) {
261
+ const response = await routeAgentRequest$1(request, env, options);
262
+ if (!response) return null;
263
+ const protocol = request.headers.get("Sec-WebSocket-Protocol");
264
+ if (response.status === 101 && protocol) {
265
+ const newResponse = new Response(null, response);
266
+ newResponse.headers.set("Sec-WebSocket-Protocol", protocol.split(",")[0].trim());
267
+ return newResponse;
268
+ }
269
+ return response;
270
+ }
271
+ //#endregion
272
+ //#region src/server/v1/agent/Agent.ts
273
+ /**
274
+ * Base agent for Cloudflare Agents SDK Durable Objects with lazy skill loading
275
+ * and `buildLLMParams` wiring.
276
+ *
277
+ * Handles CF infrastructure concerns: DO SQLite persistence for loaded skill state.
278
+ *
279
+ * For chat agents with message history, compaction, and conversation recording,
280
+ * extend {@link ChatAgent} instead.
281
+ */
282
+ var Agent = class extends Agent$1 {
283
+ clientIp;
284
+ forwardedFor;
285
+ /**
286
+ * The user context for the session.
287
+ * Define getUserContext to set a user context.
288
+ */
289
+ get userContext() {
290
+ const { connection } = getCurrentAgent();
291
+ return (connection?.state)?.userContext ?? {};
292
+ }
293
+ /**
294
+ * Returns the user ID from the durable object name.
295
+ */
296
+ getUserId() {
297
+ return this.name.split(":")[0];
298
+ }
299
+ async onConnect(connection, ctx) {
300
+ this.clientIp = ctx.request.headers.get("CF-Connecting-IP") ?? ctx.request.headers.get("X-Forwarded-For")?.split(",")[0]?.trim();
301
+ this.forwardedFor = ctx.request.headers.get("X-Forwarded-For") ?? void 0;
302
+ if (!this.env.AGENT_DB) {
303
+ console.error("[Agent] Connection rejected: no AGENT_DB bound");
304
+ connection.close(3e3, "Could not connect to agent");
305
+ return;
306
+ }
307
+ if (!this.env.AGENTS_AUDIT_LOGS) {
308
+ console.error("[Agent] Connection rejected: no AGENTS_AUDIT_LOGS bound. Audit logs are required.");
309
+ connection.close(3e3, "Could not connect to agent");
310
+ return;
311
+ }
312
+ if (!this.env.AGENTS_ANALYTICS) console.warn("[Agent] No AGENTS_ANALYTICS bound. Analytics will not be collected.");
313
+ if (!this.getUserId()) {
314
+ console.error("[Agent] Connection rejected: name must be in the format userId:uniqueChatId");
315
+ connection.close(3e3, "Could not connect to agent");
316
+ return;
317
+ }
318
+ if (this.getJwtAuthConfig) {
319
+ const config = this.getJwtAuthConfig(ctx.request);
320
+ if (config) {
321
+ let result;
322
+ try {
323
+ result = await verifyJwt(ctx.request, config);
324
+ } catch (error) {
325
+ console.error("[Agent] JWT verification error", error);
326
+ connection.close(4001, "Unauthorized");
327
+ return;
328
+ }
329
+ if (!result.success) {
330
+ connection.close(result.status === 401 ? 4001 : 4003, result.message);
331
+ return;
332
+ }
333
+ const token = extractTokenFromConnectRequest(ctx.request);
334
+ if (token) {
335
+ const userContext = await this.getUserContext?.(token);
336
+ if (userContext) connection.setState({ userContext });
337
+ }
338
+ }
339
+ }
340
+ return super.onConnect(connection, ctx);
341
+ }
342
+ /**
343
+ * Builds the parameter object for a `streamText` or `generateText` call,
344
+ * pre-filling `activeSkills` from this agent instance.
345
+ */
346
+ async buildLLMParams(config) {
347
+ const activeSkills = await getStoredSkills(this.sql.bind(this));
348
+ const experimental_context = {
349
+ ...config.experimental_context,
350
+ ...config.options?.body,
351
+ _userContext: this.userContext
352
+ };
353
+ return buildLLMParams({
354
+ ...config,
355
+ activeSkills,
356
+ experimental_context,
357
+ experimental_telemetry: {
358
+ ...config.experimental_telemetry,
359
+ isEnabled: true,
360
+ tracer: createAgentTracer(this.env.AGENTS_AUDIT_LOGS, this.env.AGENTS_ANALYTICS, {
361
+ agentName: this.constructor.name,
362
+ durableObjectName: this.name,
363
+ actorId: this.getUserId(),
364
+ ...this.clientIp ? { clientIp: this.clientIp } : {},
365
+ ...this.forwardedFor ? { forwardedFor: this.forwardedFor } : {}
366
+ }),
367
+ metadata: {
368
+ agentName: this.constructor.name,
369
+ version: "v1",
370
+ durableObjectName: this.name,
371
+ actorId: this.getUserId(),
372
+ ...this.clientIp ? { clientIp: this.clientIp } : {},
373
+ ...this.forwardedFor ? { forwardedFor: this.forwardedFor } : {},
374
+ ...config.experimental_telemetry?.metadata
375
+ }
376
+ }
377
+ });
378
+ }
379
+ };
380
+ const TOOL_RESULT_PREVIEW_CHARS = 200;
381
+ const SUMMARY_MAX_TOKENS = 4e3;
382
+ /**
383
+ * Estimates token count for a message array using a 3.5 chars/token
384
+ * approximation. Counts text from text/reasoning parts, tool inputs/outputs.
385
+ */
386
+ function estimateMessagesTokens(messages) {
387
+ let totalChars = 0;
388
+ for (const msg of messages) {
389
+ if (typeof msg.content === "string") {
390
+ totalChars += msg.content.length;
391
+ continue;
392
+ }
393
+ for (const part of msg.content) if (part.type === "text" || part.type === "reasoning") totalChars += part.text.length;
394
+ else if (part.type === "tool-call") totalChars += JSON.stringify(part.input).length;
395
+ else if (part.type === "tool-result") {
396
+ const output = part.output;
397
+ totalChars += typeof output === "string" ? output.length : JSON.stringify(output).length;
398
+ }
399
+ }
400
+ return Math.ceil(totalChars / 3.5);
401
+ }
402
+ /**
403
+ * Renders messages as human-readable text for the compaction summary prompt.
404
+ */
405
+ function formatMessagesForSummary(messages) {
406
+ const lines = [];
407
+ for (const msg of messages) {
408
+ const roleLabel = msg.role.charAt(0).toUpperCase() + msg.role.slice(1);
409
+ const parts = [];
410
+ if (typeof msg.content === "string") {
411
+ if (msg.content.trim()) parts.push(msg.content.trim());
412
+ } else for (const part of msg.content) if (part.type === "text" || part.type === "reasoning") {
413
+ const text = part.text.trim();
414
+ if (text) parts.push(text);
415
+ } else if (part.type === "tool-call") {
416
+ const p = part;
417
+ parts.push(`[Tool call: ${p.toolName}]`);
418
+ } else if (part.type === "tool-result") {
419
+ const p = part;
420
+ const rawOutput = typeof p.output === "string" ? p.output : JSON.stringify(p.output);
421
+ const preview = rawOutput.slice(0, TOOL_RESULT_PREVIEW_CHARS);
422
+ const ellipsis = rawOutput.length > TOOL_RESULT_PREVIEW_CHARS ? "..." : "";
423
+ parts.push(`[Tool: ${p.toolName}, result: ${preview}${ellipsis}]`);
424
+ }
425
+ if (parts.length > 0) lines.push(`${roleLabel}: ${parts.join(" ")}`);
426
+ }
427
+ return lines.join("\n");
428
+ }
429
+ /**
430
+ * Calls the model to produce a concise summary of old + recent message windows.
431
+ */
432
+ async function generateCompactionSummary(oldMessages, recentMessages, model) {
433
+ const prompt = `Summarize this conversation history concisely for an AI assistant to continue the conversation.
434
+ Focus MORE on recent exchanges (what the user was working on, what tools were used, what was found).
435
+ Include key facts, decisions, and context needed to continue the conversation.
436
+ Keep entity names, numbers, file paths, and specific details that might be referenced later.
437
+ Do NOT include pleasantries or meta-commentary - just the essential context.
438
+
439
+ OLDER MESSAGES (summarize briefly):
440
+ ${formatMessagesForSummary(oldMessages)}
441
+
442
+ RECENT MESSAGES (summarize with more detail - this is where the user currently is):
443
+ ${formatMessagesForSummary(recentMessages)}
444
+
445
+ Write a concise summary:`;
446
+ try {
447
+ const { text } = await generateText({
448
+ model,
449
+ messages: [{
450
+ role: "user",
451
+ content: prompt
452
+ }],
453
+ maxOutputTokens: SUMMARY_MAX_TOKENS
454
+ });
455
+ return text || "Unable to summarize conversation history.";
456
+ } catch (error) {
457
+ console.error("Compaction summarization error:", error);
458
+ return "Unable to summarize conversation history.";
459
+ }
460
+ }
461
+ /**
462
+ * Summarizes older messages into a single system message and appends the
463
+ * recent verbatim tail. Returns messages unchanged if already short enough.
464
+ */
465
+ async function compactMessages(messages, model, tailSize) {
466
+ if (messages.length <= tailSize) return messages;
467
+ const splitIndex = messages.length - tailSize;
468
+ const oldMessages = messages.slice(0, splitIndex);
469
+ const recentTail = messages.slice(splitIndex);
470
+ return [{
471
+ role: "system",
472
+ content: `[Conversation summary - older context was compacted]\n${await generateCompactionSummary(oldMessages, recentTail, model)}`
473
+ }, ...recentTail];
474
+ }
475
+ /**
476
+ * Entry point for compaction. Returns messages unchanged when model is
477
+ * undefined or estimated token count is under COMPACT_TOKEN_THRESHOLD.
478
+ */
479
+ async function compactIfNeeded(messages, model, tailSize) {
480
+ if (!model || estimateMessagesTokens(messages) <= 14e4) return messages;
481
+ return compactMessages(messages, model, tailSize);
482
+ }
483
+ //#endregion
484
+ //#region src/server/v1/agent-chat/features/conversations/conversations.ts
485
+ /**
486
+ * Renders only the user- and assistant-visible text parts of a message
487
+ * array for conversation title/summary generation.
488
+ *
489
+ * Tool calls, tool results, system messages, and reasoning parts are
490
+ * intentionally excluded so the model receives a clean transcript.
491
+ */
492
+ function formatMessagesForConversationSummary(messages) {
493
+ const lines = [];
494
+ for (const message of messages) {
495
+ if (message.role !== "user" && message.role !== "assistant") continue;
496
+ let text = "";
497
+ if (typeof message.content === "string") text = message.content.trim();
498
+ else text = message.content.filter((part) => part.type === "text").map((part) => part.text.trim()).filter(Boolean).join(" ");
499
+ if (!text) continue;
500
+ const roleLabel = message.role === "user" ? "User" : "Assistant";
501
+ lines.push(`${roleLabel}: ${text}`);
502
+ }
503
+ return lines.join("\n");
504
+ }
505
+ /**
506
+ * Number of recent messages passed to `generateSummary` for rolling
507
+ * summarization. Keeping this bounded prevents the prompt growing
508
+ * unboundedly regardless of conversation length.
509
+ */
510
+ const SUMMARY_CONTEXT_MESSAGES = 15;
511
+ async function recordConversationSummary(db, durableObjectName, messages, model) {
512
+ if (!await getConversationSummary(db, durableObjectName)) {
513
+ const { title, summary } = await generateTitleAndSummary(messages, model);
514
+ await upsertConversationSummary(db, durableObjectName, title, summary);
515
+ } else if (messages.length <= 7 || messages.length % SUMMARY_CONTEXT_MESSAGES === 0) await generateConversationSummary(db, durableObjectName, messages, model);
516
+ }
517
+ /**
518
+ * Records a conversation row in the `conversations` D1 table.
519
+ *
520
+ * Called by `ChatAgentHarness` after every turn. On first call for a given
521
+ * `durableObjectName` the row is inserted with `created_at` set to now,
522
+ * and with the provided `title` and `summary` if supplied.
523
+ * On subsequent calls only `updated_at` is refreshed —
524
+ * `created_at`, `title`, and `summary` are never overwritten, preserving
525
+ * any user edits.
526
+ */
527
+ async function upsertConversationSummary(db, durableObjectName, title, summary) {
528
+ const now = (/* @__PURE__ */ new Date()).toISOString();
529
+ await db.prepare(`INSERT INTO conversations (durable_object_name, title, summary, created_at, updated_at)
530
+ VALUES (?, ?, ?, ?, ?)
531
+ ON CONFLICT(durable_object_name) DO UPDATE SET
532
+ updated_at = excluded.updated_at`).bind(durableObjectName, title ?? null, summary ?? null, now, now).run();
533
+ }
534
+ /**
535
+ * Returns the current `title` and `summary` for a conversation row,
536
+ * or `null` if the row does not exist yet.
537
+ */
538
+ async function getConversationSummary(db, durableObjectName) {
539
+ return await db.prepare(`SELECT title, summary FROM conversations WHERE durable_object_name = ?`).bind(durableObjectName).first() ?? null;
540
+ }
541
+ /**
542
+ * Returns all conversations for a user, ordered by most recent.
543
+ */
544
+ async function getConversations(db, userId) {
545
+ const { results } = await db.prepare(`SELECT * FROM conversations WHERE durable_object_name LIKE ? ORDER BY updated_at DESC`).bind(`${userId}:%`).all();
546
+ return results;
547
+ }
548
+ /**
549
+ * Deletes a conversation row from the `conversations` D1 table.
550
+ */
551
+ async function deleteConversationRow(db, durableObjectName) {
552
+ await db.prepare(`DELETE FROM conversations WHERE durable_object_name = ?`).bind(durableObjectName).run();
553
+ }
554
+ /**
555
+ * Writes a generated `title` and `summary` back to the `conversations` row.
556
+ */
557
+ async function updateConversationSummary(db, durableObjectName, title, summary) {
558
+ await db.prepare(`UPDATE conversations SET title = ?, summary = ? WHERE durable_object_name = ?`).bind(title, summary, durableObjectName).run();
559
+ }
560
+ /**
561
+ * Generates a title and summary for a conversation using the provided model.
562
+ * Returns the result without writing to D1.
563
+ *
564
+ * Pass `existingSummary` so the model can detect direction changes when
565
+ * updating an existing summary. Omit it (or pass undefined) for the initial
566
+ * generation.
567
+ *
568
+ * Only the last `SUMMARY_CONTEXT_MESSAGES` messages are used to keep the
569
+ * prompt bounded regardless of total conversation length.
570
+ */
571
+ async function generateTitleAndSummary(messages, model, existingSummary) {
572
+ const recentMessages = await convertToModelMessages(messages.slice(-SUMMARY_CONTEXT_MESSAGES));
573
+ const previousContext = existingSummary ? `Previous summary:\n${existingSummary}\n\nMost recent messages:` : "Conversation:";
574
+ const { output } = await generateText({
575
+ model,
576
+ output: Output.object({ schema: jsonSchema({
577
+ type: "object",
578
+ properties: {
579
+ title: {
580
+ type: "string",
581
+ description: "Short title for the conversation, max 8 words"
582
+ },
583
+ summary: {
584
+ type: "string",
585
+ description: "1-2 short sentence summary of the conversation. If the conversation direction has changed from the previous summary, reflect the new direction."
586
+ }
587
+ },
588
+ required: ["title", "summary"]
589
+ }) }),
590
+ prompt: `${previousContext}\n\n${formatMessagesForConversationSummary(recentMessages)}`
591
+ });
592
+ return output;
593
+ }
594
+ /**
595
+ * Generates a title and summary for a conversation using the provided model
596
+ * and writes the result back to D1.
597
+ *
598
+ * Fetches any existing summary first so the model can detect direction changes.
599
+ * Only the last `SUMMARY_CONTEXT_MESSAGES` messages are passed to keep the
600
+ * prompt bounded regardless of total conversation length.
601
+ *
602
+ * Called by `ChatAgentHarness` every `SUMMARY_CONTEXT_MESSAGES` messages after
603
+ * the first turn.
604
+ */
605
+ async function generateConversationSummary(db, durableObjectName, messages, model) {
606
+ const { title, summary } = await generateTitleAndSummary(messages, model, (await getConversationSummary(db, durableObjectName))?.summary ?? void 0);
607
+ await updateConversationSummary(db, durableObjectName, title, summary);
608
+ return {
609
+ title,
610
+ summary
611
+ };
612
+ }
613
+ //#endregion
614
+ //#region src/server/v1/agent-chat/features/conversations/retention.ts
615
+ const DELETE_CONVERSATION_CALLBACK = "deleteConversationCallback";
616
+ const CONVERSATION_EXPIRED_CLOSE_CODE = 3001;
617
+ const CONVERSATION_EXPIRED_CLOSE_REASON = "Conversation expired due to inactivity.";
618
+ function getConversationRetentionMs(days) {
619
+ if (typeof days !== "number" || !Number.isFinite(days) || days <= 0) return null;
620
+ return Math.floor(days * 24 * 60 * 60 * 1e3);
621
+ }
622
+ function getDeleteConversationScheduleIds(schedules) {
623
+ return schedules.filter((schedule) => schedule.callback === DELETE_CONVERSATION_CALLBACK).map((schedule) => schedule.id);
624
+ }
625
+ //#endregion
626
+ //#region src/server/shared/util/messages.ts
627
+ function filterEphemeralMessages(messages) {
628
+ return messages.flatMap((message) => {
629
+ if (message.role !== "assistant" || !message.parts?.length) return [message];
630
+ const filteredParts = message.parts.filter((part) => !isEphemeralSkillToolPart(part));
631
+ if (filteredParts.length === 0) return [];
632
+ return [{
633
+ ...message,
634
+ parts: filteredParts
635
+ }];
636
+ });
637
+ }
638
+ //#endregion
639
+ //#region src/server/v1/agent-chat/features/conversations/rating.ts
640
+ async function rateMessage(db, durableObjectName, messageId, rating, comment) {
641
+ const now = (/* @__PURE__ */ new Date()).toISOString();
642
+ await db.prepare(`INSERT INTO message_ratings (message_id, durable_object_name, rating, comment, created_at, updated_at)
643
+ VALUES (?, ?, ?, ?, ?, ?)
644
+ ON CONFLICT (message_id, durable_object_name) DO UPDATE SET
645
+ rating = excluded.rating,
646
+ comment = excluded.comment,
647
+ updated_at = excluded.updated_at`).bind(messageId, durableObjectName, rating, comment ?? null, now, now).run();
648
+ }
649
+ async function getMessageRatings(db, durableObjectName) {
650
+ const result = await db.prepare(`SELECT message_id, rating, comment FROM message_ratings WHERE durable_object_name = ?`).bind(durableObjectName).all();
651
+ return Object.fromEntries(result.results.map((row) => [row.message_id, {
652
+ rating: row.rating,
653
+ ...row.comment != null ? { comment: row.comment } : {}
654
+ }]));
655
+ }
656
+ //#endregion
657
+ //#region src/server/v1/agent-chat/ChatAgent.ts
658
+ /**
659
+ * Chat agent for Cloudflare Agents SDK: lazy skill loading, message persistence,
660
+ * compaction, and conversation metadata in D1.
661
+ *
662
+ * Handles CF infrastructure concerns: DO SQLite for loaded skill state,
663
+ * stripping skill meta-tool messages before persistence, and history replay to
664
+ * newly connected clients.
665
+ *
666
+ * Skill loading, compaction, and LLM calls use `buildLLMParams` from
667
+ * `@economic/agents` inside `onChatMessage`.
668
+ */
669
+ var ChatAgent = class extends AIChatAgent {
670
+ initialState = {
671
+ status: "connecting",
672
+ type: "chat"
673
+ };
674
+ /**
675
+ * Number of days of inactivity before the full conversation is deleted.
676
+ *
677
+ * Leave `undefined` to disable automatic retention cleanup.
678
+ */
679
+ conversationRetentionDays;
680
+ /**
681
+ * Number of recent messages to keep verbatim when compaction runs.
682
+ * Older messages beyond this count are summarised into a single system message.
683
+ * Used as the default when `maxMessagesBeforeCompaction` is not provided to `buildLLMParams`.
684
+ *
685
+ * Default is 15.
686
+ */
687
+ maxMessagesBeforeCompaction = 15;
688
+ clientIp;
689
+ forwardedFor;
690
+ /**
691
+ * The user context for the session.
692
+ * Define getUserContext to set a user context.
693
+ */
694
+ get userContext() {
695
+ const { connection } = getCurrentAgent();
696
+ return (connection?.state)?.userContext ?? {};
697
+ }
698
+ /**
699
+ * Returns the user ID from the durable object name.
700
+ */
701
+ getUserId() {
702
+ return this.name.split(":")[0];
703
+ }
704
+ onStart() {
705
+ this.setState({
706
+ ...this.initialState,
707
+ status: "connecting"
708
+ });
709
+ }
710
+ async onClose() {
711
+ this.setState({
712
+ type: "chat",
713
+ status: "disconnected"
714
+ });
715
+ }
716
+ async onConnect(connection, ctx) {
717
+ this.clientIp = ctx.request.headers.get("CF-Connecting-IP") ?? ctx.request.headers.get("X-Forwarded-For")?.split(",")[0]?.trim();
718
+ this.forwardedFor = ctx.request.headers.get("X-Forwarded-For") ?? void 0;
719
+ if (!this.env.AGENT_DB) {
720
+ console.error("[Agent] Connection rejected: no AGENT_DB bound");
721
+ connection.close(3e3, "Could not connect to agent");
722
+ return;
723
+ }
724
+ if (!this.env.AGENTS_AUDIT_LOGS) {
725
+ console.error("[Agent] Connection rejected: no AGENTS_AUDIT_LOGS bound. Audit logs are required.");
726
+ connection.close(3e3, "Could not connect to agent");
727
+ return;
728
+ }
729
+ if (!this.env.AGENTS_ANALYTICS) console.warn("[Agent] No AGENTS_ANALYTICS bound. Analytics will not be collected.");
730
+ if (!this.getUserId()) {
731
+ console.error("[Agent] Connection rejected: name must be in the format userId:uniqueChatId");
732
+ connection.close(3e3, "Could not connect to agent");
733
+ return;
734
+ }
735
+ if (this.getJwtAuthConfig) {
736
+ const config = this.getJwtAuthConfig(ctx.request);
737
+ if (config) {
738
+ let result;
739
+ try {
740
+ result = await verifyJwt(ctx.request, config);
741
+ } catch (error) {
742
+ console.error("[Agent] JWT verification error", error);
743
+ connection.close(4001, "Unauthorized");
744
+ this.setState({
745
+ type: "chat",
746
+ status: "unauthorized"
747
+ });
748
+ return;
749
+ }
750
+ if (!result.success) {
751
+ connection.close(result.status === 401 ? 4001 : 4003, result.message);
752
+ this.setState({
753
+ type: "chat",
754
+ status: "unauthorized"
755
+ });
756
+ return;
757
+ }
758
+ }
759
+ const token = extractTokenFromConnectRequest(ctx.request);
760
+ if (token) this._pendingUserContextRequest = this.getUserContext?.(token).then((userContext) => {
761
+ if (userContext) connection.setState({ userContext });
762
+ });
763
+ }
764
+ this.setState({
765
+ type: "chat",
766
+ status: "connected"
767
+ });
768
+ return super.onConnect(connection, ctx);
769
+ }
770
+ _pendingUserContextRequest;
771
+ /**
772
+ * Builds the parameter object for a `streamText` or `generateText` call,
773
+ * pre-filling `messages`, `activeSkills`, and `fastModel` from this agent instance.
774
+ *
775
+ * **Compaction** runs automatically when `fastModel` is set on the class, using
776
+ * `DEFAULT_MAX_MESSAGES_BEFORE_COMPACTION` (30) as the threshold. Override the
777
+ * threshold by setting `maxMessagesBeforeCompaction` on the class. Disable compaction
778
+ * entirely by setting `maxMessagesBeforeCompaction = undefined` explicitly.
779
+ */
780
+ async buildLLMParams(config) {
781
+ const activeSkills = await getStoredSkills(this.sql.bind(this));
782
+ const experimental_context = {
783
+ ...config.experimental_context,
784
+ ...config.options?.body,
785
+ _userContext: this.userContext
786
+ };
787
+ const messages = await convertToModelMessages(this.messages);
788
+ const fastModel = this.getFastModel();
789
+ const processedMessages = fastModel && this.maxMessagesBeforeCompaction !== void 0 ? await compactIfNeeded(messages, fastModel, this.maxMessagesBeforeCompaction) : messages;
790
+ return buildLLMParams({
791
+ ...config,
792
+ activeSkills,
793
+ messages: processedMessages,
794
+ experimental_context,
795
+ experimental_telemetry: {
796
+ ...config.experimental_telemetry,
797
+ isEnabled: true,
798
+ tracer: createAgentTracer(this.env.AGENTS_AUDIT_LOGS, this.env.AGENTS_ANALYTICS, {
799
+ agentName: this.constructor.name,
800
+ durableObjectName: this.name,
801
+ actorId: this.getUserId(),
802
+ ...this.clientIp ? { clientIp: this.clientIp } : {},
803
+ ...this.forwardedFor ? { forwardedFor: this.forwardedFor } : {}
804
+ }),
805
+ metadata: {
806
+ agentName: this.constructor.name,
807
+ version: "v1",
808
+ durableObjectName: this.name,
809
+ actorId: this.getUserId(),
810
+ ...this.clientIp ? { clientIp: this.clientIp } : {},
811
+ ...this.forwardedFor ? { forwardedFor: this.forwardedFor } : {},
812
+ ...config.experimental_telemetry?.metadata
813
+ }
814
+ }
815
+ });
816
+ }
817
+ async persistMessages(messages, excludeBroadcastIds = [], options) {
818
+ const filtered = filterEphemeralMessages(messages);
819
+ await super.persistMessages(filtered, excludeBroadcastIds, options);
820
+ saveSkillStateFromMessages(this.sql.bind(this), messages);
821
+ await super.persistMessages(filterEphemeralMessages(messages), excludeBroadcastIds, options);
822
+ }
823
+ async onChatResponse(result) {
824
+ if (result.error) {
825
+ console.error("[Agent] Chat response error", result.error);
826
+ return;
827
+ }
828
+ recordConversationSummary(this.env.AGENT_DB, this.name, this.messages, this.getFastModel());
829
+ this.scheduleConversationForAutoDeletion();
830
+ }
831
+ @callable({ description: "Rate a message by its id" }) async rateMessage(messageId, rating, comment) {
832
+ await rateMessage(this.env.AGENT_DB, this.name, messageId, rating, comment);
833
+ }
834
+ @callable({ description: "Returns all message ratings for the current conversation" }) async getMessageRatings() {
835
+ return getMessageRatings(this.env.AGENT_DB, this.name);
836
+ }
837
+ @callable({ description: "Returns all conversations for the current user" }) async getConversations() {
838
+ return getConversations(this.env.AGENT_DB, this.getUserId());
839
+ }
840
+ /**
841
+ * Exports this conversation's persisted message history for the v1 -> v2
842
+ * migration. Called over DO RPC by the v2 `Assistant` while migrating a
843
+ * user's chats into facets. `this.messages` is loaded from the
844
+ * `cf_ai_chat_agent_messages` table when the DO wakes.
845
+ *
846
+ * Read-only: does not mutate or delete any state.
847
+ */
848
+ async exportForMigration() {
849
+ return { messages: this.messages };
850
+ }
851
+ @callable({ description: "Delete a conversation by its id" }) async deleteConversation(id) {
852
+ if (!id.startsWith(`${this.getUserId()}:`)) {
853
+ console.error("[Agent] Failed to delete conversation: Not owned by current user", {
854
+ conversationName: id,
855
+ userId: this.getUserId()
856
+ });
857
+ return false;
858
+ }
859
+ try {
860
+ await deleteConversationRow(this.env.AGENT_DB, id);
861
+ } catch (error) {
862
+ console.error("[Agent] Failed to delete conversation row", {
863
+ conversationName: id,
864
+ error
865
+ });
866
+ return false;
867
+ }
868
+ await (id === this.name ? this : this.binding.getByName(id)).destroy();
869
+ return true;
870
+ }
871
+ async destroy() {
872
+ for (const connection of this.getConnections()) try {
873
+ connection.close(CONVERSATION_EXPIRED_CLOSE_CODE, CONVERSATION_EXPIRED_CLOSE_REASON);
874
+ } catch (error) {
875
+ console.error("[Agent] Failed to close expired conversation connection", error);
876
+ }
877
+ return super.destroy();
878
+ }
879
+ async deleteConversationCallback() {
880
+ if (await this.deleteConversation(this.name)) console.log("[Agent] Conversation deleted due to inactivity", {
881
+ conversationName: this.name,
882
+ retentionDays: this.conversationRetentionDays ?? null
883
+ });
884
+ }
885
+ async scheduleConversationForAutoDeletion() {
886
+ const retentionMs = getConversationRetentionMs(this.conversationRetentionDays);
887
+ if (retentionMs === null) return;
888
+ const scheduleIds = getDeleteConversationScheduleIds(this.getSchedules());
889
+ await Promise.all(scheduleIds.map((scheduleId) => this.cancelSchedule(scheduleId)));
890
+ await this.schedule(new Date(Date.now() + retentionMs), DELETE_CONVERSATION_CALLBACK);
891
+ }
892
+ };
893
+ //#endregion
894
+ //#region src/server/v1/agent-chat/ChatAgentHarness.ts
895
+ var ChatAgentHarness = class extends ChatAgent {
896
+ get binding() {
897
+ const className = this.constructor.name;
898
+ return this.env[className];
899
+ }
900
+ conversationRetentionDays = 90;
901
+ /**
902
+ * Returns the tools for the agent.
903
+ * @param ctx - The context object for the agent built from the request body.
904
+ * @returns The tools for the agent.
905
+ */
906
+ getTools(_ctx) {
907
+ return {};
908
+ }
909
+ /**
910
+ * Returns the skills for the agent.
911
+ * @param ctx - The context object for the agent built from the request body.
912
+ * @returns The skills for the agent.
913
+ */
914
+ getSkills(_ctx) {
915
+ return [];
916
+ }
917
+ async onChatMessage(onFinish, options) {
918
+ if (this._pendingUserContextRequest) await this._pendingUserContextRequest;
919
+ const ctx = options?.body;
920
+ return streamText(await this.buildLLMParams({
921
+ options,
922
+ onFinish,
923
+ model: this.getModel(ctx),
924
+ system: this.getSystemPrompt(ctx),
925
+ skills: this.getSkills(ctx),
926
+ tools: this.getTools(ctx)
927
+ })).toUIMessageStreamResponse({ sendSources: true });
928
+ }
929
+ };
930
+ //#endregion
931
+ export { Agent, ChatAgent, ChatAgentHarness, buildLLMParams, routeAgentRequest };