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