@economic/agents 0.0.1-beta.6 → 1.0.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/index.mjs CHANGED
@@ -1,35 +1,10 @@
1
- import { AIChatAgent as AIChatAgent$1 } from "@cloudflare/ai-chat";
2
- import { Output, convertToModelMessages, generateText, jsonSchema, stepCountIs, tool } from "ai";
3
- import { callable } from "agents";
1
+ import { Agent as Agent$1, callable } from "agents";
2
+ import { AIChatAgent } from "@cloudflare/ai-chat";
3
+ import { Output, convertToModelMessages, generateText, jsonSchema, stepCountIs, streamText, tool } from "ai";
4
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
- }
9
- /**
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.
12
- */
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
- }
23
- /**
24
- * Persists the current list of loaded skill names to DO SQLite.
25
- * Upserts the single `skill_state` row (id = 1).
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";
5
+ const TOOL_NAME_ACTIVATE_SKILL = "activate_skill";
6
+ const TOOL_NAME_LIST_CAPABILITIES = "list_capabilities";
7
+ const SKILL_STATE_SENTINEL = "\n__SKILLS_STATE__:";
33
8
  function buildActivateSkillDescription(skills) {
34
9
  return [
35
10
  "Load additional skills to help with the user's request.",
@@ -44,17 +19,6 @@ function buildAvailableSkillList(skills) {
44
19
  }
45
20
  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.";
46
21
  /**
47
- * Sentinel appended to a successful activate_skill result.
48
- *
49
- * Format: `Loaded: search, code.\n__SKILLS_STATE__:["search","code"]`
50
- *
51
- * The CF layer's `persistMessages` detects this sentinel, extracts the JSON
52
- * array of all currently-loaded skill names, writes it to DO SQLite, and
53
- * strips the entire activate_skill message from the persisted conversation.
54
- * No `onSkillsChanged` callback or D1 dependency needed.
55
- */
56
- const SKILL_STATE_SENTINEL = "\n__SKILLS_STATE__:";
57
- /**
58
22
  * Creates a skill loading system for use with the Vercel AI SDK.
59
23
  *
60
24
  * The agent starts with only its always-on tools active. The LLM can call
@@ -72,8 +36,8 @@ function createSkills(config) {
72
36
  for (const skill of skills) Object.assign(allTools, skill.tools);
73
37
  function getActiveToolNames() {
74
38
  const names = [
75
- ACTIVATE_SKILL,
76
- LIST_CAPABILITIES,
39
+ TOOL_NAME_ACTIVATE_SKILL,
40
+ TOOL_NAME_LIST_CAPABILITIES,
77
41
  ...Object.keys(alwaysOnTools)
78
42
  ];
79
43
  for (const skillName of loadedSkills) {
@@ -92,7 +56,7 @@ function createSkills(config) {
92
56
  if (sections.length === 0) return "";
93
57
  return sections.join("\n\n");
94
58
  }
95
- allTools[ACTIVATE_SKILL] = tool({
59
+ allTools[TOOL_NAME_ACTIVATE_SKILL] = tool({
96
60
  description: buildActivateSkillDescription(skills),
97
61
  inputSchema: jsonSchema({
98
62
  type: "object",
@@ -115,10 +79,10 @@ function createSkills(config) {
115
79
  newlyLoaded.push(skillName);
116
80
  }
117
81
  if (newlyLoaded.length > 0) return `Loaded: ${newlyLoaded.join(", ")}.${SKILL_STATE_SENTINEL}${JSON.stringify([...loadedSkills])}`;
118
- return ALREADY_LOADED_OUTPUT;
82
+ return "All requested skills were already loaded.";
119
83
  }
120
84
  });
121
- allTools[LIST_CAPABILITIES] = tool({
85
+ allTools[TOOL_NAME_LIST_CAPABILITIES] = tool({
122
86
  description: LIST_CAPABILITIES_DESCRIPTION,
123
87
  inputSchema: jsonSchema({
124
88
  type: "object",
@@ -150,7 +114,6 @@ function createSkills(config) {
150
114
  }
151
115
  };
152
116
  }
153
- const ALREADY_LOADED_OUTPUT = "All requested skills were already loaded.";
154
117
  /**
155
118
  * Removes ephemeral skill-related messages from a conversation.
156
119
  *
@@ -177,136 +140,35 @@ function filterEphemeralMessages(messages) {
177
140
  }];
178
141
  });
179
142
  }
180
- const TOOL_RESULT_PREVIEW_CHARS = 200;
181
- const SUMMARY_MAX_TOKENS = 4e3;
182
- /**
183
- * Estimates token count for a message array using a 3.5 chars/token
184
- * approximation. Counts text from text/reasoning parts, tool inputs/outputs.
185
- */
186
- function estimateMessagesTokens(messages) {
187
- let totalChars = 0;
188
- for (const msg of messages) {
189
- if (typeof msg.content === "string") {
190
- totalChars += msg.content.length;
191
- continue;
192
- }
193
- for (const part of msg.content) if (part.type === "text" || part.type === "reasoning") totalChars += part.text.length;
194
- else if (part.type === "tool-call") totalChars += JSON.stringify(part.input).length;
195
- else if (part.type === "tool-result") {
196
- const output = part.output;
197
- totalChars += typeof output === "string" ? output.length : JSON.stringify(output).length;
198
- }
199
- }
200
- return Math.ceil(totalChars / 3.5);
201
- }
202
- /**
203
- * Renders messages as human-readable text for the compaction summary prompt.
204
- */
205
- function formatMessagesForSummary(messages) {
206
- const lines = [];
207
- for (const msg of messages) {
208
- const roleLabel = msg.role.charAt(0).toUpperCase() + msg.role.slice(1);
209
- const parts = [];
210
- if (typeof msg.content === "string") {
211
- if (msg.content.trim()) parts.push(msg.content.trim());
212
- } else for (const part of msg.content) if (part.type === "text" || part.type === "reasoning") {
213
- const text = part.text.trim();
214
- if (text) parts.push(text);
215
- } else if (part.type === "tool-call") {
216
- const p = part;
217
- parts.push(`[Tool call: ${p.toolName}]`);
218
- } else if (part.type === "tool-result") {
219
- const p = part;
220
- const rawOutput = typeof p.output === "string" ? p.output : JSON.stringify(p.output);
221
- const preview = rawOutput.slice(0, TOOL_RESULT_PREVIEW_CHARS);
222
- const ellipsis = rawOutput.length > TOOL_RESULT_PREVIEW_CHARS ? "..." : "";
223
- parts.push(`[Tool: ${p.toolName}, result: ${preview}${ellipsis}]`);
224
- }
225
- if (parts.length > 0) lines.push(`${roleLabel}: ${parts.join(" ")}`);
226
- }
227
- return lines.join("\n\n");
143
+ /** Creates the `skill_state` table in DO SQLite if it does not exist yet. */
144
+ function ensureSkillTable(sql) {
145
+ sql`CREATE TABLE IF NOT EXISTS skill_state (id INTEGER PRIMARY KEY, active_skills TEXT NOT NULL DEFAULT '[]')`;
228
146
  }
229
147
  /**
230
- * Calls the model to produce a concise summary of old + recent message windows.
148
+ * Reads the persisted list of loaded skill names from DO SQLite.
149
+ * Returns an empty array if the table is missing or the row does not exist.
231
150
  */
232
- async function generateCompactionSummary(oldMessages, recentMessages, model) {
233
- const prompt = `Summarize this conversation history concisely for an AI assistant to continue the conversation.
234
- Focus MORE on recent exchanges (what the user was working on, what tools were used, what was found).
235
- Include key facts, decisions, and context needed to continue the conversation.
236
- Keep entity names, numbers, file paths, and specific details that might be referenced later.
237
- Do NOT include pleasantries or meta-commentary - just the essential context.
238
-
239
- OLDER MESSAGES (summarize briefly):
240
- ${formatMessagesForSummary(oldMessages)}
241
-
242
- RECENT MESSAGES (summarize with more detail - this is where the user currently is):
243
- ${formatMessagesForSummary(recentMessages)}
244
-
245
- Write a concise summary:`;
151
+ function getStoredSkills(sql) {
246
152
  try {
247
- const { text } = await generateText({
248
- model,
249
- messages: [{
250
- role: "user",
251
- content: prompt
252
- }],
253
- maxOutputTokens: SUMMARY_MAX_TOKENS
254
- });
255
- return text || "Unable to summarize conversation history.";
256
- } catch (error) {
257
- console.error("Compaction summarization error:", error);
258
- return "Unable to summarize conversation history.";
153
+ ensureSkillTable(sql);
154
+ const rows = sql`SELECT active_skills FROM skill_state WHERE id = 1`;
155
+ if (rows.length === 0) return [];
156
+ return JSON.parse(rows[0].active_skills);
157
+ } catch {
158
+ return [];
259
159
  }
260
160
  }
261
161
  /**
262
- * Summarizes older messages into a single system message and appends the
263
- * recent verbatim tail. Returns messages unchanged if already short enough.
264
- */
265
- async function compactMessages(messages, model, tailSize) {
266
- if (messages.length <= tailSize) return messages;
267
- const splitIndex = messages.length - tailSize;
268
- const oldMessages = messages.slice(0, splitIndex);
269
- const recentTail = messages.slice(splitIndex);
270
- return [{
271
- role: "system",
272
- content: `[Conversation summary - older context was compacted]\n${await generateCompactionSummary(oldMessages, recentTail, model)}`
273
- }, ...recentTail];
274
- }
275
- /**
276
- * Entry point for compaction. Returns messages unchanged when model is
277
- * undefined or estimated token count is under COMPACT_TOKEN_THRESHOLD.
162
+ * Persists the current list of loaded skill names to DO SQLite.
163
+ * Upserts the single `skill_state` row (id = 1).
278
164
  */
279
- async function compactIfNeeded(messages, model, tailSize) {
280
- if (!model || estimateMessagesTokens(messages) <= 14e4) return messages;
281
- return compactMessages(messages, model, tailSize);
165
+ function saveStoredSkills(sql, skills) {
166
+ ensureSkillTable(sql);
167
+ sql`INSERT OR REPLACE INTO skill_state(id, active_skills) VALUES(1, ${JSON.stringify(skills)})`;
282
168
  }
283
169
  //#endregion
284
170
  //#region src/server/llm.ts
285
- /**
286
- * Composes the full system prompt from its three parts: the consumer's base
287
- * string, the static skill roster, and the dynamic loaded-skill guidance.
288
- *
289
- * The full shape, at a glance:
290
- *
291
- * {base}
292
- *
293
- * ## Tools
294
- *
295
- * Use `activate_skill` to load these skills (BE PROACTIVE on requesting
296
- * tools based on the user's request AND you DON'T need to mention that you
297
- * are loading more tools):
298
- *
299
- * **{name}**: {description}
300
- * ...
301
- *
302
- * **Loaded skill instructions**
303
- * The following skills are currently active. Apply their instructions when
304
- * using the corresponding tools.
305
- *
306
- * **{name}**
307
- * {guidance body}
308
- */
309
- function buildSystemPrompt(basePrompt, availableSkillList, loadedGuidance) {
171
+ function buildSystemPromptWithSkills(basePrompt, availableSkillList, loadedGuidance) {
310
172
  let prompt = `${basePrompt}
311
173
 
312
174
  ## Tools
@@ -359,99 +221,318 @@ function buildSourcesTransform(additional) {
359
221
  /**
360
222
  * Builds the parameter object for a Vercel AI SDK `streamText` or `generateText` call.
361
223
  *
362
- * Handles message conversion, optional compaction, skill wiring (`activate_skill`,
363
- * `list_capabilities`, `prepareStep`), and context/abort signal extraction from
364
- * the Cloudflare Agents SDK `options` object.
224
+ * Handles skill wiring (`activate_skill`, `list_capabilities`, `prepareStep`).
365
225
  *
366
226
  * The returned object can be spread directly into `streamText` or `generateText`:
367
227
  *
368
228
  * ```typescript
369
- * const params = await buildLLMParams({ ... });
229
+ * const params = buildLLMParams({ ... });
370
230
  * return streamText(params).toUIMessageStreamResponse();
371
231
  * ```
372
232
  */
373
- async function buildLLMParams(config) {
374
- const { options, messages, activeSkills = [], skills, fastModel, maxMessagesBeforeCompaction, experimental_transform, ...rest } = config;
375
- const rawMessages = await convertToModelMessages(messages);
376
- const processedMessages = fastModel && maxMessagesBeforeCompaction !== void 0 ? await compactIfNeeded(rawMessages, fastModel, maxMessagesBeforeCompaction) : rawMessages;
233
+ function buildLLMParams(config) {
234
+ const { activeSkills = [], skills, experimental_transform, system, tools = {}, ...rest } = config;
377
235
  const composedTransform = buildSourcesTransform(experimental_transform);
378
236
  const baseParams = {
379
237
  ...rest,
380
- experimental_transform: composedTransform,
381
- messages: processedMessages,
382
- experimental_context: options?.body,
383
- abortSignal: options?.abortSignal,
384
- stopWhen: rest.stopWhen ?? stepCountIs(20)
238
+ system,
239
+ tools,
240
+ stopWhen: rest.stopWhen ?? stepCountIs(20),
241
+ experimental_transform: composedTransform
385
242
  };
386
243
  if (!skills?.length) return baseParams;
387
- const base = typeof rest.system === "string" ? rest.system : void 0;
388
244
  const skillsCtx = createSkills({
389
- tools: rest.tools ?? {},
245
+ tools,
390
246
  skills,
391
247
  initialLoadedSkills: activeSkills
392
248
  });
249
+ const systemWithSkills = buildSystemPromptWithSkills(typeof system === "string" ? system : void 0, skillsCtx.availableSkillList, skillsCtx.getLoadedGuidance());
393
250
  const prepareStep = async (stepOptions) => {
394
251
  return {
395
252
  activeTools: (await skillsCtx.prepareStep(stepOptions) ?? {}).activeTools ?? [],
396
- system: buildSystemPrompt(base, skillsCtx.availableSkillList, skillsCtx.getLoadedGuidance())
253
+ system: systemWithSkills
397
254
  };
398
255
  };
399
256
  return {
400
257
  ...baseParams,
401
- system: buildSystemPrompt(base, skillsCtx.availableSkillList, skillsCtx.getLoadedGuidance()),
258
+ system: systemWithSkills,
402
259
  tools: skillsCtx.tools,
403
260
  activeTools: skillsCtx.activeTools,
404
261
  prepareStep
405
262
  };
406
263
  }
407
264
  //#endregion
408
- //#region src/server/features/audit/index.ts
265
+ //#region src/server/features/audit/audit.ts
409
266
  /**
410
267
  * Inserts a single audit event row into the shared `audit_events` D1 table.
411
268
  *
412
- * Called by `AIChatAgent.log()`. Not intended for direct use.
269
+ * Called by `ChatAgentHarness.logEvent()` (and `AgentHarness.logEvent()`). Not intended for direct use.
413
270
  */
271
+ const SENSITIVE_KEYS = /^(password|token|secret|api_key|apikey|authorization|credentials)$/i;
272
+ const REDACTED = "[REDACTED]";
273
+ /** Deep-clone and redact values for keys that look like secrets (for audit logging). */
274
+ function sanitizePayload(value) {
275
+ if (value === null || value === void 0) return value;
276
+ if (Array.isArray(value)) return value.map(sanitizePayload);
277
+ if (typeof value === "object") {
278
+ const result = {};
279
+ for (const [key, val] of Object.entries(value)) result[key] = SENSITIVE_KEYS.test(key) ? REDACTED : sanitizePayload(val);
280
+ return result;
281
+ }
282
+ return value;
283
+ }
414
284
  async function insertAuditEvent(db, durableObjectName, message, payload) {
415
285
  await db.prepare(`INSERT INTO audit_events (id, durable_object_name, message, payload, created_at)
416
- VALUES (?, ?, ?, ?, ?)`).bind(crypto.randomUUID(), durableObjectName, message, payload ? JSON.stringify(payload) : null, (/* @__PURE__ */ new Date()).toISOString()).run();
286
+ VALUES (?, ?, ?, ?, ?)`).bind(crypto.randomUUID(), durableObjectName, message, payload ? JSON.stringify(sanitizePayload(payload)) : null, (/* @__PURE__ */ new Date()).toISOString()).run();
287
+ }
288
+ function stringifyForSkillScan(output) {
289
+ if (typeof output === "string") return output;
290
+ if (output === null || output === void 0) return "";
291
+ try {
292
+ return JSON.stringify(output);
293
+ } catch {
294
+ return "";
295
+ }
296
+ }
297
+ function forEachStep(event, fn) {
298
+ if (event.steps.length > 0) for (const step of event.steps) fn(step);
299
+ else fn(event);
300
+ }
301
+ /**
302
+ * User text from provider request body (`contents` is e.g. Gemini format).
303
+ */
304
+ function extractUserInputFromRequestBody(body) {
305
+ const firstContent = body?.contents?.[0];
306
+ if (firstContent?.role !== "user" || !firstContent.parts?.length) return "";
307
+ return firstContent.parts.map((p) => p.text).filter((t) => typeof t === "string").join(" ").trim();
308
+ }
309
+ /**
310
+ * Builds the payload for a "Turn completed" audit event from the AI SDK
311
+ * `OnFinishEvent`.
312
+ *
313
+ * Returns:
314
+ * - `input`: user message text from `request.body.contents[0]` (Gemini-style)
315
+ * - `output`: assistant response text (truncated to 200 chars)
316
+ * - `toolCalls`: array of { toolName, toolInput, toolOutput }
317
+ * - `loadedSkills`: skill names extracted from activate_skill results
318
+ */
319
+ function buildTurnLogPayload(event) {
320
+ const toolCalls = [];
321
+ let latestSkills;
322
+ const toolOutputs = /* @__PURE__ */ new Map();
323
+ forEachStep(event, (step) => {
324
+ for (const tr of step.toolResults) toolOutputs.set(tr.toolCallId, tr.output);
325
+ });
326
+ forEachStep(event, (step) => {
327
+ for (const tc of step.toolCalls) toolCalls.push({
328
+ name: tc.toolName,
329
+ input: tc.input,
330
+ output: toolOutputs.get(tc.toolCallId)
331
+ });
332
+ const considerToolResultForSkills = (toolName, output) => {
333
+ if (toolName !== "activate_skill") return;
334
+ const s = stringifyForSkillScan(output);
335
+ const sentinelIdx = s.indexOf(SKILL_STATE_SENTINEL);
336
+ if (sentinelIdx === -1) return;
337
+ try {
338
+ const stateJson = s.slice(sentinelIdx + 18);
339
+ latestSkills = JSON.parse(stateJson);
340
+ } catch {}
341
+ };
342
+ for (const tr of step.toolResults) considerToolResultForSkills(tr.toolName, tr.output);
343
+ });
344
+ const input = extractUserInputFromRequestBody(event.request?.body);
345
+ return {
346
+ detail: {
347
+ model: event.model.modelId,
348
+ tokens: event.usage?.totalTokens
349
+ },
350
+ loadedSkills: latestSkills ?? [],
351
+ toolCalls,
352
+ input: input.slice(0, 200),
353
+ output: (event.text ?? "").slice(0, 200)
354
+ };
417
355
  }
356
+ //#endregion
357
+ //#region src/server/agents/Agent.ts
418
358
  /**
419
- * Builds the payload for a "turn completed" audit event from the final message list.
359
+ * Base agent for Cloudflare Agents SDK Durable Objects with lazy skill loading,
360
+ * audit logging, and `buildLLMParams` wiring.
361
+ *
362
+ * Handles CF infrastructure concerns: DO SQLite persistence for loaded skill state
363
+ * and writing audit events to D1.
420
364
  *
421
- * Extracts the last user and assistant message texts (truncated to 200 chars),
422
- * all non-meta tool call names used this turn, and the current loaded skill set.
365
+ * For chat agents with message history, compaction, and conversation recording,
366
+ * extend {@link ChatAgent} instead.
367
+ */
368
+ var Agent = class extends Agent$1 {
369
+ /**
370
+ * Returns the user ID from the durable object name.
371
+ */
372
+ getUserId() {
373
+ return this.name.split(":")[0];
374
+ }
375
+ async onConnect(connection, ctx) {
376
+ if (!this.env.AGENT_DB) {
377
+ console.error("[Agent] Connection rejected: no AGENT_DB bound");
378
+ connection.close(3e3, "Could not connect to agent, database not found");
379
+ return;
380
+ }
381
+ if (!this.getUserId()) {
382
+ console.error("[Agent] Connection rejected: name must be in the format userId:uniqueChatId");
383
+ connection.close(3e3, "Could not connect to agent, name is not in correct format");
384
+ return;
385
+ }
386
+ return super.onConnect(connection, ctx);
387
+ }
388
+ /**
389
+ * Writes an audit event to D1 if `AGENT_DB` is bound on the environment,
390
+ * otherwise silently does nothing.
391
+ *
392
+ * Called automatically at the end of each LLM turn (from `onFinish` in
393
+ * `buildLLMParams`). Also available via `experimental_context.logEvent` in tool
394
+ * `execute` functions.
395
+ */
396
+ async logEvent(message, payload) {
397
+ try {
398
+ await insertAuditEvent(this.env.AGENT_DB, this.name, message, payload);
399
+ } catch (error) {
400
+ console.error("[Agent] Failed to write audit event", error);
401
+ }
402
+ }
403
+ /**
404
+ * Builds the parameter object for a `streamText` or `generateText` call,
405
+ * pre-filling `activeSkills` from this agent instance.
406
+ * Injects `logEvent` into `experimental_context` and wires `onFinish` for
407
+ * turn-completed audit events.
408
+ */
409
+ async buildLLMParams(config) {
410
+ const activeSkills = await getStoredSkills(this.sql.bind(this));
411
+ const experimental_context = {
412
+ ...config.options?.body,
413
+ logEvent: this.logEvent.bind(this)
414
+ };
415
+ const onFinish = async (event) => {
416
+ this.logEvent("Turn completed", buildTurnLogPayload(event));
417
+ await config.onFinish?.(event);
418
+ };
419
+ return buildLLMParams({
420
+ ...config,
421
+ activeSkills,
422
+ experimental_context,
423
+ onFinish
424
+ });
425
+ }
426
+ };
427
+ const TOOL_RESULT_PREVIEW_CHARS = 200;
428
+ const SUMMARY_MAX_TOKENS = 4e3;
429
+ /**
430
+ * Estimates token count for a message array using a 3.5 chars/token
431
+ * approximation. Counts text from text/reasoning parts, tool inputs/outputs.
423
432
  */
424
- function buildTurnSummary(messages, loadedSkills) {
425
- const toolCallNames = [];
433
+ function estimateMessagesTokens(messages) {
434
+ let totalChars = 0;
426
435
  for (const msg of messages) {
427
- if (msg.role !== "assistant" || !msg.parts) continue;
428
- for (const part of msg.parts) {
429
- if (!("toolCallId" in part)) continue;
430
- const { type } = part;
431
- if (!type.startsWith("tool-")) continue;
432
- const name = type.slice(5);
433
- if (name !== "activate_skill" && name !== "list_capabilities" && !toolCallNames.includes(name)) toolCallNames.push(name);
436
+ if (typeof msg.content === "string") {
437
+ totalChars += msg.content.length;
438
+ continue;
439
+ }
440
+ for (const part of msg.content) if (part.type === "text" || part.type === "reasoning") totalChars += part.text.length;
441
+ else if (part.type === "tool-call") totalChars += JSON.stringify(part.input).length;
442
+ else if (part.type === "tool-result") {
443
+ const output = part.output;
444
+ totalChars += typeof output === "string" ? output.length : JSON.stringify(output).length;
434
445
  }
435
446
  }
436
- const lastUserMsg = [...messages].reverse().find((m) => m.role === "user");
437
- const lastAssistantMsg = [...messages].reverse().find((m) => m.role === "assistant");
438
- return {
439
- userMessage: extractMessageText(lastUserMsg).slice(0, 200),
440
- toolCalls: toolCallNames,
441
- loadedSkills,
442
- assistantMessage: extractMessageText(lastAssistantMsg).slice(0, 200)
443
- };
447
+ return Math.ceil(totalChars / 3.5);
448
+ }
449
+ /**
450
+ * Renders messages as human-readable text for the compaction summary prompt.
451
+ */
452
+ function formatMessagesForSummary(messages) {
453
+ const lines = [];
454
+ for (const msg of messages) {
455
+ const roleLabel = msg.role.charAt(0).toUpperCase() + msg.role.slice(1);
456
+ const parts = [];
457
+ if (typeof msg.content === "string") {
458
+ if (msg.content.trim()) parts.push(msg.content.trim());
459
+ } else for (const part of msg.content) if (part.type === "text" || part.type === "reasoning") {
460
+ const text = part.text.trim();
461
+ if (text) parts.push(text);
462
+ } else if (part.type === "tool-call") {
463
+ const p = part;
464
+ parts.push(`[Tool call: ${p.toolName}]`);
465
+ } else if (part.type === "tool-result") {
466
+ const p = part;
467
+ const rawOutput = typeof p.output === "string" ? p.output : JSON.stringify(p.output);
468
+ const preview = rawOutput.slice(0, TOOL_RESULT_PREVIEW_CHARS);
469
+ const ellipsis = rawOutput.length > TOOL_RESULT_PREVIEW_CHARS ? "..." : "";
470
+ parts.push(`[Tool: ${p.toolName}, result: ${preview}${ellipsis}]`);
471
+ }
472
+ if (parts.length > 0) lines.push(`${roleLabel}: ${parts.join(" ")}`);
473
+ }
474
+ return lines.join("\n\n");
444
475
  }
445
- function extractMessageText(msg) {
446
- if (!msg?.parts) return "";
447
- return msg.parts.filter((p) => p.type === "text").map((p) => p.text).join(" ").trim();
476
+ /**
477
+ * Calls the model to produce a concise summary of old + recent message windows.
478
+ */
479
+ async function generateCompactionSummary(oldMessages, recentMessages, model) {
480
+ const prompt = `Summarize this conversation history concisely for an AI assistant to continue the conversation.
481
+ Focus MORE on recent exchanges (what the user was working on, what tools were used, what was found).
482
+ Include key facts, decisions, and context needed to continue the conversation.
483
+ Keep entity names, numbers, file paths, and specific details that might be referenced later.
484
+ Do NOT include pleasantries or meta-commentary - just the essential context.
485
+
486
+ OLDER MESSAGES (summarize briefly):
487
+ ${formatMessagesForSummary(oldMessages)}
488
+
489
+ RECENT MESSAGES (summarize with more detail - this is where the user currently is):
490
+ ${formatMessagesForSummary(recentMessages)}
491
+
492
+ Write a concise summary:`;
493
+ try {
494
+ const { text } = await generateText({
495
+ model,
496
+ messages: [{
497
+ role: "user",
498
+ content: prompt
499
+ }],
500
+ maxOutputTokens: SUMMARY_MAX_TOKENS
501
+ });
502
+ return text || "Unable to summarize conversation history.";
503
+ } catch (error) {
504
+ console.error("Compaction summarization error:", error);
505
+ return "Unable to summarize conversation history.";
506
+ }
507
+ }
508
+ /**
509
+ * Summarizes older messages into a single system message and appends the
510
+ * recent verbatim tail. Returns messages unchanged if already short enough.
511
+ */
512
+ async function compactMessages(messages, model, tailSize) {
513
+ if (messages.length <= tailSize) return messages;
514
+ const splitIndex = messages.length - tailSize;
515
+ const oldMessages = messages.slice(0, splitIndex);
516
+ const recentTail = messages.slice(splitIndex);
517
+ return [{
518
+ role: "system",
519
+ content: `[Conversation summary - older context was compacted]\n${await generateCompactionSummary(oldMessages, recentTail, model)}`
520
+ }, ...recentTail];
521
+ }
522
+ /**
523
+ * Entry point for compaction. Returns messages unchanged when model is
524
+ * undefined or estimated token count is under COMPACT_TOKEN_THRESHOLD.
525
+ */
526
+ async function compactIfNeeded(messages, model, tailSize) {
527
+ if (!model || estimateMessagesTokens(messages) <= 14e4) return messages;
528
+ return compactMessages(messages, model, tailSize);
448
529
  }
449
530
  //#endregion
450
- //#region src/server/features/conversations/index.ts
531
+ //#region src/server/features/conversations/conversations.ts
451
532
  /**
452
533
  * Records a conversation row in the `conversations` D1 table.
453
534
  *
454
- * Called by `AIChatAgent` after every turn. On first call for a given
535
+ * Called by `ChatAgentHarness` after every turn. On first call for a given
455
536
  * `durableObjectName` the row is inserted with `created_at` set to now,
456
537
  * and with the provided `title` and `summary` if supplied.
457
538
  * On subsequent calls only `updated_at` is refreshed —
@@ -480,6 +561,12 @@ async function getConversations(db, userId) {
480
561
  return results;
481
562
  }
482
563
  /**
564
+ * Deletes a conversation row from the `conversations` D1 table.
565
+ */
566
+ async function deleteConversationRow(db, durableObjectName) {
567
+ await db.prepare(`DELETE FROM conversations WHERE durable_object_name = ?`).bind(durableObjectName).run();
568
+ }
569
+ /**
483
570
  * Writes a generated `title` and `summary` back to the `conversations` row.
484
571
  */
485
572
  async function updateConversationSummary(db, durableObjectName, title, summary) {
@@ -527,161 +614,134 @@ async function generateTitleAndSummary(messages, model, existingSummary) {
527
614
  * Only the last `SUMMARY_CONTEXT_MESSAGES` messages are passed to keep the
528
615
  * prompt bounded regardless of total conversation length.
529
616
  *
530
- * Called by `AIChatAgent` every `SUMMARY_CONTEXT_MESSAGES` messages after
617
+ * Called by `ChatAgentHarness` every `SUMMARY_CONTEXT_MESSAGES` messages after
531
618
  * the first turn.
532
619
  */
533
620
  async function generateConversationSummary(db, durableObjectName, messages, model) {
534
621
  const { title, summary } = await generateTitleAndSummary(messages, model, (await getConversationSummary(db, durableObjectName))?.summary ?? void 0);
535
622
  await updateConversationSummary(db, durableObjectName, title, summary);
623
+ return {
624
+ title,
625
+ summary
626
+ };
627
+ }
628
+ //#endregion
629
+ //#region src/server/features/conversations/retention.ts
630
+ const DELETE_CONVERSATION_CALLBACK = "deleteConversationCallback";
631
+ const CONVERSATION_EXPIRED_CLOSE_CODE = 3001;
632
+ const CONVERSATION_EXPIRED_CLOSE_REASON = "Conversation expired due to inactivity.";
633
+ function getConversationRetentionMs(days) {
634
+ if (typeof days !== "number" || !Number.isFinite(days) || days <= 0) return null;
635
+ return Math.floor(days * 24 * 60 * 60 * 1e3);
636
+ }
637
+ function getDeleteConversationScheduleIds(schedules) {
638
+ return schedules.filter((schedule) => schedule.callback === DELETE_CONVERSATION_CALLBACK).map((schedule) => schedule.id);
536
639
  }
537
640
  //#endregion
538
- //#region src/server/agents/AIChatAgent.ts
641
+ //#region src/server/agents/ChatAgent.ts
539
642
  /**
540
- * Base class for Cloudflare Agents SDK chat agents with lazy skill loading
541
- * and built-in audit logging.
643
+ * Chat agent for Cloudflare Agents SDK: lazy skill loading, audit logging,
644
+ * message persistence, compaction, and conversation metadata in D1.
542
645
  *
543
- * Handles CF infrastructure concerns only: DO SQLite persistence for loaded
544
- * skill state, stripping skill meta-tool messages before persistence, history
545
- * replay to newly connected clients, and writing audit events to D1.
646
+ * Handles CF infrastructure concerns: DO SQLite for loaded skill state,
647
+ * stripping skill meta-tool messages before persistence, history replay to
648
+ * newly connected clients, and audit events to D1.
546
649
  *
547
- * Skill loading, compaction, and LLM communication are delegated to
548
- * `buildLLMParams` from `@economic/agents`, which you call inside `onChatMessage`.
650
+ * Skill loading, compaction, and LLM calls use `buildLLMParams` from
651
+ * `@economic/agents` inside `onChatMessage`.
549
652
  */
550
- var AIChatAgent = class extends AIChatAgent$1 {
653
+ var ChatAgent = class extends AIChatAgent {
654
+ /**
655
+ * Number of days of inactivity before the full conversation is deleted.
656
+ *
657
+ * Leave `undefined` to disable automatic retention cleanup.
658
+ */
659
+ conversationRetentionDays;
660
+ /**
661
+ * Number of recent messages to keep verbatim when compaction runs.
662
+ * Older messages beyond this count are summarised into a single system message.
663
+ * Used as the default when `maxMessagesBeforeCompaction` is not provided to `buildLLMParams`.
664
+ *
665
+ * Default is 15.
666
+ */
667
+ maxMessagesBeforeCompaction = 15;
668
+ /**
669
+ * Returns the user ID from the durable object name.
670
+ */
551
671
  getUserId() {
552
672
  return this.name.split(":")[0];
553
673
  }
554
674
  async onConnect(connection, ctx) {
555
- await super.onConnect(connection, ctx);
556
- if (!this.getUserId()) {
557
- console.error("[AIChatAgent] Connection rejected: name must be in the format userId:uniqueChatId");
558
- connection.close(3e3, "Name does not match format userId:uniqueChatId");
675
+ if (!this.env.AGENT_DB) {
676
+ console.error("[ChatAgent] Connection rejected: no AGENT_DB bound");
677
+ connection.close(3e3, "Could not connect to agent, database not found");
559
678
  return;
560
679
  }
561
- }
562
- /**
563
- * Resolves the D1 database binding required for all D1 writes.
564
- * Returns null and silently no-ops if AGENT_DB is not bound.
565
- */
566
- resolveD1Context() {
567
- const db = this.env.AGENT_DB;
568
- if (!db) {
569
- console.error("[AIChatAgent] Skipping logging: D1 database not found");
570
- return null;
680
+ if (!this.getUserId()) {
681
+ console.error("[ChatAgent] Connection rejected: name must be in the format userId:uniqueChatId");
682
+ connection.close(3e3, "Could not connect to agent, name is not in correct format");
683
+ return;
571
684
  }
572
- return db;
573
- }
574
- /**
575
- * Returns all conversations for the current user.
576
- */
577
- @callable() async getConversations() {
578
- const db = this.resolveD1Context();
579
- if (!db) return;
580
- return getConversations(db, this.getUserId());
685
+ return super.onConnect(connection, ctx);
581
686
  }
582
687
  /**
583
688
  * Writes an audit event to D1 if `AGENT_DB` is bound on the environment,
584
689
  * otherwise silently does nothing.
585
690
  *
586
- * Called automatically after every turn (from `persistMessages`) and on
587
- * non-clean finish reasons (from `buildLLMParams`). Also available via
588
- * `experimental_context.log` in tool `execute` functions.
589
- */
590
- async log(message, payload) {
591
- const db = this.resolveD1Context();
592
- if (!db) return;
593
- await insertAuditEvent(db, this.name, message, payload);
594
- }
595
- /**
596
- * Records this conversation in the `conversations` D1 table and triggers
597
- * LLM-based title/summary generation when appropriate. Called automatically
598
- * from `persistMessages` after every turn.
599
- *
600
- * On the first turn (no existing row), awaits `generateTitleAndSummary` and
601
- * inserts the row with title and summary already populated. On subsequent
602
- * turns, upserts the timestamp and fire-and-forgets a summary refresh every
603
- * `SUMMARY_CONTEXT_MESSAGES` messages (when the context window fully turns
604
- * over). Neither path blocks the response to the client.
691
+ * Called automatically at the end of each LLM turn (from `onFinish` in
692
+ * `buildLLMParams`). Also available via `experimental_context.logEvent` in tool
693
+ * `execute` functions.
605
694
  */
606
- async recordConversation(messageCount) {
607
- const db = this.resolveD1Context();
608
- if (!db) return;
609
- if (!await getConversationSummary(db, this.name)) {
610
- const { title, summary } = await generateTitleAndSummary(this.messages, this.fastModel);
611
- await recordConversation(db, this.name, title, summary);
612
- this.log("conversation summary generated");
613
- } else {
614
- await recordConversation(db, this.name);
615
- if (messageCount % 30 === 0) {
616
- generateConversationSummary(db, this.name, this.messages, this.fastModel);
617
- this.log("conversation summary updated");
618
- }
695
+ async logEvent(message, payload) {
696
+ try {
697
+ await insertAuditEvent(this.env.AGENT_DB, this.name, message, payload);
698
+ } catch (error) {
699
+ console.error("[ChatAgent] Failed to write audit event", error);
619
700
  }
620
701
  }
621
702
  /**
622
703
  * Builds the parameter object for a `streamText` or `generateText` call,
623
704
  * pre-filling `messages`, `activeSkills`, and `fastModel` from this agent instance.
624
- * Injects `log` into `experimental_context` and logs non-clean finish reasons.
705
+ * Injects `logEvent` into `experimental_context` and wires `onFinish` for
706
+ * turn-completed audit events and conversation recording.
625
707
  *
626
708
  * **Compaction** runs automatically when `fastModel` is set on the class, using
627
709
  * `DEFAULT_MAX_MESSAGES_BEFORE_COMPACTION` (30) as the threshold. Override the
628
- * threshold by passing `maxMessagesBeforeCompaction`. Disable compaction entirely
629
- * by passing `maxMessagesBeforeCompaction: undefined` explicitly.
630
- *
631
- * ```typescript
632
- * // Compaction on (default threshold):
633
- * const params = await this.buildLLMParams({ options, onFinish, model, system: "..." });
634
- *
635
- * // Compaction with custom threshold:
636
- * const params = await this.buildLLMParams({ options, onFinish, model, maxMessagesBeforeCompaction: 50 });
637
- *
638
- * // Compaction off:
639
- * const params = await this.buildLLMParams({ options, onFinish, model, maxMessagesBeforeCompaction: undefined });
640
- *
641
- * return streamText(params).toUIMessageStreamResponse();
642
- * ```
710
+ * threshold by setting `maxMessagesBeforeCompaction` on the class. Disable compaction
711
+ * entirely by setting `maxMessagesBeforeCompaction = undefined` explicitly.
643
712
  */
644
713
  async buildLLMParams(config) {
645
- const maxMessagesBeforeCompaction = "maxMessagesBeforeCompaction" in config ? config.maxMessagesBeforeCompaction : 15;
646
- const onFinishWithErrorLogging = async (result) => {
647
- if (result.finishReason !== "stop" && result.finishReason !== "tool-calls") await this.log("turn error", { finishReason: result.finishReason });
648
- return config.onFinish?.(result);
714
+ const activeSkills = await getStoredSkills(this.sql.bind(this));
715
+ const experimental_context = {
716
+ ...config.options?.body,
717
+ logEvent: this.logEvent.bind(this)
649
718
  };
650
- return {
651
- ...await buildLLMParams({
652
- ...config,
653
- onFinish: onFinishWithErrorLogging,
654
- messages: this.messages,
655
- activeSkills: await this.getLoadedSkills(),
656
- fastModel: this.fastModel,
657
- maxMessagesBeforeCompaction
658
- }),
659
- experimental_context: {
660
- ...config.options?.body,
661
- log: this.log.bind(this)
662
- }
719
+ const messages = await convertToModelMessages(this.messages);
720
+ const fastModel = this.getFastModel();
721
+ const processedMessages = fastModel && this.maxMessagesBeforeCompaction !== void 0 ? await compactIfNeeded(messages, fastModel, this.maxMessagesBeforeCompaction) : messages;
722
+ const onFinish = async (event) => {
723
+ this.logEvent("Turn completed", buildTurnLogPayload(event));
724
+ this.recordConversation(filterEphemeralMessages([...this.messages]));
725
+ await config.onFinish?.(event);
663
726
  };
664
- }
665
- /**
666
- * Skill names persisted from previous turns, read from DO SQLite.
667
- * Returns an empty array if no skills have been loaded yet.
668
- */
669
- async getLoadedSkills() {
670
- return getStoredSkills(this.sql.bind(this));
727
+ return buildLLMParams({
728
+ ...config,
729
+ activeSkills,
730
+ messages: processedMessages,
731
+ experimental_context,
732
+ onFinish
733
+ });
671
734
  }
672
735
  /**
673
736
  * Extracts skill state from activate_skill results, persists to DO SQLite,
674
- * logs a turn summary, then strips all skill meta-tool messages before
675
- * delegating to super.
737
+ * then strips all skill meta-tool messages before delegating to super.
676
738
  *
677
739
  * 1. Scans activate_skill tool results for SKILL_STATE_SENTINEL. When found,
678
740
  * the embedded JSON array of loaded skill names is written to DO SQLite.
679
741
  *
680
- * 2. Logs a turn summary via `log()`. Best-effort: fire-and-forget.
742
+ * 2. Strips all activate_skill and list_capabilities messages from history.
681
743
  *
682
- * 3. Strips all activate_skill and list_capabilities messages from history.
683
- *
684
- * 4. Delegates to super.persistMessages for message storage and WS broadcast.
744
+ * 3. Delegates to super.persistMessages for message storage and WS broadcast.
685
745
  */
686
746
  async persistMessages(messages, excludeBroadcastIds = [], options) {
687
747
  let latestSkillState;
@@ -699,11 +759,125 @@ var AIChatAgent = class extends AIChatAgent$1 {
699
759
  }
700
760
  }
701
761
  if (latestSkillState !== void 0) saveStoredSkills(this.sql.bind(this), latestSkillState);
702
- this.log("turn completed", buildTurnSummary(messages, latestSkillState ?? []));
703
- this.recordConversation(messages.length);
704
762
  const filtered = filterEphemeralMessages(messages);
705
- return super.persistMessages(filtered, excludeBroadcastIds, options);
763
+ const result = await super.persistMessages(filtered, excludeBroadcastIds, options);
764
+ this.scheduleConversationForDeletion();
765
+ return result;
766
+ }
767
+ @callable({ description: "Returns all conversations for the current user" }) async getConversations() {
768
+ return getConversations(this.env.AGENT_DB, this.getUserId());
769
+ }
770
+ @callable({ description: "Delete a conversation by its id" }) async deleteConversation(id) {
771
+ if (!id.startsWith(`${this.getUserId()}:`)) {
772
+ console.error("[ChatAgent] Failed to delete conversation: Not owned by current user", {
773
+ conversationName: id,
774
+ userId: this.getUserId()
775
+ });
776
+ this.logEvent("Failed to delete conversation: Not owned by current user", {
777
+ conversationName: id,
778
+ userId: this.getUserId()
779
+ });
780
+ return false;
781
+ }
782
+ try {
783
+ await deleteConversationRow(this.env.AGENT_DB, id);
784
+ } catch (error) {
785
+ console.error("[ChatAgent] Failed to delete conversation row", {
786
+ conversationName: id,
787
+ error
788
+ });
789
+ return false;
790
+ }
791
+ await (id === this.name ? this : this.binding.getByName(id)).destroy();
792
+ return true;
793
+ }
794
+ async destroy() {
795
+ for (const connection of this.getConnections()) try {
796
+ connection.close(CONVERSATION_EXPIRED_CLOSE_CODE, CONVERSATION_EXPIRED_CLOSE_REASON);
797
+ } catch (error) {
798
+ console.error("[ChatAgent] Failed to close expired conversation connection", error);
799
+ }
800
+ return super.destroy();
801
+ }
802
+ /**
803
+ * Records this conversation in the `conversations` D1 table and triggers
804
+ * LLM-based title/summary generation when appropriate. Called automatically
805
+ * from `onFinish` in `buildLLMParams` after each completed LLM turn.
806
+ *
807
+ * On the first turn (no existing row), awaits `generateTitleAndSummary` and
808
+ * inserts the row with title and summary already populated. On subsequent
809
+ * turns, upserts the timestamp and fire-and-forgets a summary refresh every
810
+ * `SUMMARY_CONTEXT_MESSAGES` messages (when the context window fully turns
811
+ * over). Neither path blocks the response to the client.
812
+ */
813
+ async recordConversation(messages) {
814
+ if (!await getConversationSummary(this.env.AGENT_DB, this.name)) {
815
+ const { title, summary } = await generateTitleAndSummary(messages, this.getFastModel());
816
+ await recordConversation(this.env.AGENT_DB, this.name, title, summary);
817
+ this.logEvent("Conversation summary generated", {
818
+ title,
819
+ summary
820
+ });
821
+ } else {
822
+ await recordConversation(this.env.AGENT_DB, this.name);
823
+ if (messages.length % 30 === 0) {
824
+ const { title, summary } = await generateConversationSummary(this.env.AGENT_DB, this.name, messages, this.getFastModel());
825
+ this.logEvent("Conversation summary updated", {
826
+ title,
827
+ summary
828
+ });
829
+ }
830
+ }
831
+ }
832
+ async deleteConversationCallback() {
833
+ if (await this.deleteConversation(this.name)) this.logEvent("Conversation deleted due to inactivity", {
834
+ conversationName: this.name,
835
+ retentionDays: this.conversationRetentionDays ?? null
836
+ });
837
+ }
838
+ async scheduleConversationForDeletion() {
839
+ const retentionMs = getConversationRetentionMs(this.conversationRetentionDays);
840
+ if (retentionMs === null) return;
841
+ const scheduleIds = getDeleteConversationScheduleIds(this.getSchedules());
842
+ await Promise.all(scheduleIds.map((scheduleId) => this.cancelSchedule(scheduleId)));
843
+ await this.schedule(new Date(Date.now() + retentionMs), DELETE_CONVERSATION_CALLBACK);
844
+ }
845
+ };
846
+ //#endregion
847
+ //#region src/server/harnesses/ChatAgentHarness.ts
848
+ var ChatAgentHarness = class extends ChatAgent {
849
+ get binding() {
850
+ const className = this.constructor.name;
851
+ return this.env[className];
852
+ }
853
+ conversationRetentionDays = 90;
854
+ /**
855
+ * Returns the tools for the agent.
856
+ * @param ctx - The context object for the agent built from the request body.
857
+ * @returns The tools for the agent.
858
+ */
859
+ getTools(_ctx) {
860
+ return {};
861
+ }
862
+ /**
863
+ * Returns the skills for the agent.
864
+ * @param ctx - The context object for the agent built from the request body.
865
+ * @returns The skills for the agent.
866
+ */
867
+ getSkills(_ctx) {
868
+ return [];
869
+ }
870
+ async onChatMessage(onFinish, options) {
871
+ const ctx = options?.body;
872
+ return streamText(await this.buildLLMParams({
873
+ options,
874
+ onFinish,
875
+ model: this.getModel(ctx),
876
+ system: this.getSystemPrompt(ctx),
877
+ skills: this.getSkills(ctx),
878
+ tools: this.getTools(ctx)
879
+ })).toUIMessageStreamResponse();
706
880
  }
707
881
  };
708
882
  //#endregion
709
- export { AIChatAgent, buildLLMParams };
883
+ export { Agent, ChatAgent, ChatAgentHarness, buildLLMParams };