@economic/agents 0.0.1-beta.6 → 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +325 -418
- package/bin/cli.mjs +2 -0
- package/dist/cli.d.mts +1 -0
- package/dist/cli.mjs +561 -0
- package/dist/hono.d.mts +21 -0
- package/dist/hono.mjs +71 -0
- package/dist/index.d.mts +163 -108
- package/dist/index.mjs +501 -327
- package/package.json +21 -8
- package/schema/agent.sql +12 -0
- package/schema/{schema.sql → chat.sql} +1 -5
- package/dist/react.d.mts +0 -25
- package/dist/react.mjs +0 -38
package/dist/index.mjs
CHANGED
|
@@ -1,35 +1,10 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
1
|
+
import { Agent as Agent$1, callable } from "agents";
|
|
2
|
+
import { AIChatAgent } from "@cloudflare/ai-chat";
|
|
3
|
+
import { Output, convertToModelMessages, generateText, jsonSchema, stepCountIs, streamText, tool } from "ai";
|
|
4
4
|
//#region src/server/features/skills/index.ts
|
|
5
|
-
|
|
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,99 +221,318 @@ function buildSourcesTransform(additional) {
|
|
|
359
221
|
/**
|
|
360
222
|
* Builds the parameter object for a Vercel AI SDK `streamText` or `generateText` call.
|
|
361
223
|
*
|
|
362
|
-
* Handles
|
|
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,
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
stopWhen: rest.stopWhen ?? stepCountIs(20)
|
|
238
|
+
system,
|
|
239
|
+
tools,
|
|
240
|
+
stopWhen: rest.stopWhen ?? stepCountIs(20),
|
|
241
|
+
experimental_transform: composedTransform
|
|
385
242
|
};
|
|
386
243
|
if (!skills?.length) return baseParams;
|
|
387
|
-
const base = typeof rest.system === "string" ? rest.system : void 0;
|
|
388
244
|
const skillsCtx = createSkills({
|
|
389
|
-
tools
|
|
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
|
|
405
262
|
};
|
|
406
263
|
}
|
|
407
264
|
//#endregion
|
|
408
|
-
//#region src/server/features/audit/
|
|
265
|
+
//#region src/server/features/audit/audit.ts
|
|
409
266
|
/**
|
|
410
267
|
* Inserts a single audit event row into the shared `audit_events` D1 table.
|
|
411
268
|
*
|
|
412
|
-
* Called by `
|
|
269
|
+
* Called by `ChatAgentHarness.logEvent()` (and `AgentHarness.logEvent()`). Not intended for direct use.
|
|
413
270
|
*/
|
|
271
|
+
const SENSITIVE_KEYS = /^(password|token|secret|api_key|apikey|authorization|credentials)$/i;
|
|
272
|
+
const REDACTED = "[REDACTED]";
|
|
273
|
+
/** Deep-clone and redact values for keys that look like secrets (for audit logging). */
|
|
274
|
+
function sanitizePayload(value) {
|
|
275
|
+
if (value === null || value === void 0) return value;
|
|
276
|
+
if (Array.isArray(value)) return value.map(sanitizePayload);
|
|
277
|
+
if (typeof value === "object") {
|
|
278
|
+
const result = {};
|
|
279
|
+
for (const [key, val] of Object.entries(value)) result[key] = SENSITIVE_KEYS.test(key) ? REDACTED : sanitizePayload(val);
|
|
280
|
+
return result;
|
|
281
|
+
}
|
|
282
|
+
return value;
|
|
283
|
+
}
|
|
414
284
|
async function insertAuditEvent(db, durableObjectName, message, payload) {
|
|
415
285
|
await db.prepare(`INSERT INTO audit_events (id, durable_object_name, message, payload, created_at)
|
|
416
|
-
VALUES (?, ?, ?, ?, ?)`).bind(crypto.randomUUID(), durableObjectName, message, payload ? JSON.stringify(payload) : null, (/* @__PURE__ */ new Date()).toISOString()).run();
|
|
286
|
+
VALUES (?, ?, ?, ?, ?)`).bind(crypto.randomUUID(), durableObjectName, message, payload ? JSON.stringify(sanitizePayload(payload)) : null, (/* @__PURE__ */ new Date()).toISOString()).run();
|
|
287
|
+
}
|
|
288
|
+
function stringifyForSkillScan(output) {
|
|
289
|
+
if (typeof output === "string") return output;
|
|
290
|
+
if (output === null || output === void 0) return "";
|
|
291
|
+
try {
|
|
292
|
+
return JSON.stringify(output);
|
|
293
|
+
} catch {
|
|
294
|
+
return "";
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
function forEachStep(event, fn) {
|
|
298
|
+
if (event.steps.length > 0) for (const step of event.steps) fn(step);
|
|
299
|
+
else fn(event);
|
|
300
|
+
}
|
|
301
|
+
/**
|
|
302
|
+
* User text from provider request body (`contents` is e.g. Gemini format).
|
|
303
|
+
*/
|
|
304
|
+
function extractUserInputFromRequestBody(body) {
|
|
305
|
+
const firstContent = body?.contents?.[0];
|
|
306
|
+
if (firstContent?.role !== "user" || !firstContent.parts?.length) return "";
|
|
307
|
+
return firstContent.parts.map((p) => p.text).filter((t) => typeof t === "string").join(" ").trim();
|
|
308
|
+
}
|
|
309
|
+
/**
|
|
310
|
+
* Builds the payload for a "Turn completed" audit event from the AI SDK
|
|
311
|
+
* `OnFinishEvent`.
|
|
312
|
+
*
|
|
313
|
+
* Returns:
|
|
314
|
+
* - `input`: user message text from `request.body.contents[0]` (Gemini-style)
|
|
315
|
+
* - `output`: assistant response text (truncated to 200 chars)
|
|
316
|
+
* - `toolCalls`: array of { toolName, toolInput, toolOutput }
|
|
317
|
+
* - `loadedSkills`: skill names extracted from activate_skill results
|
|
318
|
+
*/
|
|
319
|
+
function buildTurnLogPayload(event) {
|
|
320
|
+
const toolCalls = [];
|
|
321
|
+
let latestSkills;
|
|
322
|
+
const toolOutputs = /* @__PURE__ */ new Map();
|
|
323
|
+
forEachStep(event, (step) => {
|
|
324
|
+
for (const tr of step.toolResults) toolOutputs.set(tr.toolCallId, tr.output);
|
|
325
|
+
});
|
|
326
|
+
forEachStep(event, (step) => {
|
|
327
|
+
for (const tc of step.toolCalls) toolCalls.push({
|
|
328
|
+
name: tc.toolName,
|
|
329
|
+
input: tc.input,
|
|
330
|
+
output: toolOutputs.get(tc.toolCallId)
|
|
331
|
+
});
|
|
332
|
+
const considerToolResultForSkills = (toolName, output) => {
|
|
333
|
+
if (toolName !== "activate_skill") return;
|
|
334
|
+
const s = stringifyForSkillScan(output);
|
|
335
|
+
const sentinelIdx = s.indexOf(SKILL_STATE_SENTINEL);
|
|
336
|
+
if (sentinelIdx === -1) return;
|
|
337
|
+
try {
|
|
338
|
+
const stateJson = s.slice(sentinelIdx + 18);
|
|
339
|
+
latestSkills = JSON.parse(stateJson);
|
|
340
|
+
} catch {}
|
|
341
|
+
};
|
|
342
|
+
for (const tr of step.toolResults) considerToolResultForSkills(tr.toolName, tr.output);
|
|
343
|
+
});
|
|
344
|
+
const input = extractUserInputFromRequestBody(event.request?.body);
|
|
345
|
+
return {
|
|
346
|
+
detail: {
|
|
347
|
+
model: event.model.modelId,
|
|
348
|
+
tokens: event.usage?.totalTokens
|
|
349
|
+
},
|
|
350
|
+
loadedSkills: latestSkills ?? [],
|
|
351
|
+
toolCalls,
|
|
352
|
+
input: input.slice(0, 200),
|
|
353
|
+
output: (event.text ?? "").slice(0, 200)
|
|
354
|
+
};
|
|
417
355
|
}
|
|
356
|
+
//#endregion
|
|
357
|
+
//#region src/server/agents/Agent.ts
|
|
418
358
|
/**
|
|
419
|
-
*
|
|
359
|
+
* Base agent for Cloudflare Agents SDK Durable Objects with lazy skill loading,
|
|
360
|
+
* audit logging, and `buildLLMParams` wiring.
|
|
361
|
+
*
|
|
362
|
+
* Handles CF infrastructure concerns: DO SQLite persistence for loaded skill state
|
|
363
|
+
* and writing audit events to D1.
|
|
420
364
|
*
|
|
421
|
-
*
|
|
422
|
-
*
|
|
365
|
+
* For chat agents with message history, compaction, and conversation recording,
|
|
366
|
+
* extend {@link ChatAgent} instead.
|
|
367
|
+
*/
|
|
368
|
+
var Agent = class extends Agent$1 {
|
|
369
|
+
/**
|
|
370
|
+
* Returns the user ID from the durable object name.
|
|
371
|
+
*/
|
|
372
|
+
getUserId() {
|
|
373
|
+
return this.name.split(":")[0];
|
|
374
|
+
}
|
|
375
|
+
async onConnect(connection, ctx) {
|
|
376
|
+
if (!this.env.AGENT_DB) {
|
|
377
|
+
console.error("[Agent] Connection rejected: no AGENT_DB bound");
|
|
378
|
+
connection.close(3e3, "Could not connect to agent, database not found");
|
|
379
|
+
return;
|
|
380
|
+
}
|
|
381
|
+
if (!this.getUserId()) {
|
|
382
|
+
console.error("[Agent] Connection rejected: name must be in the format userId:uniqueChatId");
|
|
383
|
+
connection.close(3e3, "Could not connect to agent, name is not in correct format");
|
|
384
|
+
return;
|
|
385
|
+
}
|
|
386
|
+
return super.onConnect(connection, ctx);
|
|
387
|
+
}
|
|
388
|
+
/**
|
|
389
|
+
* Writes an audit event to D1 if `AGENT_DB` is bound on the environment,
|
|
390
|
+
* otherwise silently does nothing.
|
|
391
|
+
*
|
|
392
|
+
* Called automatically at the end of each LLM turn (from `onFinish` in
|
|
393
|
+
* `buildLLMParams`). Also available via `experimental_context.logEvent` in tool
|
|
394
|
+
* `execute` functions.
|
|
395
|
+
*/
|
|
396
|
+
async logEvent(message, payload) {
|
|
397
|
+
try {
|
|
398
|
+
await insertAuditEvent(this.env.AGENT_DB, this.name, message, payload);
|
|
399
|
+
} catch (error) {
|
|
400
|
+
console.error("[Agent] Failed to write audit event", error);
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
/**
|
|
404
|
+
* Builds the parameter object for a `streamText` or `generateText` call,
|
|
405
|
+
* pre-filling `activeSkills` from this agent instance.
|
|
406
|
+
* Injects `logEvent` into `experimental_context` and wires `onFinish` for
|
|
407
|
+
* turn-completed audit events.
|
|
408
|
+
*/
|
|
409
|
+
async buildLLMParams(config) {
|
|
410
|
+
const activeSkills = await getStoredSkills(this.sql.bind(this));
|
|
411
|
+
const experimental_context = {
|
|
412
|
+
...config.options?.body,
|
|
413
|
+
logEvent: this.logEvent.bind(this)
|
|
414
|
+
};
|
|
415
|
+
const onFinish = async (event) => {
|
|
416
|
+
this.logEvent("Turn completed", buildTurnLogPayload(event));
|
|
417
|
+
await config.onFinish?.(event);
|
|
418
|
+
};
|
|
419
|
+
return buildLLMParams({
|
|
420
|
+
...config,
|
|
421
|
+
activeSkills,
|
|
422
|
+
experimental_context,
|
|
423
|
+
onFinish
|
|
424
|
+
});
|
|
425
|
+
}
|
|
426
|
+
};
|
|
427
|
+
const TOOL_RESULT_PREVIEW_CHARS = 200;
|
|
428
|
+
const SUMMARY_MAX_TOKENS = 4e3;
|
|
429
|
+
/**
|
|
430
|
+
* Estimates token count for a message array using a 3.5 chars/token
|
|
431
|
+
* approximation. Counts text from text/reasoning parts, tool inputs/outputs.
|
|
423
432
|
*/
|
|
424
|
-
function
|
|
425
|
-
|
|
433
|
+
function estimateMessagesTokens(messages) {
|
|
434
|
+
let totalChars = 0;
|
|
426
435
|
for (const msg of messages) {
|
|
427
|
-
if (msg.
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
436
|
+
if (typeof msg.content === "string") {
|
|
437
|
+
totalChars += msg.content.length;
|
|
438
|
+
continue;
|
|
439
|
+
}
|
|
440
|
+
for (const part of msg.content) if (part.type === "text" || part.type === "reasoning") totalChars += part.text.length;
|
|
441
|
+
else if (part.type === "tool-call") totalChars += JSON.stringify(part.input).length;
|
|
442
|
+
else if (part.type === "tool-result") {
|
|
443
|
+
const output = part.output;
|
|
444
|
+
totalChars += typeof output === "string" ? output.length : JSON.stringify(output).length;
|
|
434
445
|
}
|
|
435
446
|
}
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
447
|
+
return Math.ceil(totalChars / 3.5);
|
|
448
|
+
}
|
|
449
|
+
/**
|
|
450
|
+
* Renders messages as human-readable text for the compaction summary prompt.
|
|
451
|
+
*/
|
|
452
|
+
function formatMessagesForSummary(messages) {
|
|
453
|
+
const lines = [];
|
|
454
|
+
for (const msg of messages) {
|
|
455
|
+
const roleLabel = msg.role.charAt(0).toUpperCase() + msg.role.slice(1);
|
|
456
|
+
const parts = [];
|
|
457
|
+
if (typeof msg.content === "string") {
|
|
458
|
+
if (msg.content.trim()) parts.push(msg.content.trim());
|
|
459
|
+
} else for (const part of msg.content) if (part.type === "text" || part.type === "reasoning") {
|
|
460
|
+
const text = part.text.trim();
|
|
461
|
+
if (text) parts.push(text);
|
|
462
|
+
} else if (part.type === "tool-call") {
|
|
463
|
+
const p = part;
|
|
464
|
+
parts.push(`[Tool call: ${p.toolName}]`);
|
|
465
|
+
} else if (part.type === "tool-result") {
|
|
466
|
+
const p = part;
|
|
467
|
+
const rawOutput = typeof p.output === "string" ? p.output : JSON.stringify(p.output);
|
|
468
|
+
const preview = rawOutput.slice(0, TOOL_RESULT_PREVIEW_CHARS);
|
|
469
|
+
const ellipsis = rawOutput.length > TOOL_RESULT_PREVIEW_CHARS ? "..." : "";
|
|
470
|
+
parts.push(`[Tool: ${p.toolName}, result: ${preview}${ellipsis}]`);
|
|
471
|
+
}
|
|
472
|
+
if (parts.length > 0) lines.push(`${roleLabel}: ${parts.join(" ")}`);
|
|
473
|
+
}
|
|
474
|
+
return lines.join("\n\n");
|
|
444
475
|
}
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
476
|
+
/**
|
|
477
|
+
* Calls the model to produce a concise summary of old + recent message windows.
|
|
478
|
+
*/
|
|
479
|
+
async function generateCompactionSummary(oldMessages, recentMessages, model) {
|
|
480
|
+
const prompt = `Summarize this conversation history concisely for an AI assistant to continue the conversation.
|
|
481
|
+
Focus MORE on recent exchanges (what the user was working on, what tools were used, what was found).
|
|
482
|
+
Include key facts, decisions, and context needed to continue the conversation.
|
|
483
|
+
Keep entity names, numbers, file paths, and specific details that might be referenced later.
|
|
484
|
+
Do NOT include pleasantries or meta-commentary - just the essential context.
|
|
485
|
+
|
|
486
|
+
OLDER MESSAGES (summarize briefly):
|
|
487
|
+
${formatMessagesForSummary(oldMessages)}
|
|
488
|
+
|
|
489
|
+
RECENT MESSAGES (summarize with more detail - this is where the user currently is):
|
|
490
|
+
${formatMessagesForSummary(recentMessages)}
|
|
491
|
+
|
|
492
|
+
Write a concise summary:`;
|
|
493
|
+
try {
|
|
494
|
+
const { text } = await generateText({
|
|
495
|
+
model,
|
|
496
|
+
messages: [{
|
|
497
|
+
role: "user",
|
|
498
|
+
content: prompt
|
|
499
|
+
}],
|
|
500
|
+
maxOutputTokens: SUMMARY_MAX_TOKENS
|
|
501
|
+
});
|
|
502
|
+
return text || "Unable to summarize conversation history.";
|
|
503
|
+
} catch (error) {
|
|
504
|
+
console.error("Compaction summarization error:", error);
|
|
505
|
+
return "Unable to summarize conversation history.";
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
/**
|
|
509
|
+
* Summarizes older messages into a single system message and appends the
|
|
510
|
+
* recent verbatim tail. Returns messages unchanged if already short enough.
|
|
511
|
+
*/
|
|
512
|
+
async function compactMessages(messages, model, tailSize) {
|
|
513
|
+
if (messages.length <= tailSize) return messages;
|
|
514
|
+
const splitIndex = messages.length - tailSize;
|
|
515
|
+
const oldMessages = messages.slice(0, splitIndex);
|
|
516
|
+
const recentTail = messages.slice(splitIndex);
|
|
517
|
+
return [{
|
|
518
|
+
role: "system",
|
|
519
|
+
content: `[Conversation summary - older context was compacted]\n${await generateCompactionSummary(oldMessages, recentTail, model)}`
|
|
520
|
+
}, ...recentTail];
|
|
521
|
+
}
|
|
522
|
+
/**
|
|
523
|
+
* Entry point for compaction. Returns messages unchanged when model is
|
|
524
|
+
* undefined or estimated token count is under COMPACT_TOKEN_THRESHOLD.
|
|
525
|
+
*/
|
|
526
|
+
async function compactIfNeeded(messages, model, tailSize) {
|
|
527
|
+
if (!model || estimateMessagesTokens(messages) <= 14e4) return messages;
|
|
528
|
+
return compactMessages(messages, model, tailSize);
|
|
448
529
|
}
|
|
449
530
|
//#endregion
|
|
450
|
-
//#region src/server/features/conversations/
|
|
531
|
+
//#region src/server/features/conversations/conversations.ts
|
|
451
532
|
/**
|
|
452
533
|
* Records a conversation row in the `conversations` D1 table.
|
|
453
534
|
*
|
|
454
|
-
* Called by `
|
|
535
|
+
* Called by `ChatAgentHarness` after every turn. On first call for a given
|
|
455
536
|
* `durableObjectName` the row is inserted with `created_at` set to now,
|
|
456
537
|
* and with the provided `title` and `summary` if supplied.
|
|
457
538
|
* On subsequent calls only `updated_at` is refreshed —
|
|
@@ -480,6 +561,12 @@ async function getConversations(db, userId) {
|
|
|
480
561
|
return results;
|
|
481
562
|
}
|
|
482
563
|
/**
|
|
564
|
+
* Deletes a conversation row from the `conversations` D1 table.
|
|
565
|
+
*/
|
|
566
|
+
async function deleteConversationRow(db, durableObjectName) {
|
|
567
|
+
await db.prepare(`DELETE FROM conversations WHERE durable_object_name = ?`).bind(durableObjectName).run();
|
|
568
|
+
}
|
|
569
|
+
/**
|
|
483
570
|
* Writes a generated `title` and `summary` back to the `conversations` row.
|
|
484
571
|
*/
|
|
485
572
|
async function updateConversationSummary(db, durableObjectName, title, summary) {
|
|
@@ -527,161 +614,134 @@ async function generateTitleAndSummary(messages, model, existingSummary) {
|
|
|
527
614
|
* Only the last `SUMMARY_CONTEXT_MESSAGES` messages are passed to keep the
|
|
528
615
|
* prompt bounded regardless of total conversation length.
|
|
529
616
|
*
|
|
530
|
-
* Called by `
|
|
617
|
+
* Called by `ChatAgentHarness` every `SUMMARY_CONTEXT_MESSAGES` messages after
|
|
531
618
|
* the first turn.
|
|
532
619
|
*/
|
|
533
620
|
async function generateConversationSummary(db, durableObjectName, messages, model) {
|
|
534
621
|
const { title, summary } = await generateTitleAndSummary(messages, model, (await getConversationSummary(db, durableObjectName))?.summary ?? void 0);
|
|
535
622
|
await updateConversationSummary(db, durableObjectName, title, summary);
|
|
623
|
+
return {
|
|
624
|
+
title,
|
|
625
|
+
summary
|
|
626
|
+
};
|
|
627
|
+
}
|
|
628
|
+
//#endregion
|
|
629
|
+
//#region src/server/features/conversations/retention.ts
|
|
630
|
+
const DELETE_CONVERSATION_CALLBACK = "deleteConversationCallback";
|
|
631
|
+
const CONVERSATION_EXPIRED_CLOSE_CODE = 3001;
|
|
632
|
+
const CONVERSATION_EXPIRED_CLOSE_REASON = "Conversation expired due to inactivity.";
|
|
633
|
+
function getConversationRetentionMs(days) {
|
|
634
|
+
if (typeof days !== "number" || !Number.isFinite(days) || days <= 0) return null;
|
|
635
|
+
return Math.floor(days * 24 * 60 * 60 * 1e3);
|
|
636
|
+
}
|
|
637
|
+
function getDeleteConversationScheduleIds(schedules) {
|
|
638
|
+
return schedules.filter((schedule) => schedule.callback === DELETE_CONVERSATION_CALLBACK).map((schedule) => schedule.id);
|
|
536
639
|
}
|
|
537
640
|
//#endregion
|
|
538
|
-
//#region src/server/agents/
|
|
641
|
+
//#region src/server/agents/ChatAgent.ts
|
|
539
642
|
/**
|
|
540
|
-
*
|
|
541
|
-
* and
|
|
643
|
+
* Chat agent for Cloudflare Agents SDK: lazy skill loading, audit logging,
|
|
644
|
+
* message persistence, compaction, and conversation metadata in D1.
|
|
542
645
|
*
|
|
543
|
-
* Handles CF infrastructure concerns
|
|
544
|
-
*
|
|
545
|
-
*
|
|
646
|
+
* Handles CF infrastructure concerns: DO SQLite for loaded skill state,
|
|
647
|
+
* stripping skill meta-tool messages before persistence, history replay to
|
|
648
|
+
* newly connected clients, and audit events to D1.
|
|
546
649
|
*
|
|
547
|
-
* Skill loading, compaction, and LLM
|
|
548
|
-
*
|
|
650
|
+
* Skill loading, compaction, and LLM calls use `buildLLMParams` from
|
|
651
|
+
* `@economic/agents` inside `onChatMessage`.
|
|
549
652
|
*/
|
|
550
|
-
var
|
|
653
|
+
var ChatAgent = class extends AIChatAgent {
|
|
654
|
+
/**
|
|
655
|
+
* Number of days of inactivity before the full conversation is deleted.
|
|
656
|
+
*
|
|
657
|
+
* Leave `undefined` to disable automatic retention cleanup.
|
|
658
|
+
*/
|
|
659
|
+
conversationRetentionDays;
|
|
660
|
+
/**
|
|
661
|
+
* Number of recent messages to keep verbatim when compaction runs.
|
|
662
|
+
* Older messages beyond this count are summarised into a single system message.
|
|
663
|
+
* Used as the default when `maxMessagesBeforeCompaction` is not provided to `buildLLMParams`.
|
|
664
|
+
*
|
|
665
|
+
* Default is 15.
|
|
666
|
+
*/
|
|
667
|
+
maxMessagesBeforeCompaction = 15;
|
|
668
|
+
/**
|
|
669
|
+
* Returns the user ID from the durable object name.
|
|
670
|
+
*/
|
|
551
671
|
getUserId() {
|
|
552
672
|
return this.name.split(":")[0];
|
|
553
673
|
}
|
|
554
674
|
async onConnect(connection, ctx) {
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
connection.close(3e3, "Name does not match format userId:uniqueChatId");
|
|
675
|
+
if (!this.env.AGENT_DB) {
|
|
676
|
+
console.error("[ChatAgent] Connection rejected: no AGENT_DB bound");
|
|
677
|
+
connection.close(3e3, "Could not connect to agent, database not found");
|
|
559
678
|
return;
|
|
560
679
|
}
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
*/
|
|
566
|
-
resolveD1Context() {
|
|
567
|
-
const db = this.env.AGENT_DB;
|
|
568
|
-
if (!db) {
|
|
569
|
-
console.error("[AIChatAgent] Skipping logging: D1 database not found");
|
|
570
|
-
return null;
|
|
680
|
+
if (!this.getUserId()) {
|
|
681
|
+
console.error("[ChatAgent] Connection rejected: name must be in the format userId:uniqueChatId");
|
|
682
|
+
connection.close(3e3, "Could not connect to agent, name is not in correct format");
|
|
683
|
+
return;
|
|
571
684
|
}
|
|
572
|
-
return
|
|
573
|
-
}
|
|
574
|
-
/**
|
|
575
|
-
* Returns all conversations for the current user.
|
|
576
|
-
*/
|
|
577
|
-
@callable() async getConversations() {
|
|
578
|
-
const db = this.resolveD1Context();
|
|
579
|
-
if (!db) return;
|
|
580
|
-
return getConversations(db, this.getUserId());
|
|
685
|
+
return super.onConnect(connection, ctx);
|
|
581
686
|
}
|
|
582
687
|
/**
|
|
583
688
|
* Writes an audit event to D1 if `AGENT_DB` is bound on the environment,
|
|
584
689
|
* otherwise silently does nothing.
|
|
585
690
|
*
|
|
586
|
-
* Called automatically
|
|
587
|
-
*
|
|
588
|
-
* `
|
|
589
|
-
*/
|
|
590
|
-
async log(message, payload) {
|
|
591
|
-
const db = this.resolveD1Context();
|
|
592
|
-
if (!db) return;
|
|
593
|
-
await insertAuditEvent(db, this.name, message, payload);
|
|
594
|
-
}
|
|
595
|
-
/**
|
|
596
|
-
* Records this conversation in the `conversations` D1 table and triggers
|
|
597
|
-
* LLM-based title/summary generation when appropriate. Called automatically
|
|
598
|
-
* from `persistMessages` after every turn.
|
|
599
|
-
*
|
|
600
|
-
* On the first turn (no existing row), awaits `generateTitleAndSummary` and
|
|
601
|
-
* inserts the row with title and summary already populated. On subsequent
|
|
602
|
-
* turns, upserts the timestamp and fire-and-forgets a summary refresh every
|
|
603
|
-
* `SUMMARY_CONTEXT_MESSAGES` messages (when the context window fully turns
|
|
604
|
-
* over). Neither path blocks the response to the client.
|
|
691
|
+
* Called automatically at the end of each LLM turn (from `onFinish` in
|
|
692
|
+
* `buildLLMParams`). Also available via `experimental_context.logEvent` in tool
|
|
693
|
+
* `execute` functions.
|
|
605
694
|
*/
|
|
606
|
-
async
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
await recordConversation(db, this.name, title, summary);
|
|
612
|
-
this.log("conversation summary generated");
|
|
613
|
-
} else {
|
|
614
|
-
await recordConversation(db, this.name);
|
|
615
|
-
if (messageCount % 30 === 0) {
|
|
616
|
-
generateConversationSummary(db, this.name, this.messages, this.fastModel);
|
|
617
|
-
this.log("conversation summary updated");
|
|
618
|
-
}
|
|
695
|
+
async logEvent(message, payload) {
|
|
696
|
+
try {
|
|
697
|
+
await insertAuditEvent(this.env.AGENT_DB, this.name, message, payload);
|
|
698
|
+
} catch (error) {
|
|
699
|
+
console.error("[ChatAgent] Failed to write audit event", error);
|
|
619
700
|
}
|
|
620
701
|
}
|
|
621
702
|
/**
|
|
622
703
|
* Builds the parameter object for a `streamText` or `generateText` call,
|
|
623
704
|
* pre-filling `messages`, `activeSkills`, and `fastModel` from this agent instance.
|
|
624
|
-
* Injects `
|
|
705
|
+
* Injects `logEvent` into `experimental_context` and wires `onFinish` for
|
|
706
|
+
* turn-completed audit events and conversation recording.
|
|
625
707
|
*
|
|
626
708
|
* **Compaction** runs automatically when `fastModel` is set on the class, using
|
|
627
709
|
* `DEFAULT_MAX_MESSAGES_BEFORE_COMPACTION` (30) as the threshold. Override the
|
|
628
|
-
* threshold by
|
|
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();
|
|
642
|
-
* ```
|
|
710
|
+
* threshold by setting `maxMessagesBeforeCompaction` on the class. Disable compaction
|
|
711
|
+
* entirely by setting `maxMessagesBeforeCompaction = undefined` explicitly.
|
|
643
712
|
*/
|
|
644
713
|
async buildLLMParams(config) {
|
|
645
|
-
const
|
|
646
|
-
const
|
|
647
|
-
|
|
648
|
-
|
|
714
|
+
const activeSkills = await getStoredSkills(this.sql.bind(this));
|
|
715
|
+
const experimental_context = {
|
|
716
|
+
...config.options?.body,
|
|
717
|
+
logEvent: this.logEvent.bind(this)
|
|
649
718
|
};
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
maxMessagesBeforeCompaction
|
|
658
|
-
}),
|
|
659
|
-
experimental_context: {
|
|
660
|
-
...config.options?.body,
|
|
661
|
-
log: this.log.bind(this)
|
|
662
|
-
}
|
|
719
|
+
const messages = await convertToModelMessages(this.messages);
|
|
720
|
+
const fastModel = this.getFastModel();
|
|
721
|
+
const processedMessages = fastModel && this.maxMessagesBeforeCompaction !== void 0 ? await compactIfNeeded(messages, fastModel, this.maxMessagesBeforeCompaction) : messages;
|
|
722
|
+
const onFinish = async (event) => {
|
|
723
|
+
this.logEvent("Turn completed", buildTurnLogPayload(event));
|
|
724
|
+
this.recordConversation(filterEphemeralMessages([...this.messages]));
|
|
725
|
+
await config.onFinish?.(event);
|
|
663
726
|
};
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
727
|
+
return buildLLMParams({
|
|
728
|
+
...config,
|
|
729
|
+
activeSkills,
|
|
730
|
+
messages: processedMessages,
|
|
731
|
+
experimental_context,
|
|
732
|
+
onFinish
|
|
733
|
+
});
|
|
671
734
|
}
|
|
672
735
|
/**
|
|
673
736
|
* Extracts skill state from activate_skill results, persists to DO SQLite,
|
|
674
|
-
*
|
|
675
|
-
* delegating to super.
|
|
737
|
+
* then strips all skill meta-tool messages before delegating to super.
|
|
676
738
|
*
|
|
677
739
|
* 1. Scans activate_skill tool results for SKILL_STATE_SENTINEL. When found,
|
|
678
740
|
* the embedded JSON array of loaded skill names is written to DO SQLite.
|
|
679
741
|
*
|
|
680
|
-
* 2.
|
|
742
|
+
* 2. Strips all activate_skill and list_capabilities messages from history.
|
|
681
743
|
*
|
|
682
|
-
* 3.
|
|
683
|
-
*
|
|
684
|
-
* 4. Delegates to super.persistMessages for message storage and WS broadcast.
|
|
744
|
+
* 3. Delegates to super.persistMessages for message storage and WS broadcast.
|
|
685
745
|
*/
|
|
686
746
|
async persistMessages(messages, excludeBroadcastIds = [], options) {
|
|
687
747
|
let latestSkillState;
|
|
@@ -699,11 +759,125 @@ var AIChatAgent = class extends AIChatAgent$1 {
|
|
|
699
759
|
}
|
|
700
760
|
}
|
|
701
761
|
if (latestSkillState !== void 0) saveStoredSkills(this.sql.bind(this), latestSkillState);
|
|
702
|
-
this.log("turn completed", buildTurnSummary(messages, latestSkillState ?? []));
|
|
703
|
-
this.recordConversation(messages.length);
|
|
704
762
|
const filtered = filterEphemeralMessages(messages);
|
|
705
|
-
|
|
763
|
+
const result = await super.persistMessages(filtered, excludeBroadcastIds, options);
|
|
764
|
+
this.scheduleConversationForDeletion();
|
|
765
|
+
return result;
|
|
766
|
+
}
|
|
767
|
+
@callable({ description: "Returns all conversations for the current user" }) async getConversations() {
|
|
768
|
+
return getConversations(this.env.AGENT_DB, this.getUserId());
|
|
769
|
+
}
|
|
770
|
+
@callable({ description: "Delete a conversation by its id" }) async deleteConversation(id) {
|
|
771
|
+
if (!id.startsWith(`${this.getUserId()}:`)) {
|
|
772
|
+
console.error("[ChatAgent] Failed to delete conversation: Not owned by current user", {
|
|
773
|
+
conversationName: id,
|
|
774
|
+
userId: this.getUserId()
|
|
775
|
+
});
|
|
776
|
+
this.logEvent("Failed to delete conversation: Not owned by current user", {
|
|
777
|
+
conversationName: id,
|
|
778
|
+
userId: this.getUserId()
|
|
779
|
+
});
|
|
780
|
+
return false;
|
|
781
|
+
}
|
|
782
|
+
try {
|
|
783
|
+
await deleteConversationRow(this.env.AGENT_DB, id);
|
|
784
|
+
} catch (error) {
|
|
785
|
+
console.error("[ChatAgent] Failed to delete conversation row", {
|
|
786
|
+
conversationName: id,
|
|
787
|
+
error
|
|
788
|
+
});
|
|
789
|
+
return false;
|
|
790
|
+
}
|
|
791
|
+
await (id === this.name ? this : this.binding.getByName(id)).destroy();
|
|
792
|
+
return true;
|
|
793
|
+
}
|
|
794
|
+
async destroy() {
|
|
795
|
+
for (const connection of this.getConnections()) try {
|
|
796
|
+
connection.close(CONVERSATION_EXPIRED_CLOSE_CODE, CONVERSATION_EXPIRED_CLOSE_REASON);
|
|
797
|
+
} catch (error) {
|
|
798
|
+
console.error("[ChatAgent] Failed to close expired conversation connection", error);
|
|
799
|
+
}
|
|
800
|
+
return super.destroy();
|
|
801
|
+
}
|
|
802
|
+
/**
|
|
803
|
+
* Records this conversation in the `conversations` D1 table and triggers
|
|
804
|
+
* LLM-based title/summary generation when appropriate. Called automatically
|
|
805
|
+
* from `onFinish` in `buildLLMParams` after each completed LLM turn.
|
|
806
|
+
*
|
|
807
|
+
* On the first turn (no existing row), awaits `generateTitleAndSummary` and
|
|
808
|
+
* inserts the row with title and summary already populated. On subsequent
|
|
809
|
+
* turns, upserts the timestamp and fire-and-forgets a summary refresh every
|
|
810
|
+
* `SUMMARY_CONTEXT_MESSAGES` messages (when the context window fully turns
|
|
811
|
+
* over). Neither path blocks the response to the client.
|
|
812
|
+
*/
|
|
813
|
+
async recordConversation(messages) {
|
|
814
|
+
if (!await getConversationSummary(this.env.AGENT_DB, this.name)) {
|
|
815
|
+
const { title, summary } = await generateTitleAndSummary(messages, this.getFastModel());
|
|
816
|
+
await recordConversation(this.env.AGENT_DB, this.name, title, summary);
|
|
817
|
+
this.logEvent("Conversation summary generated", {
|
|
818
|
+
title,
|
|
819
|
+
summary
|
|
820
|
+
});
|
|
821
|
+
} else {
|
|
822
|
+
await recordConversation(this.env.AGENT_DB, this.name);
|
|
823
|
+
if (messages.length % 30 === 0) {
|
|
824
|
+
const { title, summary } = await generateConversationSummary(this.env.AGENT_DB, this.name, messages, this.getFastModel());
|
|
825
|
+
this.logEvent("Conversation summary updated", {
|
|
826
|
+
title,
|
|
827
|
+
summary
|
|
828
|
+
});
|
|
829
|
+
}
|
|
830
|
+
}
|
|
831
|
+
}
|
|
832
|
+
async deleteConversationCallback() {
|
|
833
|
+
if (await this.deleteConversation(this.name)) this.logEvent("Conversation deleted due to inactivity", {
|
|
834
|
+
conversationName: this.name,
|
|
835
|
+
retentionDays: this.conversationRetentionDays ?? null
|
|
836
|
+
});
|
|
837
|
+
}
|
|
838
|
+
async scheduleConversationForDeletion() {
|
|
839
|
+
const retentionMs = getConversationRetentionMs(this.conversationRetentionDays);
|
|
840
|
+
if (retentionMs === null) return;
|
|
841
|
+
const scheduleIds = getDeleteConversationScheduleIds(this.getSchedules());
|
|
842
|
+
await Promise.all(scheduleIds.map((scheduleId) => this.cancelSchedule(scheduleId)));
|
|
843
|
+
await this.schedule(new Date(Date.now() + retentionMs), DELETE_CONVERSATION_CALLBACK);
|
|
844
|
+
}
|
|
845
|
+
};
|
|
846
|
+
//#endregion
|
|
847
|
+
//#region src/server/harnesses/ChatAgentHarness.ts
|
|
848
|
+
var ChatAgentHarness = class extends ChatAgent {
|
|
849
|
+
get binding() {
|
|
850
|
+
const className = this.constructor.name;
|
|
851
|
+
return this.env[className];
|
|
852
|
+
}
|
|
853
|
+
conversationRetentionDays = 90;
|
|
854
|
+
/**
|
|
855
|
+
* Returns the tools for the agent.
|
|
856
|
+
* @param ctx - The context object for the agent built from the request body.
|
|
857
|
+
* @returns The tools for the agent.
|
|
858
|
+
*/
|
|
859
|
+
getTools(_ctx) {
|
|
860
|
+
return {};
|
|
861
|
+
}
|
|
862
|
+
/**
|
|
863
|
+
* Returns the skills for the agent.
|
|
864
|
+
* @param ctx - The context object for the agent built from the request body.
|
|
865
|
+
* @returns The skills for the agent.
|
|
866
|
+
*/
|
|
867
|
+
getSkills(_ctx) {
|
|
868
|
+
return [];
|
|
869
|
+
}
|
|
870
|
+
async onChatMessage(onFinish, options) {
|
|
871
|
+
const ctx = options?.body;
|
|
872
|
+
return streamText(await this.buildLLMParams({
|
|
873
|
+
options,
|
|
874
|
+
onFinish,
|
|
875
|
+
model: this.getModel(ctx),
|
|
876
|
+
system: this.getSystemPrompt(ctx),
|
|
877
|
+
skills: this.getSkills(ctx),
|
|
878
|
+
tools: this.getTools(ctx)
|
|
879
|
+
})).toUIMessageStreamResponse();
|
|
706
880
|
}
|
|
707
881
|
};
|
|
708
882
|
//#endregion
|
|
709
|
-
export {
|
|
883
|
+
export { Agent, ChatAgent, ChatAgentHarness, buildLLMParams };
|