@economic/agents 0.0.1-beta.5 → 0.0.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/README.md CHANGED
@@ -565,24 +565,37 @@ The `conversations` table is created by the same `schema/schema.sql` file used f
565
565
 
566
566
  ### Upsert behaviour
567
567
 
568
- - **First turn**: a new row is inserted with `created_at` and `updated_at` both set to now. `title` and `summary` are `NULL`.
569
- - **Subsequent turns**: only `user_id` and `updated_at` are updated. `created_at`, `title`, and `summary` are never overwritten by the upsert.
570
- - `title` and `summary` are populated automatically after the conversation goes idle (see below).
568
+ - **First turn**: `AIChatAgent` generates `title` and `summary` first, then inserts the row with `created_at` and `updated_at` both set to now and `title`/`summary` already populated.
569
+ - **Subsequent turns**: the upsert only refreshes `updated_at`. `created_at`, `title`, and `summary` are preserved by the upsert path.
570
+ - Every `SUMMARY_CONTEXT_MESSAGES` messages, `AIChatAgent` separately re-generates `title` and `summary` and writes them back without changing `created_at`.
571
571
 
572
572
  ### Automatic title and summary generation
573
573
 
574
- After every turn, `AIChatAgent` schedules a `generateSummary` callback to fire 30 minutes in the future. If another message arrives before the timer fires, the schedule is cancelled and reset so the callback only runs once the conversation has been idle for 30 minutes.
574
+ On the first persisted turn, `AIChatAgent` generates a title and summary from the current conversation and inserts them into the new D1 row.
575
575
 
576
- When `generateSummary` fires it:
577
-
578
- 1. Fetches the current summary from D1 (if any).
579
- 2. Takes the last 30 messages (`SUMMARY_CONTEXT_MESSAGES`) to keep the prompt bounded.
580
- 3. Calls `fastModel` with `Output.object()` to generate a structured `{ title, summary }`.
581
- 4. If a previous summary exists, it is included in the prompt so the model can detect direction changes.
582
- 5. Writes the result back to the `conversations` row.
576
+ On later turns, it always refreshes `updated_at`, and it re-generates the title/summary every `SUMMARY_CONTEXT_MESSAGES` messages using the latest window plus the previous summary.
583
577
 
584
578
  No subclass code is needed — this runs automatically when `AGENT_DB` is bound and `fastModel` is set on the class.
585
579
 
580
+ ### Automatic conversation retention
581
+
582
+ Set `conversationRetentionDays` on your subclass to automatically delete inactive conversations after that many days:
583
+
584
+ ```typescript
585
+ export class MyAgent extends AIChatAgent<Env> {
586
+ protected fastModel = openai("gpt-4o-mini");
587
+ protected conversationRetentionDays = 90;
588
+ }
589
+ ```
590
+
591
+ After each persisted turn, the base class resets a per-conversation scheduled callback on the Durable Object. When it fires, the callback:
592
+
593
+ 1. Deletes the matching row from the D1 `conversations` table.
594
+ 2. Closes any active WebSocket connections for that conversation.
595
+ 3. Wipes the Durable Object's SQLite storage with `deleteAll()`.
596
+
597
+ If `conversationRetentionDays` is `undefined`, retention cleanup is disabled and old conversation URLs stay resumable indefinitely.
598
+
586
599
  ### Querying conversation lists
587
600
 
588
601
  From a connected agent client, prefer the built-in callable (see **`getConversations` (callable)** under [`AIChatAgent`](#aichatagent)): `await agent.call("getConversations")`.
package/dist/index.d.mts CHANGED
@@ -1,6 +1,6 @@
1
+ import { Connection, ConnectionContext } from "agents";
1
2
  import { AIChatAgent as AIChatAgent$1, OnChatMessageOptions } from "@cloudflare/ai-chat";
2
3
  import { LanguageModel, ToolSet, UIMessage, generateText, streamText } from "ai";
3
- import { Connection, ConnectionContext } from "agents";
4
4
 
5
5
  //#region src/server/features/skills/index.d.ts
6
6
  /**
@@ -26,48 +26,28 @@ interface Skill {
26
26
  //#endregion
27
27
  //#region src/server/llm.d.ts
28
28
  type LLMParams = Parameters<typeof streamText>[0] & Parameters<typeof generateText>[0];
29
- type BuildLLMParamsConfig = Omit<LLMParams, "messages" | "experimental_context" | "abortSignal"> & {
30
- /** CF options object extracts `abortSignal` and `experimental_context` (from `body`). */options: OnChatMessageOptions | undefined; /** Conversation history (`this.messages`). Converted to `ModelMessage[]` internally. */
31
- messages: UIMessage[]; /** Skill names loaded in previous turns. Pass `await this.getLoadedSkills()`. */
32
- activeSkills?: string[]; /** Skills available for on-demand loading this turn. */
29
+ type BuildLLMParamsConfig = Omit<LLMParams, "prompt"> & {
30
+ /** Skill names loaded in previous turns. Pass `await this.getLoadedSkills()`. */activeSkills?: string[]; /** Skills available for on-demand loading this turn. */
33
31
  skills?: Skill[];
34
- /**
35
- * Number of recent messages to keep verbatim during compaction. Older messages
36
- * beyond this count are summarised by `fastModel` before being sent to the LLM.
37
- *
38
- * Defaults to `DEFAULT_MAX_MESSAGES_BEFORE_COMPACTION` (30) when not provided.
39
- * Set explicitly to `undefined` to disable compaction entirely.
40
- *
41
- * Compaction only runs when `fastModel` is also set on the agent class.
42
- *
43
- * @internal Injected by `AIChatAgent.buildLLMParams` — do not set this directly.
44
- */
45
- maxMessagesBeforeCompaction?: number;
46
- /**
47
- * The fast/cheap model used for compaction and background summarization.
48
- * Provided automatically from `AIChatAgent.fastModel` — do not set this directly.
49
- *
50
- * @internal
51
- */
52
- fastModel?: LanguageModel;
53
32
  };
54
33
  /**
55
34
  * Builds the parameter object for a Vercel AI SDK `streamText` or `generateText` call.
56
35
  *
57
- * Handles message conversion, optional compaction, skill wiring (`activate_skill`,
58
- * `list_capabilities`, `prepareStep`), and context/abort signal extraction from
59
- * the Cloudflare Agents SDK `options` object.
36
+ * Handles skill wiring (`activate_skill`, `list_capabilities`, `prepareStep`).
60
37
  *
61
38
  * The returned object can be spread directly into `streamText` or `generateText`:
62
39
  *
63
40
  * ```typescript
64
- * const params = await buildLLMParams({ ... });
41
+ * const params = buildLLMParams({ ... });
65
42
  * return streamText(params).toUIMessageStreamResponse();
66
43
  * ```
67
44
  */
68
- declare function buildLLMParams(config: BuildLLMParamsConfig): Promise<LLMParams>;
45
+ declare function buildLLMParams(config: BuildLLMParamsConfig): LLMParams;
69
46
  //#endregion
70
47
  //#region src/server/agents/AIChatAgent.d.ts
48
+ interface AIChatAgentEnv {
49
+ AGENT_DB: D1Database;
50
+ }
71
51
  /**
72
52
  * Base class for Cloudflare Agents SDK chat agents with lazy skill loading
73
53
  * and built-in audit logging.
@@ -79,7 +59,7 @@ declare function buildLLMParams(config: BuildLLMParamsConfig): Promise<LLMParams
79
59
  * Skill loading, compaction, and LLM communication are delegated to
80
60
  * `buildLLMParams` from `@economic/agents`, which you call inside `onChatMessage`.
81
61
  */
82
- declare abstract class AIChatAgent<Env extends Cloudflare.Env = Cloudflare.Env> extends AIChatAgent$1<Env> {
62
+ declare abstract class AIChatAgent<Env extends Cloudflare.Env & AIChatAgentEnv = Cloudflare.Env & AIChatAgentEnv> extends AIChatAgent$1<Env> {
83
63
  /**
84
64
  * Fast/cheap language model used for background tasks: compaction and conversation summarization.
85
65
  *
@@ -93,17 +73,25 @@ declare abstract class AIChatAgent<Env extends Cloudflare.Env = Cloudflare.Env>
93
73
  * to `buildLLMParams` rather than omitting or nulling out `fastModel`.
94
74
  */
95
75
  protected abstract fastModel: LanguageModel;
96
- protected getUserId(): string;
97
- onConnect(connection: Connection, ctx: ConnectionContext): Promise<void>;
98
76
  /**
99
- * Resolves the D1 database binding required for all D1 writes.
100
- * Returns null and silently no-ops if AGENT_DB is not bound.
77
+ * Number of days of inactivity before the full conversation is deleted.
78
+ *
79
+ * Leave `undefined` to disable automatic retention cleanup.
80
+ */
81
+ protected conversationRetentionDays?: number;
82
+ /**
83
+ * Number of recent messages to keep verbatim when compaction runs.
84
+ * Older messages beyond this count are summarised into a single system message.
85
+ * Used as the default when `maxMessagesBeforeCompaction` is not provided to `buildLLMParams`.
86
+ *
87
+ * Default is 15.
101
88
  */
102
- private resolveD1Context;
89
+ protected maxMessagesBeforeCompaction?: number | undefined;
103
90
  /**
104
- * Returns all conversations for the current user.
91
+ * Returns the user ID from the durable object name.
105
92
  */
106
- getConversations(): Promise<Record<string, unknown>[] | undefined>;
93
+ protected getUserId(): string;
94
+ onConnect(connection: Connection, ctx: ConnectionContext): Promise<void>;
107
95
  /**
108
96
  * Writes an audit event to D1 if `AGENT_DB` is bound on the environment,
109
97
  * otherwise silently does nothing.
@@ -113,18 +101,6 @@ declare abstract class AIChatAgent<Env extends Cloudflare.Env = Cloudflare.Env>
113
101
  * `experimental_context.log` in tool `execute` functions.
114
102
  */
115
103
  protected log(message: string, payload?: Record<string, unknown>): Promise<void>;
116
- /**
117
- * Records this conversation in the `conversations` D1 table and triggers
118
- * LLM-based title/summary generation when appropriate. Called automatically
119
- * from `persistMessages` after every turn.
120
- *
121
- * On the first turn (no existing row), awaits `generateTitleAndSummary` and
122
- * inserts the row with title and summary already populated. On subsequent
123
- * turns, upserts the timestamp and fire-and-forgets a summary refresh every
124
- * `SUMMARY_CONTEXT_MESSAGES` messages (when the context window fully turns
125
- * over). Neither path blocks the response to the client.
126
- */
127
- private recordConversation;
128
104
  /**
129
105
  * Builds the parameter object for a `streamText` or `generateText` call,
130
106
  * pre-filling `messages`, `activeSkills`, and `fastModel` from this agent instance.
@@ -132,28 +108,13 @@ declare abstract class AIChatAgent<Env extends Cloudflare.Env = Cloudflare.Env>
132
108
  *
133
109
  * **Compaction** runs automatically when `fastModel` is set on the class, using
134
110
  * `DEFAULT_MAX_MESSAGES_BEFORE_COMPACTION` (30) as the threshold. Override the
135
- * threshold by passing `maxMessagesBeforeCompaction`. Disable compaction entirely
136
- * by passing `maxMessagesBeforeCompaction: undefined` explicitly.
137
- *
138
- * ```typescript
139
- * // Compaction on (default threshold):
140
- * const params = await this.buildLLMParams({ options, onFinish, model, system: "..." });
141
- *
142
- * // Compaction with custom threshold:
143
- * const params = await this.buildLLMParams({ options, onFinish, model, maxMessagesBeforeCompaction: 50 });
144
- *
145
- * // Compaction off:
146
- * const params = await this.buildLLMParams({ options, onFinish, model, maxMessagesBeforeCompaction: undefined });
147
- *
148
- * return streamText(params).toUIMessageStreamResponse();
111
+ * threshold by setting `maxMessagesBeforeCompaction` on the class. Disable compaction
112
+ * entirely by setting `maxMessagesBeforeCompaction = undefined` explicitly.
149
113
  * ```
150
114
  */
151
- protected buildLLMParams<TBody = Record<string, unknown>>(config: Omit<BuildLLMParamsConfig, "messages" | "activeSkills" | "fastModel">): ReturnType<typeof buildLLMParams>;
152
- /**
153
- * Skill names persisted from previous turns, read from DO SQLite.
154
- * Returns an empty array if no skills have been loaded yet.
155
- */
156
- protected getLoadedSkills(): Promise<string[]>;
115
+ protected buildLLMParams<TBody = Record<string, unknown>>(config: BuildLLMParamsConfig & {
116
+ options?: OnChatMessageOptions;
117
+ }): Promise<LLMParams>;
157
118
  /**
158
119
  * Extracts skill state from activate_skill results, persists to DO SQLite,
159
120
  * logs a turn summary, then strips all skill meta-tool messages before
@@ -171,6 +132,22 @@ declare abstract class AIChatAgent<Env extends Cloudflare.Env = Cloudflare.Env>
171
132
  persistMessages(messages: UIMessage[], excludeBroadcastIds?: string[], options?: {
172
133
  _deleteStaleRows?: boolean;
173
134
  }): Promise<void>;
135
+ getConversations(): Promise<Record<string, unknown>[]>;
136
+ /**
137
+ * Records this conversation in the `conversations` D1 table and triggers
138
+ * LLM-based title/summary generation when appropriate. Called automatically
139
+ * from `persistMessages` after every turn.
140
+ *
141
+ * On the first turn (no existing row), awaits `generateTitleAndSummary` and
142
+ * inserts the row with title and summary already populated. On subsequent
143
+ * turns, upserts the timestamp and fire-and-forgets a summary refresh every
144
+ * `SUMMARY_CONTEXT_MESSAGES` messages (when the context window fully turns
145
+ * over). Neither path blocks the response to the client.
146
+ */
147
+ private recordConversation;
148
+ private deleteConversation;
149
+ private scheduleConversationForDeletion;
150
+ private clearConversationMemoryState;
174
151
  }
175
152
  //#endregion
176
153
  //#region src/server/types.d.ts
package/dist/index.mjs CHANGED
@@ -1,35 +1,10 @@
1
+ import { callable } from "agents";
1
2
  import { AIChatAgent as AIChatAgent$1 } from "@cloudflare/ai-chat";
2
3
  import { Output, convertToModelMessages, generateText, jsonSchema, stepCountIs, tool } from "ai";
3
- import { callable } from "agents";
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,46 +221,41 @@ 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,
238
+ system,
239
+ tools,
380
240
  experimental_transform: composedTransform,
381
- messages: processedMessages,
382
- experimental_context: options?.body,
383
- abortSignal: options?.abortSignal,
384
241
  stopWhen: rest.stopWhen ?? stepCountIs(20)
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
@@ -415,39 +272,111 @@ async function insertAuditEvent(db, durableObjectName, message, payload) {
415
272
  await db.prepare(`INSERT INTO audit_events (id, durable_object_name, message, payload, created_at)
416
273
  VALUES (?, ?, ?, ?, ?)`).bind(crypto.randomUUID(), durableObjectName, message, payload ? JSON.stringify(payload) : null, (/* @__PURE__ */ new Date()).toISOString()).run();
417
274
  }
275
+ const TOOL_RESULT_PREVIEW_CHARS = 200;
276
+ const SUMMARY_MAX_TOKENS = 4e3;
418
277
  /**
419
- * Builds the payload for a "turn completed" audit event from the final message list.
420
- *
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.
278
+ * Estimates token count for a message array using a 3.5 chars/token
279
+ * approximation. Counts text from text/reasoning parts, tool inputs/outputs.
423
280
  */
424
- function buildTurnSummary(messages, loadedSkills) {
425
- const toolCallNames = [];
281
+ function estimateMessagesTokens(messages) {
282
+ let totalChars = 0;
426
283
  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);
284
+ if (typeof msg.content === "string") {
285
+ totalChars += msg.content.length;
286
+ continue;
287
+ }
288
+ for (const part of msg.content) if (part.type === "text" || part.type === "reasoning") totalChars += part.text.length;
289
+ else if (part.type === "tool-call") totalChars += JSON.stringify(part.input).length;
290
+ else if (part.type === "tool-result") {
291
+ const output = part.output;
292
+ totalChars += typeof output === "string" ? output.length : JSON.stringify(output).length;
434
293
  }
435
294
  }
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
- };
295
+ return Math.ceil(totalChars / 3.5);
444
296
  }
445
- function extractMessageText(msg) {
446
- if (!msg?.parts) return "";
447
- return msg.parts.filter((p) => p.type === "text").map((p) => p.text).join(" ").trim();
297
+ /**
298
+ * Renders messages as human-readable text for the compaction summary prompt.
299
+ */
300
+ function formatMessagesForSummary(messages) {
301
+ const lines = [];
302
+ for (const msg of messages) {
303
+ const roleLabel = msg.role.charAt(0).toUpperCase() + msg.role.slice(1);
304
+ const parts = [];
305
+ if (typeof msg.content === "string") {
306
+ if (msg.content.trim()) parts.push(msg.content.trim());
307
+ } else for (const part of msg.content) if (part.type === "text" || part.type === "reasoning") {
308
+ const text = part.text.trim();
309
+ if (text) parts.push(text);
310
+ } else if (part.type === "tool-call") {
311
+ const p = part;
312
+ parts.push(`[Tool call: ${p.toolName}]`);
313
+ } else if (part.type === "tool-result") {
314
+ const p = part;
315
+ const rawOutput = typeof p.output === "string" ? p.output : JSON.stringify(p.output);
316
+ const preview = rawOutput.slice(0, TOOL_RESULT_PREVIEW_CHARS);
317
+ const ellipsis = rawOutput.length > TOOL_RESULT_PREVIEW_CHARS ? "..." : "";
318
+ parts.push(`[Tool: ${p.toolName}, result: ${preview}${ellipsis}]`);
319
+ }
320
+ if (parts.length > 0) lines.push(`${roleLabel}: ${parts.join(" ")}`);
321
+ }
322
+ return lines.join("\n\n");
323
+ }
324
+ /**
325
+ * Calls the model to produce a concise summary of old + recent message windows.
326
+ */
327
+ async function generateCompactionSummary(oldMessages, recentMessages, model) {
328
+ const prompt = `Summarize this conversation history concisely for an AI assistant to continue the conversation.
329
+ Focus MORE on recent exchanges (what the user was working on, what tools were used, what was found).
330
+ Include key facts, decisions, and context needed to continue the conversation.
331
+ Keep entity names, numbers, file paths, and specific details that might be referenced later.
332
+ Do NOT include pleasantries or meta-commentary - just the essential context.
333
+
334
+ OLDER MESSAGES (summarize briefly):
335
+ ${formatMessagesForSummary(oldMessages)}
336
+
337
+ RECENT MESSAGES (summarize with more detail - this is where the user currently is):
338
+ ${formatMessagesForSummary(recentMessages)}
339
+
340
+ Write a concise summary:`;
341
+ try {
342
+ const { text } = await generateText({
343
+ model,
344
+ messages: [{
345
+ role: "user",
346
+ content: prompt
347
+ }],
348
+ maxOutputTokens: SUMMARY_MAX_TOKENS
349
+ });
350
+ return text || "Unable to summarize conversation history.";
351
+ } catch (error) {
352
+ console.error("Compaction summarization error:", error);
353
+ return "Unable to summarize conversation history.";
354
+ }
355
+ }
356
+ /**
357
+ * Summarizes older messages into a single system message and appends the
358
+ * recent verbatim tail. Returns messages unchanged if already short enough.
359
+ */
360
+ async function compactMessages(messages, model, tailSize) {
361
+ if (messages.length <= tailSize) return messages;
362
+ const splitIndex = messages.length - tailSize;
363
+ const oldMessages = messages.slice(0, splitIndex);
364
+ const recentTail = messages.slice(splitIndex);
365
+ return [{
366
+ role: "system",
367
+ content: `[Conversation summary - older context was compacted]\n${await generateCompactionSummary(oldMessages, recentTail, model)}`
368
+ }, ...recentTail];
369
+ }
370
+ /**
371
+ * Entry point for compaction. Returns messages unchanged when model is
372
+ * undefined or estimated token count is under COMPACT_TOKEN_THRESHOLD.
373
+ */
374
+ async function compactIfNeeded(messages, model, tailSize) {
375
+ if (!model || estimateMessagesTokens(messages) <= 14e4) return messages;
376
+ return compactMessages(messages, model, tailSize);
448
377
  }
449
378
  //#endregion
450
- //#region src/server/features/conversations/index.ts
379
+ //#region src/server/features/conversations/conversations.ts
451
380
  /**
452
381
  * Records a conversation row in the `conversations` D1 table.
453
382
  *
@@ -480,6 +409,12 @@ async function getConversations(db, userId) {
480
409
  return results;
481
410
  }
482
411
  /**
412
+ * Deletes a conversation row from the `conversations` D1 table.
413
+ */
414
+ async function deleteConversationRow(db, durableObjectName) {
415
+ await db.prepare(`DELETE FROM conversations WHERE durable_object_name = ?`).bind(durableObjectName).run();
416
+ }
417
+ /**
483
418
  * Writes a generated `title` and `summary` back to the `conversations` row.
484
419
  */
485
420
  async function updateConversationSummary(db, durableObjectName, title, summary) {
@@ -535,6 +470,25 @@ async function generateConversationSummary(db, durableObjectName, messages, mode
535
470
  await updateConversationSummary(db, durableObjectName, title, summary);
536
471
  }
537
472
  //#endregion
473
+ //#region src/server/features/conversations/retention.ts
474
+ const DELETE_CONVERSATION_CALLBACK = "deleteConversation";
475
+ const CONVERSATION_EXPIRED_CLOSE_CODE = 3001;
476
+ const CONVERSATION_EXPIRED_CLOSE_REASON = "Conversation expired due to inactivity.";
477
+ function getConversationRetentionMs(days) {
478
+ if (typeof days !== "number" || !Number.isFinite(days) || days <= 0) return null;
479
+ return Math.floor(days * 24 * 60 * 60 * 1e3);
480
+ }
481
+ function getDeleteConversationScheduleIds(schedules) {
482
+ return schedules.filter((schedule) => schedule.callback === DELETE_CONVERSATION_CALLBACK).map((schedule) => schedule.id);
483
+ }
484
+ function clearConversationRuntimeState(state) {
485
+ for (const controller of state.chatMessageAbortControllers?.values() ?? []) controller.abort();
486
+ state.messages.length = 0;
487
+ state.clearResumableStream();
488
+ state.chatMessageAbortControllers?.clear();
489
+ state.pendingResumeConnections?.clear();
490
+ }
491
+ //#endregion
538
492
  //#region src/server/agents/AIChatAgent.ts
539
493
  /**
540
494
  * Base class for Cloudflare Agents SDK chat agents with lazy skill loading
@@ -548,36 +502,38 @@ async function generateConversationSummary(db, durableObjectName, messages, mode
548
502
  * `buildLLMParams` from `@economic/agents`, which you call inside `onChatMessage`.
549
503
  */
550
504
  var AIChatAgent = class extends AIChatAgent$1 {
505
+ /**
506
+ * Number of days of inactivity before the full conversation is deleted.
507
+ *
508
+ * Leave `undefined` to disable automatic retention cleanup.
509
+ */
510
+ conversationRetentionDays;
511
+ /**
512
+ * Number of recent messages to keep verbatim when compaction runs.
513
+ * Older messages beyond this count are summarised into a single system message.
514
+ * Used as the default when `maxMessagesBeforeCompaction` is not provided to `buildLLMParams`.
515
+ *
516
+ * Default is 15.
517
+ */
518
+ maxMessagesBeforeCompaction = 15;
519
+ /**
520
+ * Returns the user ID from the durable object name.
521
+ */
551
522
  getUserId() {
552
523
  return this.name.split(":")[0];
553
524
  }
554
525
  async onConnect(connection, ctx) {
555
- await super.onConnect(connection, ctx);
526
+ if (!this.env.AGENT_DB) {
527
+ console.error("[AIChatAgent] Connection rejected: no AGENT_DB bound");
528
+ connection.close(3e3, "Could not connect to agent, database not found");
529
+ return;
530
+ }
556
531
  if (!this.getUserId()) {
557
532
  console.error("[AIChatAgent] Connection rejected: name must be in the format userId:uniqueChatId");
558
- connection.close(3e3, "Name does not match format userId:uniqueChatId");
533
+ connection.close(3e3, "Could not connect to agent, name is not in correct format");
559
534
  return;
560
535
  }
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;
571
- }
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());
536
+ return super.onConnect(connection, ctx);
581
537
  }
582
538
  /**
583
539
  * Writes an audit event to D1 if `AGENT_DB` is bound on the environment,
@@ -588,34 +544,10 @@ var AIChatAgent = class extends AIChatAgent$1 {
588
544
  * `experimental_context.log` in tool `execute` functions.
589
545
  */
590
546
  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.
605
- */
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
- }
547
+ try {
548
+ await insertAuditEvent(this.env.AGENT_DB, this.name, message, payload);
549
+ } catch (error) {
550
+ console.error("[AIChatAgent] Failed to write audit event", error);
619
551
  }
620
552
  }
621
553
  /**
@@ -625,49 +557,24 @@ var AIChatAgent = class extends AIChatAgent$1 {
625
557
  *
626
558
  * **Compaction** runs automatically when `fastModel` is set on the class, using
627
559
  * `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();
560
+ * threshold by setting `maxMessagesBeforeCompaction` on the class. Disable compaction
561
+ * entirely by setting `maxMessagesBeforeCompaction = undefined` explicitly.
642
562
  * ```
643
563
  */
644
564
  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);
649
- };
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
- }
565
+ const activeSkills = await getStoredSkills(this.sql.bind(this));
566
+ const context = {
567
+ ...config.options?.body,
568
+ log: this.log.bind(this)
663
569
  };
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));
570
+ const messages = await convertToModelMessages(this.messages);
571
+ const processedMessages = this.fastModel && this.maxMessagesBeforeCompaction !== void 0 ? await compactIfNeeded(messages, this.fastModel, this.maxMessagesBeforeCompaction) : messages;
572
+ return buildLLMParams({
573
+ ...config,
574
+ activeSkills,
575
+ messages: processedMessages,
576
+ experimental_context: context
577
+ });
671
578
  }
672
579
  /**
673
580
  * Extracts skill state from activate_skill results, persists to DO SQLite,
@@ -699,11 +606,115 @@ var AIChatAgent = class extends AIChatAgent$1 {
699
606
  }
700
607
  }
701
608
  if (latestSkillState !== void 0) saveStoredSkills(this.sql.bind(this), latestSkillState);
702
- this.log("turn completed", buildTurnSummary(messages, latestSkillState ?? []));
703
- this.recordConversation(messages.length);
704
609
  const filtered = filterEphemeralMessages(messages);
705
- return super.persistMessages(filtered, excludeBroadcastIds, options);
610
+ const result = await super.persistMessages(filtered, excludeBroadcastIds, options);
611
+ this.recordConversation(filtered);
612
+ this.scheduleConversationForDeletion();
613
+ this.log("turn completed", buildTurnSummaryForLog(messages, latestSkillState ?? []));
614
+ return result;
615
+ }
616
+ @callable({ description: "Returns all conversations for the current user" }) async getConversations() {
617
+ return getConversations(this.env.AGENT_DB, this.getUserId());
618
+ }
619
+ /**
620
+ * Records this conversation in the `conversations` D1 table and triggers
621
+ * LLM-based title/summary generation when appropriate. Called automatically
622
+ * from `persistMessages` after every turn.
623
+ *
624
+ * On the first turn (no existing row), awaits `generateTitleAndSummary` and
625
+ * inserts the row with title and summary already populated. On subsequent
626
+ * turns, upserts the timestamp and fire-and-forgets a summary refresh every
627
+ * `SUMMARY_CONTEXT_MESSAGES` messages (when the context window fully turns
628
+ * over). Neither path blocks the response to the client.
629
+ */
630
+ async recordConversation(messages) {
631
+ if (!await getConversationSummary(this.env.AGENT_DB, this.name)) {
632
+ const { title, summary } = await generateTitleAndSummary(messages, this.fastModel);
633
+ await recordConversation(this.env.AGENT_DB, this.name, title, summary);
634
+ this.log("[AIChatAgent] Conversation summary generated");
635
+ } else {
636
+ await recordConversation(this.env.AGENT_DB, this.name);
637
+ if (messages.length % 30 === 0) {
638
+ generateConversationSummary(this.env.AGENT_DB, this.name, messages, this.fastModel);
639
+ this.log("[AIChatAgent] Conversation summary updated");
640
+ }
641
+ }
642
+ }
643
+ async deleteConversation() {
644
+ try {
645
+ await deleteConversationRow(this.env.AGENT_DB, this.name);
646
+ } catch (error) {
647
+ console.error("[AIChatAgent] Failed to delete conversation row", {
648
+ conversationName: this.name,
649
+ error
650
+ });
651
+ return;
652
+ }
653
+ for (const connection of this.getConnections()) try {
654
+ connection.close(CONVERSATION_EXPIRED_CLOSE_CODE, CONVERSATION_EXPIRED_CLOSE_REASON);
655
+ } catch (error) {
656
+ console.error("[AIChatAgent] Failed to close expired conversation connection", error);
657
+ }
658
+ this.clearConversationMemoryState();
659
+ await this.ctx.storage.deleteAll();
660
+ this.log("[AiChatAgent] Conversation deleted due to inactivity", {
661
+ conversationName: this.name,
662
+ retentionDays: this.conversationRetentionDays ?? null
663
+ });
664
+ }
665
+ async scheduleConversationForDeletion() {
666
+ const retentionMs = getConversationRetentionMs(this.conversationRetentionDays);
667
+ if (retentionMs === null) return;
668
+ const scheduleIds = getDeleteConversationScheduleIds(this.getSchedules());
669
+ await Promise.all(scheduleIds.map((scheduleId) => this.cancelSchedule(scheduleId)));
670
+ await this.schedule(new Date(Date.now() + retentionMs), DELETE_CONVERSATION_CALLBACK);
671
+ }
672
+ clearConversationMemoryState() {
673
+ const mutableState = this;
674
+ clearConversationRuntimeState({
675
+ chatMessageAbortControllers: mutableState._chatMessageAbortControllers,
676
+ clearResumableStream: () => this._resumableStream.clearAll(),
677
+ messages: mutableState.messages,
678
+ pendingResumeConnections: mutableState._pendingResumeConnections
679
+ });
680
+ mutableState._approvalPersistedMessageId = null;
681
+ mutableState._lastBody = void 0;
682
+ mutableState._lastClientTools = void 0;
683
+ mutableState._streamCompletionPromise = null;
684
+ mutableState._streamCompletionResolve = null;
685
+ mutableState._streamingMessage = null;
706
686
  }
707
687
  };
688
+ /**
689
+ * Builds the payload for a "turn completed" audit event from the final message list.
690
+ *
691
+ * Extracts the last user and assistant message texts (truncated to 200 chars),
692
+ * all non-meta tool call names used this turn, and the current loaded skill set.
693
+ */
694
+ function buildTurnSummaryForLog(messages, loadedSkills) {
695
+ const toolCallNames = [];
696
+ for (const msg of messages) {
697
+ if (msg.role !== "assistant" || !msg.parts) continue;
698
+ for (const part of msg.parts) {
699
+ if (!("toolCallId" in part)) continue;
700
+ const { type } = part;
701
+ if (!type.startsWith("tool-")) continue;
702
+ const name = type.slice(5);
703
+ if (name !== "activate_skill" && name !== "list_capabilities" && !toolCallNames.includes(name)) toolCallNames.push(name);
704
+ }
705
+ }
706
+ const lastUserMsg = [...messages].reverse().find((m) => m.role === "user");
707
+ const lastAssistantMsg = [...messages].reverse().find((m) => m.role === "assistant");
708
+ return {
709
+ userMessage: extractMessageText(lastUserMsg).slice(0, 200),
710
+ toolCalls: toolCallNames,
711
+ loadedSkills,
712
+ assistantMessage: extractMessageText(lastAssistantMsg).slice(0, 200)
713
+ };
714
+ }
715
+ function extractMessageText(msg) {
716
+ if (!msg?.parts) return "";
717
+ return msg.parts.filter((p) => p.type === "text").map((p) => p.text).join(" ").trim();
718
+ }
708
719
  //#endregion
709
720
  export { AIChatAgent, buildLLMParams };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@economic/agents",
3
- "version": "0.0.1-beta.5",
3
+ "version": "0.0.1",
4
4
  "description": "A starter for creating a TypeScript package.",
5
5
  "license": "MIT",
6
6
  "files": [
@@ -8,6 +8,7 @@
8
8
  "schema"
9
9
  ],
10
10
  "type": "module",
11
+ "types": "./dist/index.d.mts",
11
12
  "exports": {
12
13
  ".": "./dist/index.mjs",
13
14
  "./react": "./dist/react.mjs",