@economic/agents 0.0.1-alpha.8 → 0.0.1-beta.1

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/index.mjs CHANGED
@@ -1,18 +1,35 @@
1
- import { convertToModelMessages, generateText, jsonSchema, stepCountIs, streamText, tool } from "ai";
2
1
  import { AIChatAgent as AIChatAgent$1 } from "@cloudflare/ai-chat";
3
- //#region src/features/skills/meta-tools.ts
2
+ import { Output, convertToModelMessages, generateText, jsonSchema, stepCountIs, tool } from "ai";
3
+ import { callable } from "agents";
4
+ //#region src/server/features/skills/index.ts
5
+ /** Creates the `skill_state` table in DO SQLite if it does not exist yet. */
6
+ function ensureSkillTable(sql) {
7
+ sql`CREATE TABLE IF NOT EXISTS skill_state (id INTEGER PRIMARY KEY, active_skills TEXT NOT NULL DEFAULT '[]')`;
8
+ }
4
9
  /**
5
- * Names and descriptions for the built-in meta tools.
6
- *
7
- * The execute logic for these lives in createSkills() where it has
8
- * access to the closure state (loadedSkills).
10
+ * Reads the persisted list of loaded skill names from DO SQLite.
11
+ * Returns an empty array if the table is missing or the row does not exist.
9
12
  */
10
- const ACTIVATE_SKILL = "activate_skill";
11
- const LIST_CAPABILITIES = "list_capabilities";
13
+ function getStoredSkills(sql) {
14
+ try {
15
+ ensureSkillTable(sql);
16
+ const rows = sql`SELECT active_skills FROM skill_state WHERE id = 1`;
17
+ if (rows.length === 0) return [];
18
+ return JSON.parse(rows[0].active_skills);
19
+ } catch {
20
+ return [];
21
+ }
22
+ }
12
23
  /**
13
- * Builds the tool description for activate_skill, including the
14
- * current list of available skills with their descriptions.
24
+ * Persists the current list of loaded skill names to DO SQLite.
25
+ * Upserts the single `skill_state` row (id = 1).
15
26
  */
27
+ function saveStoredSkills(sql, skills) {
28
+ ensureSkillTable(sql);
29
+ sql`INSERT OR REPLACE INTO skill_state(id, active_skills) VALUES(1, ${JSON.stringify(skills)})`;
30
+ }
31
+ const ACTIVATE_SKILL = "activate_skill";
32
+ const LIST_CAPABILITIES = "list_capabilities";
16
33
  function buildActivateSkillDescription(skills) {
17
34
  return [
18
35
  "Load additional skills to help with the user's request.",
@@ -23,42 +40,27 @@ function buildActivateSkillDescription(skills) {
23
40
  ].join("\n");
24
41
  }
25
42
  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.";
26
- //#endregion
27
- //#region src/features/skills/index.ts
43
+ /**
44
+ * Sentinel appended to a successful activate_skill result.
45
+ *
46
+ * Format: `Loaded: search, code.\n__SKILLS_STATE__:["search","code"]`
47
+ *
48
+ * The CF layer's `persistMessages` detects this sentinel, extracts the JSON
49
+ * array of all currently-loaded skill names, writes it to DO SQLite, and
50
+ * strips the entire activate_skill message from the persisted conversation.
51
+ * No `onSkillsChanged` callback or D1 dependency needed.
52
+ */
53
+ const SKILL_STATE_SENTINEL = "\n__SKILLS_STATE__:";
28
54
  /**
29
55
  * Creates a skill loading system for use with the Vercel AI SDK.
30
56
  *
31
57
  * The agent starts with only its always-on tools active. The LLM can call
32
58
  * activate_skill to load skill tools on demand. Which skills are loaded is
33
- * persisted to D1 across turns no message-history parsing required.
34
- *
35
- * Guidance from loaded skills is injected as a system message just before
36
- * the current user turn, keeping the `system` prompt static and cacheable.
37
- * prepareStep keeps the guidance message updated if new skills load mid-turn.
38
- *
39
- * Usage with streamText (ai v6):
40
- * ```typescript
41
- * import { streamText, convertToModelMessages, stepCountIs } from "ai";
42
- *
43
- * // initialLoadedSkills comes from D1 (read at turn start by the agent).
44
- * // onSkillsChanged is called when new skills are loaded; the agent
45
- * // buffers the value and writes it to D1 at turn end in persistMessages.
46
- * const lt = createSkills({ tools, skills, initialLoadedSkills, onSkillsChanged });
47
- * const messages = injectGuidance(modelMessages, lt.getLoadedGuidance());
48
- *
49
- * const result = streamText({
50
- * model,
51
- * system: baseSystemPrompt, // static — never contains guidance, stays cacheable
52
- * messages,
53
- * tools: lt.tools,
54
- * activeTools: lt.activeTools,
55
- * prepareStep: lt.prepareStep, // keeps guidance message updated mid-turn
56
- * stopWhen: stepCountIs(20),
57
- * });
58
- * ```
59
+ * communicated to the CF layer via a sentinel in the activate_skill result
60
+ * and persisted to DO SQLite — no D1 or message-history parsing required.
59
61
  */
60
62
  function createSkills(config) {
61
- const { tools: alwaysOnTools, skills, filterSkill, onSkillsChanged } = config;
63
+ const { tools: alwaysOnTools, skills } = config;
62
64
  const loadedSkills = new Set(config.initialLoadedSkills ?? []);
63
65
  const skillMap = new Map(skills.map((s) => [s.name, s]));
64
66
  const allTools = {};
@@ -101,24 +103,13 @@ function createSkills(config) {
101
103
  }),
102
104
  execute: async ({ skills: requested }) => {
103
105
  const newlyLoaded = [];
104
- const denied = [];
105
106
  for (const skillName of requested) {
106
107
  if (!skillMap.get(skillName)) continue;
107
108
  if (loadedSkills.has(skillName)) continue;
108
- if (!(filterSkill ? await filterSkill(skillName) : true)) {
109
- denied.push(skillName);
110
- continue;
111
- }
112
109
  loadedSkills.add(skillName);
113
110
  newlyLoaded.push(skillName);
114
111
  }
115
- if (newlyLoaded.length > 0 && onSkillsChanged) await onSkillsChanged([...loadedSkills]);
116
- if (newlyLoaded.length > 0) {
117
- let result = `Loaded: ${newlyLoaded.join(", ")}.`;
118
- if (denied.length > 0) result += ` Access denied for: ${denied.join(", ")}.`;
119
- return result;
120
- }
121
- if (denied.length > 0) return `Access denied for: ${denied.join(", ")}.`;
112
+ if (newlyLoaded.length > 0) return `Loaded: ${newlyLoaded.join(", ")}.${SKILL_STATE_SENTINEL}${JSON.stringify([...loadedSkills])}`;
122
113
  return ALREADY_LOADED_OUTPUT;
123
114
  }
124
115
  });
@@ -158,48 +149,23 @@ function createSkills(config) {
158
149
  };
159
150
  }
160
151
  const ALREADY_LOADED_OUTPUT = "All requested skills were already loaded.";
161
- const DENIED_OUTPUT_PREFIX = "Access denied for:";
162
152
  /**
163
- * Removes ephemeral messages from the conversation before it is saved to D1.
164
- *
165
- * Three kinds of messages are stripped:
166
- *
167
- * 1. list_capabilities tool calls — always stripped. Capability discovery is
168
- * only relevant within the current turn; it adds no useful context for
169
- * future turns.
153
+ * Removes ephemeral skill-related messages from a conversation.
170
154
  *
171
- * 2. activate_skill calls when nothing was newly loaded stripped when all
172
- * requested skills were already active, or when all were denied. In both
173
- * cases nothing changed, so persisting the call would only add noise.
174
- *
175
- * 3. Guidance system messages — stripped by exact content match against the
176
- * provided guidance string. Guidance is always recomputed from loaded skill
177
- * definitions at turn start, so persisting it would create a redundant
178
- * second source of truth alongside the loaded_skills D1 column.
179
- *
180
- * When skills ARE successfully loaded, the short "Loaded: X" result is kept
181
- * in history for model context — so the model can see what was loaded in
182
- * prior turns. Skill state is restored from D1 loaded_skills, not from these
183
- * strings.
155
+ * Strips both `activate_skill` and `list_capabilities` tool calls entirely
156
+ * skill state is persisted to DO SQLite by the CF layer, so these messages
157
+ * are not needed for future turns.
184
158
  *
185
159
  * If stripping leaves an assistant message with no parts, the entire message
186
- * is dropped (e.g. a step that did nothing but call list_capabilities).
160
+ * is dropped.
187
161
  */
188
- function filterEphemeralMessages(messages, guidanceToStrip) {
162
+ function filterEphemeralMessages(messages) {
189
163
  return messages.flatMap((msg) => {
190
- if (msg.role === "system" && guidanceToStrip) {
191
- if (msg.parts?.some((p) => "text" in p && p.text === guidanceToStrip)) return [];
192
- }
193
164
  if (msg.role !== "assistant" || !msg.parts?.length) return [msg];
194
165
  const filtered = msg.parts.filter((part) => {
195
166
  if (!("toolCallId" in part)) return true;
196
- const { type, output } = part;
197
- if (type === `tool-list_capabilities`) return false;
198
- if (type === `tool-activate_skill`) {
199
- if (typeof output !== "string") return true;
200
- return output !== ALREADY_LOADED_OUTPUT && !output.startsWith(DENIED_OUTPUT_PREFIX);
201
- }
202
- return true;
167
+ const { type } = part;
168
+ return type !== `tool-list_capabilities` && type !== `tool-activate_skill`;
203
169
  });
204
170
  if (filtered.length === 0) return [];
205
171
  if (filtered.length === msg.parts.length) return [msg];
@@ -209,122 +175,57 @@ function filterEphemeralMessages(messages, guidanceToStrip) {
209
175
  }];
210
176
  });
211
177
  }
212
- /**
213
- * Injects loaded skill guidance as a system message just before the last
214
- * message in the array (typically the current user turn).
215
- *
216
- * Guidance is kept separate from the static `system` prompt so that the
217
- * system prompt stays identical on every turn and can be prompt-cached.
218
- * Positioning just before the last message means guidance survives any
219
- * compaction strategy that preserves recent context.
220
- *
221
- * Pass `previousGuidance` (the string injected on the prior call) to remove
222
- * the stale guidance message before inserting the updated one. Removal is by
223
- * exact content match — not by role — so other system messages (memories,
224
- * user preferences, etc.) are left untouched.
225
- *
226
- * At turn start, omit `previousGuidance` — guidance is never persisted to D1
227
- * (it is stripped by filterEphemeralMessages before saving), so there is
228
- * nothing to remove. prepareStep uses previousGuidance within a turn to
229
- * handle guidance updates when new skills are loaded mid-turn.
230
- *
231
- * ```typescript
232
- * // Turn start — just inject
233
- * const messages = injectGuidance(modelMessages, skills.getLoadedGuidance());
234
- *
235
- * // prepareStep — remove stale guidance then re-inject updated guidance
236
- * const messages = injectGuidance(stepMessages, newGuidance, previousGuidance);
237
- * ```
238
- */
239
- function injectGuidance(messages, guidance, previousGuidance) {
240
- if (!guidance) return messages;
241
- const base = previousGuidance ? messages.filter((m) => !(m.role === "system" && m.content === previousGuidance)) : messages;
242
- const insertAt = base.findLastIndex((m) => m.role === "user");
243
- return [
244
- ...base.slice(0, insertAt),
245
- {
246
- role: "system",
247
- content: guidance
248
- },
249
- ...base.slice(insertAt)
250
- ];
251
- }
252
- //#endregion
253
- //#region src/agents/chat/compaction/index.ts
254
- /**
255
- * Message compaction for long-running conversations.
256
- *
257
- * When the stored conversation history exceeds COMPACT_TOKEN_THRESHOLD, older
258
- * messages are summarised via an LLM call and replaced with a single system
259
- * message containing the summary, followed by the recent verbatim tail.
260
- *
261
- * Entry point: compactIfNeeded() — called once per turn from persistMessages.
262
- *
263
- * To remove compaction entirely: delete this directory, remove the import in
264
- * AIChatAgentBase, and change `toSave` back to `filtered`.
265
- */
266
- const COMPACT_TOKEN_THRESHOLD = 14e4;
267
178
  const TOOL_RESULT_PREVIEW_CHARS = 200;
268
179
  const SUMMARY_MAX_TOKENS = 4e3;
269
180
  /**
270
181
  * Estimates token count for a message array using a 3.5 chars/token
271
- * approximation the same heuristic used by slack-bot. Counts text from
272
- * text parts, tool inputs/outputs, and reasoning parts.
182
+ * approximation. Counts text from text/reasoning parts, tool inputs/outputs.
273
183
  */
274
184
  function estimateMessagesTokens(messages) {
275
185
  let totalChars = 0;
276
186
  for (const msg of messages) {
277
- if (!msg.parts) continue;
278
- for (const part of msg.parts) {
279
- if ((part.type === "text" || part.type === "reasoning") && "text" in part) {
280
- totalChars += part.text.length;
281
- continue;
282
- }
283
- if ("toolCallId" in part) {
284
- const toolPart = part;
285
- if (toolPart.input) totalChars += JSON.stringify(toolPart.input).length;
286
- if (toolPart.output !== void 0) {
287
- const outputStr = typeof toolPart.output === "string" ? toolPart.output : JSON.stringify(toolPart.output);
288
- totalChars += outputStr.length;
289
- }
290
- }
187
+ if (typeof msg.content === "string") {
188
+ totalChars += msg.content.length;
189
+ continue;
190
+ }
191
+ for (const part of msg.content) if (part.type === "text" || part.type === "reasoning") totalChars += part.text.length;
192
+ else if (part.type === "tool-call") totalChars += JSON.stringify(part.input).length;
193
+ else if (part.type === "tool-result") {
194
+ const output = part.output;
195
+ totalChars += typeof output === "string" ? output.length : JSON.stringify(output).length;
291
196
  }
292
197
  }
293
198
  return Math.ceil(totalChars / 3.5);
294
199
  }
295
200
  /**
296
201
  * Renders messages as human-readable text for the compaction summary prompt.
297
- * Text parts are included verbatim; tool calls show name and a truncated result.
298
- * step-start and empty messages are omitted.
299
202
  */
300
203
  function formatMessagesForSummary(messages) {
301
204
  const lines = [];
302
205
  for (const msg of messages) {
303
206
  const roleLabel = msg.role.charAt(0).toUpperCase() + msg.role.slice(1);
304
207
  const parts = [];
305
- for (const part of msg.parts ?? []) {
306
- if (part.type === "step-start") continue;
307
- if ((part.type === "text" || part.type === "reasoning") && "text" in part) {
308
- const text = part.text.trim();
309
- if (text) parts.push(text);
310
- continue;
311
- }
312
- if ("toolCallId" in part) {
313
- const toolPart = part;
314
- const toolName = toolPart.type.startsWith("tool-") ? toolPart.type.slice(5) : toolPart.type;
315
- const rawOutput = toolPart.output === void 0 ? "no result" : typeof toolPart.output === "string" ? toolPart.output : JSON.stringify(toolPart.output);
316
- const preview = rawOutput.slice(0, TOOL_RESULT_PREVIEW_CHARS);
317
- const ellipsis = rawOutput.length > TOOL_RESULT_PREVIEW_CHARS ? "..." : "";
318
- parts.push(`[Tool: ${toolName}, result: ${preview}${ellipsis}]`);
319
- }
208
+ if (typeof msg.content === "string") {
209
+ if (msg.content.trim()) parts.push(msg.content.trim());
210
+ } else for (const part of msg.content) if (part.type === "text" || part.type === "reasoning") {
211
+ const text = part.text.trim();
212
+ if (text) parts.push(text);
213
+ } else if (part.type === "tool-call") {
214
+ const p = part;
215
+ parts.push(`[Tool call: ${p.toolName}]`);
216
+ } else if (part.type === "tool-result") {
217
+ const p = part;
218
+ const rawOutput = typeof p.output === "string" ? p.output : JSON.stringify(p.output);
219
+ const preview = rawOutput.slice(0, TOOL_RESULT_PREVIEW_CHARS);
220
+ const ellipsis = rawOutput.length > TOOL_RESULT_PREVIEW_CHARS ? "..." : "";
221
+ parts.push(`[Tool: ${p.toolName}, result: ${preview}${ellipsis}]`);
320
222
  }
321
223
  if (parts.length > 0) lines.push(`${roleLabel}: ${parts.join(" ")}`);
322
224
  }
323
225
  return lines.join("\n\n");
324
226
  }
325
227
  /**
326
- * Calls the LLM to produce a concise summary of old + recent message windows.
327
- * Weights the prompt toward recent exchanges, matching slack-bot's approach.
228
+ * Calls the model to produce a concise summary of old + recent message windows.
328
229
  */
329
230
  async function generateCompactionSummary(oldMessages, recentMessages, model) {
330
231
  const prompt = `Summarize this conversation history concisely for an AI assistant to continue the conversation.
@@ -357,363 +258,375 @@ Write a concise summary:`;
357
258
  }
358
259
  /**
359
260
  * Summarizes older messages into a single system message and appends the
360
- * recent verbatim tail. Returns messages unchanged if the history is already
361
- * short enough to fit within tailSize.
261
+ * recent verbatim tail. Returns messages unchanged if already short enough.
362
262
  */
363
263
  async function compactMessages(messages, model, tailSize) {
364
264
  if (messages.length <= tailSize) return messages;
365
265
  const splitIndex = messages.length - tailSize;
366
266
  const oldMessages = messages.slice(0, splitIndex);
367
267
  const recentTail = messages.slice(splitIndex);
368
- const summary = await generateCompactionSummary(oldMessages, recentTail, model);
369
268
  return [{
370
- id: `compact_${Date.now()}_${Math.random().toString(36).slice(2, 9)}`,
371
269
  role: "system",
372
- parts: [{
373
- type: "text",
374
- text: `[Conversation summary - older context was compacted]\n${summary}`,
375
- state: "done"
376
- }]
270
+ content: `[Conversation summary - older context was compacted]\n${await generateCompactionSummary(oldMessages, recentTail, model)}`
377
271
  }, ...recentTail];
378
272
  }
379
273
  /**
380
- * Entry point called from persistMessages once per turn.
381
- *
382
- * Returns messages unchanged when:
383
- * - model is undefined (compaction disabled on this agent)
384
- * - estimated token count is under COMPACT_TOKEN_THRESHOLD
385
- *
386
- * Otherwise delegates to compactMessages.
274
+ * Entry point for compaction. Returns messages unchanged when model is
275
+ * undefined or estimated token count is under COMPACT_TOKEN_THRESHOLD.
387
276
  */
388
277
  async function compactIfNeeded(messages, model, tailSize) {
389
278
  if (!model || estimateMessagesTokens(messages) <= 14e4) return messages;
390
279
  return compactMessages(messages, model, tailSize);
391
280
  }
392
281
  //#endregion
393
- //#region src/agents/chat/AIChatAgentBase.ts
282
+ //#region src/server/llm.ts
394
283
  /**
395
- * Base class for chat agents with lazy skill loading.
284
+ * Builds the parameter object for a Vercel AI SDK `streamText` or `generateText` call.
396
285
  *
397
- * Owns:
398
- * - D1 persistence for loaded skill state (skill names survive DO eviction)
399
- * - Ephemeral message filtering (list_capabilities, no-op activate_skill calls)
400
- * - Message compaction (LLM summarisation when history exceeds token threshold)
401
- * - History replay to newly connected clients (onConnect override)
402
- * - Skill context preparation for use with the @withSkills decorator
286
+ * Handles message conversion, optional compaction, skill wiring (`activate_skill`,
287
+ * `list_capabilities`, `prepareStep`), and context/abort signal extraction from
288
+ * the Cloudflare Agents SDK `options` object.
403
289
  *
404
- * Conversation messages are stored in Durable Object SQLite, managed
405
- * automatically by the Cloudflare AIChatAgent — no D1 write needed for messages.
290
+ * The returned object can be spread directly into `streamText` or `generateText`:
406
291
  *
407
- * D1 is written only when skills change (activate_skill was called this turn),
408
- * not on every turn.
292
+ * ```typescript
293
+ * const params = await buildLLMParams({ ... });
294
+ * return streamText(params).toUIMessageStreamResponse();
295
+ * ```
296
+ */
297
+ async function buildLLMParams(config) {
298
+ const { options, messages, activeSkills = [], skills, fastModel, maxMessagesBeforeCompaction, ...rest } = config;
299
+ const rawMessages = await convertToModelMessages(messages);
300
+ const processedMessages = fastModel && maxMessagesBeforeCompaction !== void 0 ? await compactIfNeeded(rawMessages, fastModel, maxMessagesBeforeCompaction) : rawMessages;
301
+ const baseParams = {
302
+ ...rest,
303
+ messages: processedMessages,
304
+ experimental_context: options?.body,
305
+ abortSignal: options?.abortSignal,
306
+ stopWhen: rest.stopWhen ?? stepCountIs(20)
307
+ };
308
+ if (!skills?.length) return baseParams;
309
+ const skillsCtx = createSkills({
310
+ tools: rest.tools ?? {},
311
+ skills,
312
+ initialLoadedSkills: activeSkills,
313
+ systemPrompt: typeof rest.system === "string" ? rest.system : void 0
314
+ });
315
+ const prepareStep = async (stepOptions) => {
316
+ const skillsResult = await skillsCtx.prepareStep(stepOptions) ?? {};
317
+ return {
318
+ activeTools: skillsResult.activeTools ?? [],
319
+ system: skillsResult.system
320
+ };
321
+ };
322
+ return {
323
+ ...baseParams,
324
+ system: skillsCtx.getSystem() || rest.system,
325
+ tools: skillsCtx.tools,
326
+ activeTools: skillsCtx.activeTools,
327
+ prepareStep
328
+ };
329
+ }
330
+ //#endregion
331
+ //#region src/server/features/audit/index.ts
332
+ /**
333
+ * Inserts a single audit event row into the shared `audit_events` D1 table.
409
334
  *
410
- * ## Usage
335
+ * Called by `AIChatAgent.log()`. Not intended for direct use.
336
+ */
337
+ async function insertAuditEvent(db, durableObjectName, message, payload) {
338
+ await db.prepare(`INSERT INTO audit_events (id, durable_object_name, message, payload, created_at)
339
+ VALUES (?, ?, ?, ?, ?)`).bind(crypto.randomUUID(), durableObjectName, message, payload ? JSON.stringify(payload) : null, (/* @__PURE__ */ new Date()).toISOString()).run();
340
+ }
341
+ /**
342
+ * Builds the payload for a "turn completed" audit event from the final message list.
411
343
  *
412
- * Extend this class when you want full control over `streamText`. Implement
413
- * `getTools()`, `getSkills()`, and your own `onChatMessage` decorated with
414
- * `@withSkills`:
344
+ * Extracts the last user and assistant message texts (truncated to 200 chars),
345
+ * all non-meta tool call names used this turn, and the current loaded skill set.
346
+ */
347
+ function buildTurnSummary(messages, loadedSkills) {
348
+ const toolCallNames = [];
349
+ for (const msg of messages) {
350
+ if (msg.role !== "assistant" || !msg.parts) continue;
351
+ for (const part of msg.parts) {
352
+ if (!("toolCallId" in part)) continue;
353
+ const { type } = part;
354
+ if (!type.startsWith("tool-")) continue;
355
+ const name = type.slice(5);
356
+ if (name !== "activate_skill" && name !== "list_capabilities" && !toolCallNames.includes(name)) toolCallNames.push(name);
357
+ }
358
+ }
359
+ const lastUserMsg = [...messages].reverse().find((m) => m.role === "user");
360
+ const lastAssistantMsg = [...messages].reverse().find((m) => m.role === "assistant");
361
+ return {
362
+ userMessage: extractMessageText(lastUserMsg).slice(0, 200),
363
+ toolCalls: toolCallNames,
364
+ loadedSkills,
365
+ assistantMessage: extractMessageText(lastAssistantMsg).slice(0, 200)
366
+ };
367
+ }
368
+ function extractMessageText(msg) {
369
+ if (!msg?.parts) return "";
370
+ return msg.parts.filter((p) => p.type === "text").map((p) => p.text).join(" ").trim();
371
+ }
372
+ //#endregion
373
+ //#region src/server/features/conversations/index.ts
374
+ /**
375
+ * Records a conversation row in the `conversations` D1 table.
376
+ *
377
+ * Called by `AIChatAgent` after every turn. On first call for a given
378
+ * `durableObjectName` the row is inserted with `created_at` set to now,
379
+ * and with the provided `title` and `summary` if supplied.
380
+ * On subsequent calls only `updated_at` is refreshed —
381
+ * `created_at`, `title`, and `summary` are never overwritten, preserving
382
+ * any user edits.
383
+ */
384
+ async function recordConversation(db, durableObjectName, title, summary) {
385
+ const now = (/* @__PURE__ */ new Date()).toISOString();
386
+ await db.prepare(`INSERT INTO conversations (durable_object_name, title, summary, created_at, updated_at)
387
+ VALUES (?, ?, ?, ?, ?)
388
+ ON CONFLICT(durable_object_name) DO UPDATE SET
389
+ updated_at = excluded.updated_at`).bind(durableObjectName, title ?? null, summary ?? null, now, now).run();
390
+ }
391
+ /**
392
+ * Returns the current `title` and `summary` for a conversation row,
393
+ * or `null` if the row does not exist yet.
394
+ */
395
+ async function getConversationSummary(db, durableObjectName) {
396
+ return await db.prepare(`SELECT title, summary FROM conversations WHERE durable_object_name = ?`).bind(durableObjectName).first() ?? null;
397
+ }
398
+ /**
399
+ * Returns all conversations for a user, ordered by most recent.
400
+ */
401
+ async function getConversations(db, userId) {
402
+ const { results } = await db.prepare(`SELECT * FROM conversations WHERE durable_object_name LIKE ? ORDER BY updated_at DESC`).bind(`${userId}:%`).all();
403
+ return results;
404
+ }
405
+ /**
406
+ * Writes a generated `title` and `summary` back to the `conversations` row.
407
+ */
408
+ async function updateConversationSummary(db, durableObjectName, title, summary) {
409
+ await db.prepare(`UPDATE conversations SET title = ?, summary = ? WHERE durable_object_name = ?`).bind(title, summary, durableObjectName).run();
410
+ }
411
+ /**
412
+ * Generates a title and summary for a conversation using the provided model.
413
+ * Returns the result without writing to D1.
415
414
  *
416
- * ```typescript
417
- * export class MyAgent extends AIChatAgentBase {
418
- * getTools() { return []; }
419
- * getSkills() { return [searchSkill, codeSkill]; }
420
- * getDB() { return this.env.AGENT_DB; }
415
+ * Pass `existingSummary` so the model can detect direction changes when
416
+ * updating an existing summary. Omit it (or pass undefined) for the initial
417
+ * generation.
421
418
  *
422
- * @withSkills
423
- * async onChatMessage(onFinish, ctx: SkillContext, options?) {
424
- * const { messages, ...skillArgs } = ctx;
425
- * return streamText({
426
- * model: openai("gpt-4o"),
427
- * system: "You are a helpful assistant.",
428
- * messages,
429
- * ...skillArgs,
430
- * onFinish,
431
- * stopWhen: stepCountIs(20),
432
- * }).toUIMessageStreamResponse();
433
- * }
434
- * }
435
- * ```
419
+ * Only the last `SUMMARY_CONTEXT_MESSAGES` messages are used to keep the
420
+ * prompt bounded regardless of total conversation length.
421
+ */
422
+ async function generateTitleAndSummary(messages, model, existingSummary) {
423
+ const recentMessages = await convertToModelMessages(messages.slice(-30));
424
+ const previousContext = existingSummary ? `Previous summary: ${existingSummary}\n\nMost recent messages:` : "Conversation:";
425
+ const { output } = await generateText({
426
+ model,
427
+ output: Output.object({ schema: jsonSchema({
428
+ type: "object",
429
+ properties: {
430
+ title: {
431
+ type: "string",
432
+ description: "Short title for the conversation, max 8 words"
433
+ },
434
+ summary: {
435
+ type: "string",
436
+ description: "2-3 sentence summary. If the conversation direction has changed from the previous summary, reflect the new direction."
437
+ }
438
+ },
439
+ required: ["title", "summary"]
440
+ }) }),
441
+ prompt: `${previousContext}\n\n${formatMessagesForSummary(recentMessages)}`
442
+ });
443
+ return output;
444
+ }
445
+ /**
446
+ * Generates a title and summary for a conversation using the provided model
447
+ * and writes the result back to D1.
436
448
  *
437
- * For a batteries-included experience where the base class owns `onChatMessage`,
438
- * extend `AIChatAgent` instead.
449
+ * Fetches any existing summary first so the model can detect direction changes.
450
+ * Only the last `SUMMARY_CONTEXT_MESSAGES` messages are passed to keep the
451
+ * prompt bounded regardless of total conversation length.
452
+ *
453
+ * Called by `AIChatAgent` every `SUMMARY_CONTEXT_MESSAGES` messages after
454
+ * the first turn.
439
455
  */
440
- var AIChatAgentBase = class extends AIChatAgent$1 {
441
- /**
442
- * Maximum number of messages stored in DO SQLite.
443
- *
444
- * Lowered from the Cloudflare AIChatAgent default of 200. When compaction
445
- * is enabled, one slot is reserved for the summary message so the verbatim
446
- * tail is maxPersistedMessages - 1 recent messages. Raise or lower per agent.
447
- */
448
- maxPersistedMessages = 50;
449
- /**
450
- * Query parameter names to read from the WebSocket connection URL and
451
- * forward to tools via experimental_context.
452
- *
453
- * Browsers cannot set custom headers on WebSocket upgrade requests, so
454
- * auth tokens and other metadata must be passed as query parameters instead.
455
- *
456
- * ```typescript
457
- * passthroughRequestHeaders = ['authorization', 'x-user-id'];
458
- * ```
459
- *
460
- * Values are read from the URL at connect time and stored in _requestHeaders
461
- * for the lifetime of the Durable Object instance.
462
- */
463
- passthroughRequestHeaders = [];
464
- /**
465
- * Return a LanguageModel to use for compaction summarisation.
466
- *
467
- * Return undefined (default) to disable compaction — messages are kept up
468
- * to maxPersistedMessages and older ones are dropped by the Cloudflare
469
- * AIChatAgent's built-in hard cap.
470
- *
471
- * Override to use a cheaper or faster model for summarisation, or to enable
472
- * compaction in subclasses that do not override it automatically.
473
- */
474
- getCompactionModel() {}
475
- /**
476
- * Return the D1 database binding for persisting loaded skill state.
477
- *
478
- * Override in your subclass to return the binding from env:
479
- * ```typescript
480
- * protected getDB() { return this.env.AGENT_DB; }
481
- * ```
482
- *
483
- * Defaults to undefined — when undefined, loaded skills reset on every new
484
- * conversation (skills still work within a turn, just not across turns).
485
- */
486
- getDB() {}
487
- /**
488
- * Optional permission hook. Return false to deny the agent access to a
489
- * skill when activate_skill is called. Defaults to allow-all.
490
- */
491
- async filterSkill(_skillName) {
492
- return true;
456
+ async function generateConversationSummary(db, durableObjectName, messages, model) {
457
+ const { title, summary } = await generateTitleAndSummary(messages, model, (await getConversationSummary(db, durableObjectName))?.summary ?? void 0);
458
+ await updateConversationSummary(db, durableObjectName, title, summary);
459
+ }
460
+ //#endregion
461
+ //#region src/server/agents/AIChatAgent.ts
462
+ /**
463
+ * Base class for Cloudflare Agents SDK chat agents with lazy skill loading
464
+ * and built-in audit logging.
465
+ *
466
+ * Handles CF infrastructure concerns only: DO SQLite persistence for loaded
467
+ * skill state, stripping skill meta-tool messages before persistence, history
468
+ * replay to newly connected clients, and writing audit events to D1.
469
+ *
470
+ * Skill loading, compaction, and LLM communication are delegated to
471
+ * `buildLLMParams` from `@economic/agents`, which you call inside `onChatMessage`.
472
+ */
473
+ var AIChatAgent = class extends AIChatAgent$1 {
474
+ getUserId() {
475
+ return this.name.split(":")[0];
476
+ }
477
+ async onConnect(connection, ctx) {
478
+ await super.onConnect(connection, ctx);
479
+ if (!this.getUserId()) {
480
+ console.error("[AIChatAgent] Connection rejected: name must be in the format userId:uniqueChatId");
481
+ connection.close(3e3, "Name does not match format userId:uniqueChatId");
482
+ return;
483
+ }
493
484
  }
494
- /** @internal Captured header values, keyed by lowercase name. */
495
- _requestHeaders = {};
496
485
  /**
497
- * Buffered skill state from the current turn.
498
- *
499
- * Set by the onSkillsChanged callback when activate_skill loads new skills
500
- * mid-turn. Flushed to D1 in persistMessages at turn end — only written
501
- * when this value is set, so D1 is not touched on turns where no new skills
502
- * are loaded.
486
+ * Resolves the D1 database binding required for all D1 writes.
487
+ * Returns null and silently no-ops if AGENT_DB is not bound.
503
488
  */
504
- _pendingSkills;
489
+ resolveD1Context() {
490
+ const db = this.env.AGENT_DB;
491
+ if (!db) {
492
+ console.error("[AIChatAgent] Skipping logging: D1 database not found");
493
+ return null;
494
+ }
495
+ return db;
496
+ }
505
497
  /**
506
- * Reads loaded skill names from D1 for this agent.
507
- *
508
- * Returns an empty array if no record exists (first turn, or no skills
509
- * loaded yet). Conversation messages are not read here — the Cloudflare
510
- * AIChatAgent provides those via this.messages from DO SQLite.
498
+ * Returns all conversations for the current user.
511
499
  */
512
- async _readSkillState() {
513
- const row = await this.getDB()?.prepare("SELECT loaded_skills FROM agent_state WHERE agent_id = ?").bind(this.name).first();
514
- if (!row?.loaded_skills) return [];
515
- return JSON.parse(row.loaded_skills);
500
+ @callable() async getConversations() {
501
+ const db = this.resolveD1Context();
502
+ if (!db) return;
503
+ return getConversations(db, this.getUserId());
516
504
  }
517
505
  /**
518
- * Writes loaded skill names to D1 for this agent.
506
+ * Writes an audit event to D1 if `AGENT_DB` is bound on the environment,
507
+ * otherwise silently does nothing.
519
508
  *
520
- * Uses INSERT OR REPLACE so the first skill load creates the row and
521
- * subsequent loads update it. Only called when skills actually changed
522
- * this turn (_pendingSkills is set).
509
+ * Called automatically after every turn (from `persistMessages`) and on
510
+ * non-clean finish reasons (from `buildLLMParams`). Also available via
511
+ * `experimental_context.log` in tool `execute` functions.
523
512
  */
524
- async _writeSkillState(skills) {
525
- await this.getDB()?.prepare("INSERT OR REPLACE INTO agent_state (agent_id, loaded_skills, last_updated) VALUES (?, ?, ?)").bind(this.name, JSON.stringify(skills), Date.now()).run();
513
+ async log(message, payload) {
514
+ const db = this.resolveD1Context();
515
+ if (!db) return;
516
+ await insertAuditEvent(db, this.name, message, payload);
526
517
  }
527
518
  /**
528
- * Flush persisted message history to a newly connected client.
529
- *
530
- * The Cloudflare AIChatAgent broadcasts message updates to existing
531
- * connections via persistMessages, but does nothing for connections that
532
- * arrive after a conversation has ended. Without this override, a page
533
- * refresh produces an empty UI even though the history is intact in DO SQLite.
519
+ * Records this conversation in the `conversations` D1 table and triggers
520
+ * LLM-based title/summary generation when appropriate. Called automatically
521
+ * from `persistMessages` after every turn.
534
522
  *
535
- * Skips replay when a stream is active CF_AGENT_STREAM_RESUMING handles
536
- * that case and replays in-progress chunks via its own protocol.
523
+ * On the first turn (no existing row), awaits `generateTitleAndSummary` and
524
+ * inserts the row with title and summary already populated. On subsequent
525
+ * turns, upserts the timestamp and fire-and-forgets a summary refresh every
526
+ * `SUMMARY_CONTEXT_MESSAGES` messages (when the context window fully turns
527
+ * over). Neither path blocks the response to the client.
537
528
  */
538
- async onConnect(connection, ctx) {
539
- if (this.passthroughRequestHeaders.length > 0) {
540
- this._requestHeaders = {};
541
- const params = new URL(ctx.request.url).searchParams;
542
- for (const name of this.passthroughRequestHeaders) {
543
- const value = params.get(name);
544
- if (value !== null) this._requestHeaders[name] = value;
529
+ async recordConversation(messageCount) {
530
+ const db = this.resolveD1Context();
531
+ if (!db) return;
532
+ if (!await getConversationSummary(db, this.name)) {
533
+ const { title, summary } = await generateTitleAndSummary(this.messages, this.fastModel);
534
+ await recordConversation(db, this.name, title, summary);
535
+ this.log("conversation summary generated");
536
+ } else {
537
+ await recordConversation(db, this.name);
538
+ if (messageCount % 30 === 0) {
539
+ generateConversationSummary(db, this.name, this.messages, this.fastModel);
540
+ this.log("conversation summary updated");
545
541
  }
546
542
  }
547
- await super.onConnect(connection, ctx);
548
- if (!this._activeStreamId && this.messages.length > 0) connection.send(JSON.stringify({
549
- type: "cf_agent_chat_messages",
550
- messages: this.messages
551
- }));
552
543
  }
553
544
  /**
554
- * Strips ephemeral content, conditionally saves skill state to D1, then
555
- * delegates to super for DO SQLite persistence and WebSocket broadcast.
545
+ * Builds the parameter object for a `streamText` or `generateText` call,
546
+ * pre-filling `messages`, `activeSkills`, and `fastModel` from this agent instance.
547
+ * Injects `log` into `experimental_context` and logs non-clean finish reasons.
556
548
  *
557
- * The Cloudflare AIChatAgent calls persistMessages once per turn after all
558
- * steps complete, so overriding here is the correct place to act — it runs
559
- * after the full assistant message (including all tool results) is assembled.
549
+ * **Compaction** runs automatically when `fastModel` is set on the class, using
550
+ * `DEFAULT_MAX_MESSAGES_BEFORE_COMPACTION` (30) as the threshold. Override the
551
+ * threshold by passing `maxMessagesBeforeCompaction`. Disable compaction entirely
552
+ * by passing `maxMessagesBeforeCompaction: undefined` explicitly.
560
553
  *
561
- * Two things happen here:
554
+ * ```typescript
555
+ * // Compaction on (default threshold):
556
+ * const params = await this.buildLLMParams({ options, onFinish, model, system: "..." });
562
557
  *
563
- * 1. Ephemeral tool calls are stripped — list_capabilities (always) and
564
- * activate_skill when nothing was newly loaded (no state change).
558
+ * // Compaction with custom threshold:
559
+ * const params = await this.buildLLMParams({ options, onFinish, model, maxMessagesBeforeCompaction: 50 });
565
560
  *
566
- * 2. If skills changed this turn (_pendingSkills is set), the updated list
567
- * is written to D1. Turns where no skills were loaded do not touch D1.
561
+ * // Compaction off:
562
+ * const params = await this.buildLLMParams({ options, onFinish, model, maxMessagesBeforeCompaction: undefined });
568
563
  *
569
- * Message persistence itself is handled by super.persistMessages, which
570
- * writes to DO SQLite — no D1 write needed for messages.
564
+ * return streamText(params).toUIMessageStreamResponse();
565
+ * ```
571
566
  */
572
- async persistMessages(messages, excludeBroadcastIds = [], options) {
573
- const filtered = filterEphemeralMessages(messages);
574
- if (this._pendingSkills !== void 0) {
575
- await this._writeSkillState(this._pendingSkills);
576
- this._pendingSkills = void 0;
577
- }
578
- const toSave = await compactIfNeeded(filtered, this.getCompactionModel(), this.maxPersistedMessages - 1);
579
- return super.persistMessages(toSave, excludeBroadcastIds, options);
567
+ async buildLLMParams(config) {
568
+ const maxMessagesBeforeCompaction = "maxMessagesBeforeCompaction" in config ? config.maxMessagesBeforeCompaction : 15;
569
+ const onFinishWithErrorLogging = async (result) => {
570
+ if (result.finishReason !== "stop" && result.finishReason !== "tool-calls") await this.log("turn error", { finishReason: result.finishReason });
571
+ return config.onFinish?.(result);
572
+ };
573
+ return {
574
+ ...await buildLLMParams({
575
+ ...config,
576
+ onFinish: onFinishWithErrorLogging,
577
+ messages: this.messages,
578
+ activeSkills: await this.getLoadedSkills(),
579
+ fastModel: this.fastModel,
580
+ maxMessagesBeforeCompaction
581
+ }),
582
+ experimental_context: {
583
+ ...config.options?.body,
584
+ log: this.log.bind(this)
585
+ }
586
+ };
580
587
  }
581
588
  /**
582
- * Widened onChatMessage signature that accommodates the @withSkills decorator.
583
- *
584
- * The decorator transforms the consumer's 3-arg form (onFinish, ctx, options) into
585
- * a 2-arg wrapper at runtime. This declaration widens the base class signature so
586
- * that TypeScript accepts the consumer's 3-arg override without errors.
587
- *
588
- * @ts-ignore — intentional: widens the Cloudflare AIChatAgent's (onFinish, options?) signature.
589
+ * Skill names persisted from previous turns, read from DO SQLite.
590
+ * Returns an empty array if no skills have been loaded yet.
589
591
  */
590
- onChatMessage(onFinish, ctxOrOptions) {
591
- return super.onChatMessage(onFinish, ctxOrOptions);
592
+ async getLoadedSkills() {
593
+ return getStoredSkills(this.sql.bind(this));
592
594
  }
593
595
  /**
594
- * Called by the @withSkills decorator at the start of each turn.
596
+ * Extracts skill state from activate_skill results, persists to DO SQLite,
597
+ * logs a turn summary, then strips all skill meta-tool messages before
598
+ * delegating to super.
595
599
  *
596
- * Reads loaded skill state from D1, seeds createSkills, and returns a
597
- * SkillContext ready to use in a streamText call.
600
+ * 1. Scans activate_skill tool results for SKILL_STATE_SENTINEL. When found,
601
+ * the embedded JSON array of loaded skill names is written to DO SQLite.
598
602
  *
599
- * Guidance is exposed as `ctx.guidance` compose your system prompt as:
600
- * `${myBase}${ctx.guidance ? '\n\n' + ctx.guidance : ''}`
601
- *
602
- * Messages are plain (no guidance injected). Guidance stays out of the
603
- * messages array — Anthropic/Gemini only allow system messages at position 0.
604
- */
605
- async _prepareSkillContext() {
606
- const loadedSkills = await this._readSkillState();
607
- const skills = createSkills({
608
- tools: this.getTools(),
609
- skills: this.getSkills(),
610
- initialLoadedSkills: loadedSkills,
611
- onSkillsChanged: async (updated) => {
612
- this._pendingSkills = updated;
613
- },
614
- filterSkill: (name) => this.filterSkill(name)
615
- });
616
- return {
617
- tools: skills.tools,
618
- activeTools: skills.activeTools,
619
- prepareStep: skills.prepareStep,
620
- guidance: skills.getLoadedGuidance(),
621
- messages: await convertToModelMessages(this.messages),
622
- headers: this._requestHeaders
623
- };
624
- }
625
- };
626
- function withSkills(fn, _context) {
627
- const wrapper = async function(onFinish, maybeOptions) {
628
- const ctx = await this._prepareSkillContext();
629
- return fn.call(this, onFinish, ctx, maybeOptions);
630
- };
631
- return wrapper;
632
- }
633
- //#endregion
634
- //#region src/agents/chat/AIChatAgent.ts
635
- /**
636
- * Batteries-included base class for chat agents with lazy skill loading.
637
- *
638
- * Owns the full `onChatMessage` lifecycle. Implement four abstract methods and
639
- * get lazy skill loading, cross-turn skill persistence, guidance injection,
640
- * ephemeral message cleanup, and message compaction for free.
641
- *
642
- * Conversation messages are stored in Durable Object SQLite by the Cloudflare
643
- * AIChatAgent automatically — available as this.messages at the start of each
644
- * turn. Loaded skill state is stored in D1 (via getDB()) and read at turn start.
645
- * Guidance is injected as a system message just before the current user turn,
646
- * keeping the `system` param static and cacheable across all turns.
647
- *
648
- * ```typescript
649
- * export class MyAgent extends AIChatAgent {
650
- * getModel() { return openai("gpt-4o"); }
651
- * getTools() { return tools; }
652
- * getSkills() { return [searchSkill, codeSkill]; }
653
- * getSystemPrompt() { return "You are a helpful assistant."; }
654
- * getDB() { return this.env.AGENT_DB; }
655
- * }
656
- * ```
657
- *
658
- * ## Passing auth headers to tools
659
- *
660
- * Set `passthroughRequestHeaders` to capture headers from the WebSocket upgrade
661
- * request. They are forwarded automatically to every tool via `experimental_context`:
662
- *
663
- * ```typescript
664
- * passthroughRequestHeaders = ['authorization', 'x-user-id'];
665
- * ```
666
- *
667
- * Tools receive them as the second `execute` argument:
668
- *
669
- * ```typescript
670
- * execute: async (args, { experimental_context }) => {
671
- * const { authorization } = experimental_context?.headers ?? {};
672
- * }
673
- * ```
674
- *
675
- * If you need full control over the `streamText` call (custom model options,
676
- * streaming transforms, varying the model per request, etc.) use
677
- * `AIChatAgentBase` with the `@withSkills` decorator instead.
678
- */
679
- var AIChatAgent = class extends AIChatAgentBase {
680
- /**
681
- * Return the model used for compaction summarisation.
603
+ * 2. Logs a turn summary via `log()`. Best-effort: fire-and-forget.
682
604
  *
683
- * Defaults to getModel() the agent's primary model — so compaction is
684
- * enabled automatically. Override to substitute a cheaper or faster model
685
- * for summarisation (e.g. a smaller model when the primary is expensive).
605
+ * 3. Strips all activate_skill and list_capabilities messages from history.
686
606
  *
687
- * To opt out of message compaction: override and return undefined.
607
+ * 4. Delegates to super.persistMessages for message storage and WS broadcast.
688
608
  */
689
- getCompactionModel() {
690
- return this.getModel();
691
- }
692
- async onChatMessage(onFinish, options) {
693
- const loadedSkills = await this._readSkillState();
694
- const skills = createSkills({
695
- tools: this.getTools(),
696
- skills: this.getSkills(),
697
- systemPrompt: this.getSystemPrompt(),
698
- initialLoadedSkills: loadedSkills,
699
- onSkillsChanged: async (updated) => {
700
- this._pendingSkills = updated;
701
- },
702
- filterSkill: (name) => this.filterSkill(name)
703
- });
704
- return streamText({
705
- model: this.getModel(),
706
- system: skills.getSystem(),
707
- messages: await convertToModelMessages(this.messages),
708
- tools: skills.tools,
709
- activeTools: skills.activeTools,
710
- prepareStep: skills.prepareStep,
711
- experimental_context: { headers: this._requestHeaders },
712
- stopWhen: stepCountIs(20),
713
- abortSignal: options?.abortSignal,
714
- onFinish
715
- }).toUIMessageStreamResponse();
609
+ async persistMessages(messages, excludeBroadcastIds = [], options) {
610
+ let latestSkillState;
611
+ for (const msg of messages) {
612
+ if (msg.role !== "assistant" || !msg.parts) continue;
613
+ for (const part of msg.parts) {
614
+ if (!("toolCallId" in part)) continue;
615
+ const { type, output } = part;
616
+ if (type !== `tool-activate_skill` || typeof output !== "string") continue;
617
+ const sentinelIdx = output.indexOf(SKILL_STATE_SENTINEL);
618
+ if (sentinelIdx !== -1) try {
619
+ const stateJson = output.slice(sentinelIdx + 18);
620
+ latestSkillState = JSON.parse(stateJson);
621
+ } catch {}
622
+ }
623
+ }
624
+ if (latestSkillState !== void 0) saveStoredSkills(this.sql.bind(this), latestSkillState);
625
+ this.log("turn completed", buildTurnSummary(messages, latestSkillState ?? []));
626
+ this.recordConversation(messages.length);
627
+ const filtered = filterEphemeralMessages(messages);
628
+ return super.persistMessages(filtered, excludeBroadcastIds, options);
716
629
  }
717
630
  };
718
631
  //#endregion
719
- export { AIChatAgent, AIChatAgentBase, COMPACT_TOKEN_THRESHOLD, compactIfNeeded, compactMessages, createSkills, estimateMessagesTokens, filterEphemeralMessages, injectGuidance, withSkills };
632
+ export { AIChatAgent, buildLLMParams };