@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 +24 -11
- package/dist/index.d.mts +46 -69
- package/dist/index.mjs +308 -297
- package/package.json +2 -1
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**:
|
|
569
|
-
- **Subsequent turns**: only
|
|
570
|
-
- `title` and `summary`
|
|
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
|
-
|
|
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
|
-
|
|
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, "
|
|
30
|
-
/**
|
|
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
|
|
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 =
|
|
41
|
+
* const params = buildLLMParams({ ... });
|
|
65
42
|
* return streamText(params).toUIMessageStreamResponse();
|
|
66
43
|
* ```
|
|
67
44
|
*/
|
|
68
|
-
declare function buildLLMParams(config: BuildLLMParamsConfig):
|
|
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
|
-
*
|
|
100
|
-
*
|
|
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
|
-
|
|
89
|
+
protected maxMessagesBeforeCompaction?: number | undefined;
|
|
103
90
|
/**
|
|
104
|
-
* Returns
|
|
91
|
+
* Returns the user ID from the durable object name.
|
|
105
92
|
*/
|
|
106
|
-
|
|
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
|
|
136
|
-
* by
|
|
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:
|
|
152
|
-
|
|
153
|
-
|
|
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
|
-
|
|
6
|
-
|
|
7
|
-
|
|
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
|
-
|
|
76
|
-
|
|
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[
|
|
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
|
|
82
|
+
return "All requested skills were already loaded.";
|
|
119
83
|
}
|
|
120
84
|
});
|
|
121
|
-
allTools[
|
|
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
|
-
|
|
181
|
-
|
|
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
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
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
|
-
*
|
|
263
|
-
*
|
|
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
|
-
|
|
280
|
-
|
|
281
|
-
|
|
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
|
|
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 =
|
|
229
|
+
* const params = buildLLMParams({ ... });
|
|
370
230
|
* return streamText(params).toUIMessageStreamResponse();
|
|
371
231
|
* ```
|
|
372
232
|
*/
|
|
373
|
-
|
|
374
|
-
const {
|
|
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
|
|
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:
|
|
253
|
+
system: systemWithSkills
|
|
397
254
|
};
|
|
398
255
|
};
|
|
399
256
|
return {
|
|
400
257
|
...baseParams,
|
|
401
|
-
system:
|
|
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
|
-
*
|
|
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
|
|
425
|
-
|
|
281
|
+
function estimateMessagesTokens(messages) {
|
|
282
|
+
let totalChars = 0;
|
|
426
283
|
for (const msg of messages) {
|
|
427
|
-
if (msg.
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
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
|
-
|
|
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
|
-
|
|
446
|
-
|
|
447
|
-
|
|
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/
|
|
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
|
-
|
|
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, "
|
|
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
|
-
|
|
592
|
-
|
|
593
|
-
|
|
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
|
|
629
|
-
* by
|
|
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
|
|
646
|
-
const
|
|
647
|
-
|
|
648
|
-
|
|
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
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
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
|
-
|
|
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
|
|
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",
|