@economic/agents 2.1.7 → 2.2.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 +10 -8
- package/dist/index.d.mts +197 -219
- package/dist/index.mjs +574 -784
- package/dist/v1.d.mts +278 -0
- package/dist/v1.mjs +931 -0
- package/package.json +2 -2
- package/dist/v2.d.mts +0 -256
- package/dist/v2.mjs +0 -721
package/dist/v1.mjs
ADDED
|
@@ -0,0 +1,931 @@
|
|
|
1
|
+
import { n as extractTokenFromConnectRequest, r as verifyJwt, t as createAgentTracer } from "./telemetry-BDxmv_R7.mjs";
|
|
2
|
+
import { Output, convertToModelMessages, generateText, jsonSchema, stepCountIs, streamText, tool } from "ai";
|
|
3
|
+
import { Agent as Agent$1, callable, getCurrentAgent, routeAgentRequest as routeAgentRequest$1 } from "agents";
|
|
4
|
+
import { AIChatAgent } from "@cloudflare/ai-chat";
|
|
5
|
+
//#region src/server/shared/features/skills/index.ts
|
|
6
|
+
const TOOL_NAME_ACTIVATE_SKILL = "activate_skill";
|
|
7
|
+
const TOOL_NAME_LIST_CAPABILITIES = "list_capabilities";
|
|
8
|
+
const SKILL_STATE_SENTINEL = "\n__SKILLS_STATE__:";
|
|
9
|
+
function buildActivateSkillDescription(skills) {
|
|
10
|
+
return [
|
|
11
|
+
"Load additional skills to help with the user's request.",
|
|
12
|
+
"Call this BEFORE attempting actions that need tools from unloaded skills.",
|
|
13
|
+
"",
|
|
14
|
+
"Available skills:",
|
|
15
|
+
skills.map((s) => `• ${s.name} — ${s.description}`).join("\n")
|
|
16
|
+
].join("\n");
|
|
17
|
+
}
|
|
18
|
+
function buildAvailableSkillList(skills) {
|
|
19
|
+
return skills.map((s) => `**${s.name}**: ${s.description}`).join("\n");
|
|
20
|
+
}
|
|
21
|
+
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.";
|
|
22
|
+
/**
|
|
23
|
+
* Creates a skill loading system for use with the Vercel AI SDK.
|
|
24
|
+
*
|
|
25
|
+
* The agent starts with only its always-on tools active. The LLM can call
|
|
26
|
+
* activate_skill to load skill tools on demand. Which skills are loaded is
|
|
27
|
+
* communicated to the CF layer via a sentinel in the activate_skill result
|
|
28
|
+
* and persisted to DO SQLite — no D1 or message-history parsing required.
|
|
29
|
+
*/
|
|
30
|
+
function createSkills(config) {
|
|
31
|
+
const { tools: alwaysOnTools, skills } = config;
|
|
32
|
+
const loadedSkills = new Set(config.initialLoadedSkills ?? []);
|
|
33
|
+
const skillMap = new Map(skills.map((s) => [s.name, s]));
|
|
34
|
+
const availableSkillList = skills.length > 0 ? buildAvailableSkillList(skills) : "";
|
|
35
|
+
const allTools = {};
|
|
36
|
+
Object.assign(allTools, alwaysOnTools);
|
|
37
|
+
for (const skill of skills) Object.assign(allTools, skill.tools);
|
|
38
|
+
function getActiveToolNames() {
|
|
39
|
+
const names = [
|
|
40
|
+
TOOL_NAME_ACTIVATE_SKILL,
|
|
41
|
+
TOOL_NAME_LIST_CAPABILITIES,
|
|
42
|
+
...Object.keys(alwaysOnTools)
|
|
43
|
+
];
|
|
44
|
+
for (const skillName of loadedSkills) {
|
|
45
|
+
const skill = skillMap.get(skillName);
|
|
46
|
+
if (!skill) continue;
|
|
47
|
+
for (const toolName of Object.keys(skill.tools)) if (!names.includes(toolName)) names.push(toolName);
|
|
48
|
+
}
|
|
49
|
+
return names;
|
|
50
|
+
}
|
|
51
|
+
function getLoadedGuidance() {
|
|
52
|
+
const sections = [...loadedSkills].flatMap((name) => {
|
|
53
|
+
const skill = skillMap.get(name);
|
|
54
|
+
if (!skill?.guidance) return [];
|
|
55
|
+
return [`**${skill.name}**\n${skill.guidance}`];
|
|
56
|
+
});
|
|
57
|
+
if (sections.length === 0) return "";
|
|
58
|
+
return sections.join("\n\n");
|
|
59
|
+
}
|
|
60
|
+
allTools[TOOL_NAME_ACTIVATE_SKILL] = tool({
|
|
61
|
+
description: buildActivateSkillDescription(skills),
|
|
62
|
+
inputSchema: jsonSchema({
|
|
63
|
+
type: "object",
|
|
64
|
+
properties: { skills: {
|
|
65
|
+
type: "array",
|
|
66
|
+
items: {
|
|
67
|
+
type: "string",
|
|
68
|
+
enum: skills.map((s) => s.name)
|
|
69
|
+
},
|
|
70
|
+
description: "Skills to load"
|
|
71
|
+
} },
|
|
72
|
+
required: ["skills"]
|
|
73
|
+
}),
|
|
74
|
+
execute: async ({ skills: requested }) => {
|
|
75
|
+
const newlyLoaded = [];
|
|
76
|
+
for (const skillName of requested) {
|
|
77
|
+
if (!skillMap.get(skillName)) continue;
|
|
78
|
+
if (loadedSkills.has(skillName)) continue;
|
|
79
|
+
loadedSkills.add(skillName);
|
|
80
|
+
newlyLoaded.push(skillName);
|
|
81
|
+
}
|
|
82
|
+
if (newlyLoaded.length > 0) return `Loaded: ${newlyLoaded.join(", ")}.${SKILL_STATE_SENTINEL}${JSON.stringify([...loadedSkills])}`;
|
|
83
|
+
return "All requested skills were already loaded.";
|
|
84
|
+
}
|
|
85
|
+
});
|
|
86
|
+
allTools[TOOL_NAME_LIST_CAPABILITIES] = tool({
|
|
87
|
+
description: LIST_CAPABILITIES_DESCRIPTION,
|
|
88
|
+
inputSchema: jsonSchema({
|
|
89
|
+
type: "object",
|
|
90
|
+
properties: {},
|
|
91
|
+
required: []
|
|
92
|
+
}),
|
|
93
|
+
execute: async () => {
|
|
94
|
+
const activeNames = getActiveToolNames();
|
|
95
|
+
const loadedNames = [...loadedSkills];
|
|
96
|
+
const unloaded = skills.filter((s) => !loadedSkills.has(s.name)).map((s) => s.name);
|
|
97
|
+
return [
|
|
98
|
+
`Active tools (${activeNames.length}): ${activeNames.join(", ")}`,
|
|
99
|
+
`Loaded skills: ${loadedNames.length > 0 ? loadedNames.join(", ") : "none"}`,
|
|
100
|
+
`Available to load: ${unloaded.length > 0 ? unloaded.join(", ") : "none"}`
|
|
101
|
+
].join("\n");
|
|
102
|
+
}
|
|
103
|
+
});
|
|
104
|
+
const prepareStep = async () => {
|
|
105
|
+
return { activeTools: getActiveToolNames() };
|
|
106
|
+
};
|
|
107
|
+
return {
|
|
108
|
+
tools: allTools,
|
|
109
|
+
activeTools: getActiveToolNames(),
|
|
110
|
+
prepareStep,
|
|
111
|
+
availableSkillList,
|
|
112
|
+
getLoadedGuidance,
|
|
113
|
+
getLoadedSkills() {
|
|
114
|
+
return [...loadedSkills];
|
|
115
|
+
}
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
function isEphemeralSkillToolPart(part) {
|
|
119
|
+
if (!part || typeof part !== "object" || !("toolCallId" in part) || !("type" in part)) return false;
|
|
120
|
+
const { type } = part;
|
|
121
|
+
return type === `tool-list_capabilities` || type === `tool-activate_skill`;
|
|
122
|
+
}
|
|
123
|
+
/** Creates the `skill_state` table in DO SQLite if it does not exist yet. */
|
|
124
|
+
function ensureSkillTable(sql) {
|
|
125
|
+
sql`CREATE TABLE IF NOT EXISTS skill_state (id INTEGER PRIMARY KEY, active_skills TEXT NOT NULL DEFAULT '[]')`;
|
|
126
|
+
}
|
|
127
|
+
/**
|
|
128
|
+
* Reads the persisted list of loaded skill names from DO SQLite.
|
|
129
|
+
* Returns an empty array if the table is missing or the row does not exist.
|
|
130
|
+
*/
|
|
131
|
+
function getStoredSkills(sql) {
|
|
132
|
+
try {
|
|
133
|
+
ensureSkillTable(sql);
|
|
134
|
+
const rows = sql`SELECT active_skills FROM skill_state WHERE id = 1`;
|
|
135
|
+
if (rows.length === 0) return [];
|
|
136
|
+
return JSON.parse(rows[0].active_skills);
|
|
137
|
+
} catch {
|
|
138
|
+
return [];
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
/**
|
|
142
|
+
* Persists the current list of loaded skill names to DO SQLite.
|
|
143
|
+
* Upserts the single `skill_state` row (id = 1).
|
|
144
|
+
*/
|
|
145
|
+
function saveSkillStateFromMessages(sql, messages) {
|
|
146
|
+
let latestSkillState;
|
|
147
|
+
for (const msg of messages) {
|
|
148
|
+
if (msg.role !== "assistant" || !msg.parts) continue;
|
|
149
|
+
for (const part of msg.parts) {
|
|
150
|
+
if (!("toolCallId" in part)) continue;
|
|
151
|
+
if (part.type !== `tool-activate_skill` || typeof part.output !== "string") continue;
|
|
152
|
+
const sentinelIndex = part.output.indexOf(SKILL_STATE_SENTINEL);
|
|
153
|
+
if (sentinelIndex !== -1) try {
|
|
154
|
+
const stateJson = part.output.slice(sentinelIndex + 18);
|
|
155
|
+
latestSkillState = JSON.parse(stateJson);
|
|
156
|
+
} catch {}
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
if (latestSkillState == void 0) return;
|
|
160
|
+
ensureSkillTable(sql);
|
|
161
|
+
sql`INSERT OR REPLACE INTO skill_state(id, active_skills) VALUES(1, ${JSON.stringify(latestSkillState)})`;
|
|
162
|
+
}
|
|
163
|
+
//#endregion
|
|
164
|
+
//#region src/server/shared/util/llm.ts
|
|
165
|
+
function buildSystemPromptWithSkills(basePrompt, availableSkillList, loadedGuidance) {
|
|
166
|
+
let prompt = `${basePrompt}
|
|
167
|
+
|
|
168
|
+
## Tools
|
|
169
|
+
|
|
170
|
+
### Loading More Tools:
|
|
171
|
+
Use \`activate_skill\` to load these skills. NEVER ask the user for permission to load a skill - just load it silently when relevant. The user should not be aware of skills or tools; activate them autonomously based on the request:
|
|
172
|
+
|
|
173
|
+
${availableSkillList}`;
|
|
174
|
+
if (loadedGuidance) prompt += `
|
|
175
|
+
|
|
176
|
+
### Loaded skill instructions:
|
|
177
|
+
The following skills are currently active. Apply their instructions when using the corresponding tools.
|
|
178
|
+
|
|
179
|
+
${loadedGuidance}`;
|
|
180
|
+
return prompt.trim();
|
|
181
|
+
}
|
|
182
|
+
/**
|
|
183
|
+
* Convention: tools can include a `sources` array in their return value to surface
|
|
184
|
+
* source URLs in the message stream. This transform detects it automatically.
|
|
185
|
+
*
|
|
186
|
+
* ```typescript
|
|
187
|
+
* // In any tool's execute function:
|
|
188
|
+
* return { myContent: "...", sources: [{ url: "https://...", title: "Page title" }] };
|
|
189
|
+
* ```
|
|
190
|
+
*/
|
|
191
|
+
function buildSourcesTransform(additional) {
|
|
192
|
+
const sourcesTransform = () => new TransformStream({ transform(chunk, controller) {
|
|
193
|
+
controller.enqueue(chunk);
|
|
194
|
+
if (chunk.type !== "tool-result") return;
|
|
195
|
+
const output = chunk.output;
|
|
196
|
+
if (!output || typeof output !== "object" || Array.isArray(output)) return;
|
|
197
|
+
const sources = output.sources;
|
|
198
|
+
if (!Array.isArray(sources)) return;
|
|
199
|
+
for (const source of sources) {
|
|
200
|
+
if (!source || typeof source !== "object") continue;
|
|
201
|
+
const s = source;
|
|
202
|
+
if (typeof s.url !== "string") continue;
|
|
203
|
+
controller.enqueue({
|
|
204
|
+
type: "source",
|
|
205
|
+
sourceType: "url",
|
|
206
|
+
id: crypto.randomUUID(),
|
|
207
|
+
url: s.url,
|
|
208
|
+
...typeof s.title === "string" ? { title: s.title } : {}
|
|
209
|
+
});
|
|
210
|
+
}
|
|
211
|
+
} });
|
|
212
|
+
if (!additional) return sourcesTransform;
|
|
213
|
+
return [sourcesTransform, ...Array.isArray(additional) ? additional : [additional]];
|
|
214
|
+
}
|
|
215
|
+
/**
|
|
216
|
+
* Builds the parameter object for a Vercel AI SDK `streamText` or `generateText` call.
|
|
217
|
+
*
|
|
218
|
+
* Handles skill wiring (`activate_skill`, `list_capabilities`, `prepareStep`).
|
|
219
|
+
*
|
|
220
|
+
* The returned object can be spread directly into `streamText` or `generateText`:
|
|
221
|
+
*
|
|
222
|
+
* ```typescript
|
|
223
|
+
* const params = buildLLMParams({ ... });
|
|
224
|
+
* return streamText(params).toUIMessageStreamResponse();
|
|
225
|
+
* ```
|
|
226
|
+
*/
|
|
227
|
+
function buildLLMParams(config) {
|
|
228
|
+
const { activeSkills = [], skills, experimental_transform, system, tools = {}, ...rest } = config;
|
|
229
|
+
const composedTransform = buildSourcesTransform(experimental_transform);
|
|
230
|
+
const baseParams = {
|
|
231
|
+
...rest,
|
|
232
|
+
system,
|
|
233
|
+
tools,
|
|
234
|
+
stopWhen: rest.stopWhen ?? stepCountIs(20),
|
|
235
|
+
experimental_transform: composedTransform
|
|
236
|
+
};
|
|
237
|
+
if (!skills?.length) return baseParams;
|
|
238
|
+
const skillsCtx = createSkills({
|
|
239
|
+
tools,
|
|
240
|
+
skills,
|
|
241
|
+
initialLoadedSkills: activeSkills
|
|
242
|
+
});
|
|
243
|
+
const systemWithSkills = buildSystemPromptWithSkills(typeof system === "string" ? system : void 0, skillsCtx.availableSkillList, skillsCtx.getLoadedGuidance());
|
|
244
|
+
const prepareStep = async (stepOptions) => {
|
|
245
|
+
return {
|
|
246
|
+
activeTools: (await skillsCtx.prepareStep(stepOptions) ?? {}).activeTools ?? [],
|
|
247
|
+
system: systemWithSkills
|
|
248
|
+
};
|
|
249
|
+
};
|
|
250
|
+
return {
|
|
251
|
+
...baseParams,
|
|
252
|
+
system: systemWithSkills,
|
|
253
|
+
tools: skillsCtx.tools,
|
|
254
|
+
activeTools: skillsCtx.activeTools,
|
|
255
|
+
prepareStep
|
|
256
|
+
};
|
|
257
|
+
}
|
|
258
|
+
//#endregion
|
|
259
|
+
//#region src/server/v1/route-agent-request.ts
|
|
260
|
+
async function routeAgentRequest(request, env, options) {
|
|
261
|
+
const response = await routeAgentRequest$1(request, env, options);
|
|
262
|
+
if (!response) return null;
|
|
263
|
+
const protocol = request.headers.get("Sec-WebSocket-Protocol");
|
|
264
|
+
if (response.status === 101 && protocol) {
|
|
265
|
+
const newResponse = new Response(null, response);
|
|
266
|
+
newResponse.headers.set("Sec-WebSocket-Protocol", protocol.split(",")[0].trim());
|
|
267
|
+
return newResponse;
|
|
268
|
+
}
|
|
269
|
+
return response;
|
|
270
|
+
}
|
|
271
|
+
//#endregion
|
|
272
|
+
//#region src/server/v1/agent/Agent.ts
|
|
273
|
+
/**
|
|
274
|
+
* Base agent for Cloudflare Agents SDK Durable Objects with lazy skill loading
|
|
275
|
+
* and `buildLLMParams` wiring.
|
|
276
|
+
*
|
|
277
|
+
* Handles CF infrastructure concerns: DO SQLite persistence for loaded skill state.
|
|
278
|
+
*
|
|
279
|
+
* For chat agents with message history, compaction, and conversation recording,
|
|
280
|
+
* extend {@link ChatAgent} instead.
|
|
281
|
+
*/
|
|
282
|
+
var Agent = class extends Agent$1 {
|
|
283
|
+
clientIp;
|
|
284
|
+
forwardedFor;
|
|
285
|
+
/**
|
|
286
|
+
* The user context for the session.
|
|
287
|
+
* Define getUserContext to set a user context.
|
|
288
|
+
*/
|
|
289
|
+
get userContext() {
|
|
290
|
+
const { connection } = getCurrentAgent();
|
|
291
|
+
return (connection?.state)?.userContext ?? {};
|
|
292
|
+
}
|
|
293
|
+
/**
|
|
294
|
+
* Returns the user ID from the durable object name.
|
|
295
|
+
*/
|
|
296
|
+
getUserId() {
|
|
297
|
+
return this.name.split(":")[0];
|
|
298
|
+
}
|
|
299
|
+
async onConnect(connection, ctx) {
|
|
300
|
+
this.clientIp = ctx.request.headers.get("CF-Connecting-IP") ?? ctx.request.headers.get("X-Forwarded-For")?.split(",")[0]?.trim();
|
|
301
|
+
this.forwardedFor = ctx.request.headers.get("X-Forwarded-For") ?? void 0;
|
|
302
|
+
if (!this.env.AGENT_DB) {
|
|
303
|
+
console.error("[Agent] Connection rejected: no AGENT_DB bound");
|
|
304
|
+
connection.close(3e3, "Could not connect to agent");
|
|
305
|
+
return;
|
|
306
|
+
}
|
|
307
|
+
if (!this.env.AGENTS_AUDIT_LOGS) {
|
|
308
|
+
console.error("[Agent] Connection rejected: no AGENTS_AUDIT_LOGS bound. Audit logs are required.");
|
|
309
|
+
connection.close(3e3, "Could not connect to agent");
|
|
310
|
+
return;
|
|
311
|
+
}
|
|
312
|
+
if (!this.env.AGENTS_ANALYTICS) console.warn("[Agent] No AGENTS_ANALYTICS bound. Analytics will not be collected.");
|
|
313
|
+
if (!this.getUserId()) {
|
|
314
|
+
console.error("[Agent] Connection rejected: name must be in the format userId:uniqueChatId");
|
|
315
|
+
connection.close(3e3, "Could not connect to agent");
|
|
316
|
+
return;
|
|
317
|
+
}
|
|
318
|
+
if (this.getJwtAuthConfig) {
|
|
319
|
+
const config = this.getJwtAuthConfig(ctx.request);
|
|
320
|
+
if (config) {
|
|
321
|
+
let result;
|
|
322
|
+
try {
|
|
323
|
+
result = await verifyJwt(ctx.request, config);
|
|
324
|
+
} catch (error) {
|
|
325
|
+
console.error("[Agent] JWT verification error", error);
|
|
326
|
+
connection.close(4001, "Unauthorized");
|
|
327
|
+
return;
|
|
328
|
+
}
|
|
329
|
+
if (!result.success) {
|
|
330
|
+
connection.close(result.status === 401 ? 4001 : 4003, result.message);
|
|
331
|
+
return;
|
|
332
|
+
}
|
|
333
|
+
const token = extractTokenFromConnectRequest(ctx.request);
|
|
334
|
+
if (token) {
|
|
335
|
+
const userContext = await this.getUserContext?.(token);
|
|
336
|
+
if (userContext) connection.setState({ userContext });
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
return super.onConnect(connection, ctx);
|
|
341
|
+
}
|
|
342
|
+
/**
|
|
343
|
+
* Builds the parameter object for a `streamText` or `generateText` call,
|
|
344
|
+
* pre-filling `activeSkills` from this agent instance.
|
|
345
|
+
*/
|
|
346
|
+
async buildLLMParams(config) {
|
|
347
|
+
const activeSkills = await getStoredSkills(this.sql.bind(this));
|
|
348
|
+
const experimental_context = {
|
|
349
|
+
...config.experimental_context,
|
|
350
|
+
...config.options?.body,
|
|
351
|
+
_userContext: this.userContext
|
|
352
|
+
};
|
|
353
|
+
return buildLLMParams({
|
|
354
|
+
...config,
|
|
355
|
+
activeSkills,
|
|
356
|
+
experimental_context,
|
|
357
|
+
experimental_telemetry: {
|
|
358
|
+
...config.experimental_telemetry,
|
|
359
|
+
isEnabled: true,
|
|
360
|
+
tracer: createAgentTracer(this.env.AGENTS_AUDIT_LOGS, this.env.AGENTS_ANALYTICS, {
|
|
361
|
+
agentName: this.constructor.name,
|
|
362
|
+
durableObjectName: this.name,
|
|
363
|
+
actorId: this.getUserId(),
|
|
364
|
+
...this.clientIp ? { clientIp: this.clientIp } : {},
|
|
365
|
+
...this.forwardedFor ? { forwardedFor: this.forwardedFor } : {}
|
|
366
|
+
}),
|
|
367
|
+
metadata: {
|
|
368
|
+
agentName: this.constructor.name,
|
|
369
|
+
version: "v1",
|
|
370
|
+
durableObjectName: this.name,
|
|
371
|
+
actorId: this.getUserId(),
|
|
372
|
+
...this.clientIp ? { clientIp: this.clientIp } : {},
|
|
373
|
+
...this.forwardedFor ? { forwardedFor: this.forwardedFor } : {},
|
|
374
|
+
...config.experimental_telemetry?.metadata
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
});
|
|
378
|
+
}
|
|
379
|
+
};
|
|
380
|
+
const TOOL_RESULT_PREVIEW_CHARS = 200;
|
|
381
|
+
const SUMMARY_MAX_TOKENS = 4e3;
|
|
382
|
+
/**
|
|
383
|
+
* Estimates token count for a message array using a 3.5 chars/token
|
|
384
|
+
* approximation. Counts text from text/reasoning parts, tool inputs/outputs.
|
|
385
|
+
*/
|
|
386
|
+
function estimateMessagesTokens(messages) {
|
|
387
|
+
let totalChars = 0;
|
|
388
|
+
for (const msg of messages) {
|
|
389
|
+
if (typeof msg.content === "string") {
|
|
390
|
+
totalChars += msg.content.length;
|
|
391
|
+
continue;
|
|
392
|
+
}
|
|
393
|
+
for (const part of msg.content) if (part.type === "text" || part.type === "reasoning") totalChars += part.text.length;
|
|
394
|
+
else if (part.type === "tool-call") totalChars += JSON.stringify(part.input).length;
|
|
395
|
+
else if (part.type === "tool-result") {
|
|
396
|
+
const output = part.output;
|
|
397
|
+
totalChars += typeof output === "string" ? output.length : JSON.stringify(output).length;
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
return Math.ceil(totalChars / 3.5);
|
|
401
|
+
}
|
|
402
|
+
/**
|
|
403
|
+
* Renders messages as human-readable text for the compaction summary prompt.
|
|
404
|
+
*/
|
|
405
|
+
function formatMessagesForSummary(messages) {
|
|
406
|
+
const lines = [];
|
|
407
|
+
for (const msg of messages) {
|
|
408
|
+
const roleLabel = msg.role.charAt(0).toUpperCase() + msg.role.slice(1);
|
|
409
|
+
const parts = [];
|
|
410
|
+
if (typeof msg.content === "string") {
|
|
411
|
+
if (msg.content.trim()) parts.push(msg.content.trim());
|
|
412
|
+
} else for (const part of msg.content) if (part.type === "text" || part.type === "reasoning") {
|
|
413
|
+
const text = part.text.trim();
|
|
414
|
+
if (text) parts.push(text);
|
|
415
|
+
} else if (part.type === "tool-call") {
|
|
416
|
+
const p = part;
|
|
417
|
+
parts.push(`[Tool call: ${p.toolName}]`);
|
|
418
|
+
} else if (part.type === "tool-result") {
|
|
419
|
+
const p = part;
|
|
420
|
+
const rawOutput = typeof p.output === "string" ? p.output : JSON.stringify(p.output);
|
|
421
|
+
const preview = rawOutput.slice(0, TOOL_RESULT_PREVIEW_CHARS);
|
|
422
|
+
const ellipsis = rawOutput.length > TOOL_RESULT_PREVIEW_CHARS ? "..." : "";
|
|
423
|
+
parts.push(`[Tool: ${p.toolName}, result: ${preview}${ellipsis}]`);
|
|
424
|
+
}
|
|
425
|
+
if (parts.length > 0) lines.push(`${roleLabel}: ${parts.join(" ")}`);
|
|
426
|
+
}
|
|
427
|
+
return lines.join("\n");
|
|
428
|
+
}
|
|
429
|
+
/**
|
|
430
|
+
* Calls the model to produce a concise summary of old + recent message windows.
|
|
431
|
+
*/
|
|
432
|
+
async function generateCompactionSummary(oldMessages, recentMessages, model) {
|
|
433
|
+
const prompt = `Summarize this conversation history concisely for an AI assistant to continue the conversation.
|
|
434
|
+
Focus MORE on recent exchanges (what the user was working on, what tools were used, what was found).
|
|
435
|
+
Include key facts, decisions, and context needed to continue the conversation.
|
|
436
|
+
Keep entity names, numbers, file paths, and specific details that might be referenced later.
|
|
437
|
+
Do NOT include pleasantries or meta-commentary - just the essential context.
|
|
438
|
+
|
|
439
|
+
OLDER MESSAGES (summarize briefly):
|
|
440
|
+
${formatMessagesForSummary(oldMessages)}
|
|
441
|
+
|
|
442
|
+
RECENT MESSAGES (summarize with more detail - this is where the user currently is):
|
|
443
|
+
${formatMessagesForSummary(recentMessages)}
|
|
444
|
+
|
|
445
|
+
Write a concise summary:`;
|
|
446
|
+
try {
|
|
447
|
+
const { text } = await generateText({
|
|
448
|
+
model,
|
|
449
|
+
messages: [{
|
|
450
|
+
role: "user",
|
|
451
|
+
content: prompt
|
|
452
|
+
}],
|
|
453
|
+
maxOutputTokens: SUMMARY_MAX_TOKENS
|
|
454
|
+
});
|
|
455
|
+
return text || "Unable to summarize conversation history.";
|
|
456
|
+
} catch (error) {
|
|
457
|
+
console.error("Compaction summarization error:", error);
|
|
458
|
+
return "Unable to summarize conversation history.";
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
/**
|
|
462
|
+
* Summarizes older messages into a single system message and appends the
|
|
463
|
+
* recent verbatim tail. Returns messages unchanged if already short enough.
|
|
464
|
+
*/
|
|
465
|
+
async function compactMessages(messages, model, tailSize) {
|
|
466
|
+
if (messages.length <= tailSize) return messages;
|
|
467
|
+
const splitIndex = messages.length - tailSize;
|
|
468
|
+
const oldMessages = messages.slice(0, splitIndex);
|
|
469
|
+
const recentTail = messages.slice(splitIndex);
|
|
470
|
+
return [{
|
|
471
|
+
role: "system",
|
|
472
|
+
content: `[Conversation summary - older context was compacted]\n${await generateCompactionSummary(oldMessages, recentTail, model)}`
|
|
473
|
+
}, ...recentTail];
|
|
474
|
+
}
|
|
475
|
+
/**
|
|
476
|
+
* Entry point for compaction. Returns messages unchanged when model is
|
|
477
|
+
* undefined or estimated token count is under COMPACT_TOKEN_THRESHOLD.
|
|
478
|
+
*/
|
|
479
|
+
async function compactIfNeeded(messages, model, tailSize) {
|
|
480
|
+
if (!model || estimateMessagesTokens(messages) <= 14e4) return messages;
|
|
481
|
+
return compactMessages(messages, model, tailSize);
|
|
482
|
+
}
|
|
483
|
+
//#endregion
|
|
484
|
+
//#region src/server/v1/agent-chat/features/conversations/conversations.ts
|
|
485
|
+
/**
|
|
486
|
+
* Renders only the user- and assistant-visible text parts of a message
|
|
487
|
+
* array for conversation title/summary generation.
|
|
488
|
+
*
|
|
489
|
+
* Tool calls, tool results, system messages, and reasoning parts are
|
|
490
|
+
* intentionally excluded so the model receives a clean transcript.
|
|
491
|
+
*/
|
|
492
|
+
function formatMessagesForConversationSummary(messages) {
|
|
493
|
+
const lines = [];
|
|
494
|
+
for (const message of messages) {
|
|
495
|
+
if (message.role !== "user" && message.role !== "assistant") continue;
|
|
496
|
+
let text = "";
|
|
497
|
+
if (typeof message.content === "string") text = message.content.trim();
|
|
498
|
+
else text = message.content.filter((part) => part.type === "text").map((part) => part.text.trim()).filter(Boolean).join(" ");
|
|
499
|
+
if (!text) continue;
|
|
500
|
+
const roleLabel = message.role === "user" ? "User" : "Assistant";
|
|
501
|
+
lines.push(`${roleLabel}: ${text}`);
|
|
502
|
+
}
|
|
503
|
+
return lines.join("\n");
|
|
504
|
+
}
|
|
505
|
+
/**
|
|
506
|
+
* Number of recent messages passed to `generateSummary` for rolling
|
|
507
|
+
* summarization. Keeping this bounded prevents the prompt growing
|
|
508
|
+
* unboundedly regardless of conversation length.
|
|
509
|
+
*/
|
|
510
|
+
const SUMMARY_CONTEXT_MESSAGES = 15;
|
|
511
|
+
async function recordConversationSummary(db, durableObjectName, messages, model) {
|
|
512
|
+
if (!await getConversationSummary(db, durableObjectName)) {
|
|
513
|
+
const { title, summary } = await generateTitleAndSummary(messages, model);
|
|
514
|
+
await upsertConversationSummary(db, durableObjectName, title, summary);
|
|
515
|
+
} else if (messages.length <= 7 || messages.length % SUMMARY_CONTEXT_MESSAGES === 0) await generateConversationSummary(db, durableObjectName, messages, model);
|
|
516
|
+
}
|
|
517
|
+
/**
|
|
518
|
+
* Records a conversation row in the `conversations` D1 table.
|
|
519
|
+
*
|
|
520
|
+
* Called by `ChatAgentHarness` after every turn. On first call for a given
|
|
521
|
+
* `durableObjectName` the row is inserted with `created_at` set to now,
|
|
522
|
+
* and with the provided `title` and `summary` if supplied.
|
|
523
|
+
* On subsequent calls only `updated_at` is refreshed —
|
|
524
|
+
* `created_at`, `title`, and `summary` are never overwritten, preserving
|
|
525
|
+
* any user edits.
|
|
526
|
+
*/
|
|
527
|
+
async function upsertConversationSummary(db, durableObjectName, title, summary) {
|
|
528
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
529
|
+
await db.prepare(`INSERT INTO conversations (durable_object_name, title, summary, created_at, updated_at)
|
|
530
|
+
VALUES (?, ?, ?, ?, ?)
|
|
531
|
+
ON CONFLICT(durable_object_name) DO UPDATE SET
|
|
532
|
+
updated_at = excluded.updated_at`).bind(durableObjectName, title ?? null, summary ?? null, now, now).run();
|
|
533
|
+
}
|
|
534
|
+
/**
|
|
535
|
+
* Returns the current `title` and `summary` for a conversation row,
|
|
536
|
+
* or `null` if the row does not exist yet.
|
|
537
|
+
*/
|
|
538
|
+
async function getConversationSummary(db, durableObjectName) {
|
|
539
|
+
return await db.prepare(`SELECT title, summary FROM conversations WHERE durable_object_name = ?`).bind(durableObjectName).first() ?? null;
|
|
540
|
+
}
|
|
541
|
+
/**
|
|
542
|
+
* Returns all conversations for a user, ordered by most recent.
|
|
543
|
+
*/
|
|
544
|
+
async function getConversations(db, userId) {
|
|
545
|
+
const { results } = await db.prepare(`SELECT * FROM conversations WHERE durable_object_name LIKE ? ORDER BY updated_at DESC`).bind(`${userId}:%`).all();
|
|
546
|
+
return results;
|
|
547
|
+
}
|
|
548
|
+
/**
|
|
549
|
+
* Deletes a conversation row from the `conversations` D1 table.
|
|
550
|
+
*/
|
|
551
|
+
async function deleteConversationRow(db, durableObjectName) {
|
|
552
|
+
await db.prepare(`DELETE FROM conversations WHERE durable_object_name = ?`).bind(durableObjectName).run();
|
|
553
|
+
}
|
|
554
|
+
/**
|
|
555
|
+
* Writes a generated `title` and `summary` back to the `conversations` row.
|
|
556
|
+
*/
|
|
557
|
+
async function updateConversationSummary(db, durableObjectName, title, summary) {
|
|
558
|
+
await db.prepare(`UPDATE conversations SET title = ?, summary = ? WHERE durable_object_name = ?`).bind(title, summary, durableObjectName).run();
|
|
559
|
+
}
|
|
560
|
+
/**
|
|
561
|
+
* Generates a title and summary for a conversation using the provided model.
|
|
562
|
+
* Returns the result without writing to D1.
|
|
563
|
+
*
|
|
564
|
+
* Pass `existingSummary` so the model can detect direction changes when
|
|
565
|
+
* updating an existing summary. Omit it (or pass undefined) for the initial
|
|
566
|
+
* generation.
|
|
567
|
+
*
|
|
568
|
+
* Only the last `SUMMARY_CONTEXT_MESSAGES` messages are used to keep the
|
|
569
|
+
* prompt bounded regardless of total conversation length.
|
|
570
|
+
*/
|
|
571
|
+
async function generateTitleAndSummary(messages, model, existingSummary) {
|
|
572
|
+
const recentMessages = await convertToModelMessages(messages.slice(-SUMMARY_CONTEXT_MESSAGES));
|
|
573
|
+
const previousContext = existingSummary ? `Previous summary:\n${existingSummary}\n\nMost recent messages:` : "Conversation:";
|
|
574
|
+
const { output } = await generateText({
|
|
575
|
+
model,
|
|
576
|
+
output: Output.object({ schema: jsonSchema({
|
|
577
|
+
type: "object",
|
|
578
|
+
properties: {
|
|
579
|
+
title: {
|
|
580
|
+
type: "string",
|
|
581
|
+
description: "Short title for the conversation, max 8 words"
|
|
582
|
+
},
|
|
583
|
+
summary: {
|
|
584
|
+
type: "string",
|
|
585
|
+
description: "1-2 short sentence summary of the conversation. If the conversation direction has changed from the previous summary, reflect the new direction."
|
|
586
|
+
}
|
|
587
|
+
},
|
|
588
|
+
required: ["title", "summary"]
|
|
589
|
+
}) }),
|
|
590
|
+
prompt: `${previousContext}\n\n${formatMessagesForConversationSummary(recentMessages)}`
|
|
591
|
+
});
|
|
592
|
+
return output;
|
|
593
|
+
}
|
|
594
|
+
/**
|
|
595
|
+
* Generates a title and summary for a conversation using the provided model
|
|
596
|
+
* and writes the result back to D1.
|
|
597
|
+
*
|
|
598
|
+
* Fetches any existing summary first so the model can detect direction changes.
|
|
599
|
+
* Only the last `SUMMARY_CONTEXT_MESSAGES` messages are passed to keep the
|
|
600
|
+
* prompt bounded regardless of total conversation length.
|
|
601
|
+
*
|
|
602
|
+
* Called by `ChatAgentHarness` every `SUMMARY_CONTEXT_MESSAGES` messages after
|
|
603
|
+
* the first turn.
|
|
604
|
+
*/
|
|
605
|
+
async function generateConversationSummary(db, durableObjectName, messages, model) {
|
|
606
|
+
const { title, summary } = await generateTitleAndSummary(messages, model, (await getConversationSummary(db, durableObjectName))?.summary ?? void 0);
|
|
607
|
+
await updateConversationSummary(db, durableObjectName, title, summary);
|
|
608
|
+
return {
|
|
609
|
+
title,
|
|
610
|
+
summary
|
|
611
|
+
};
|
|
612
|
+
}
|
|
613
|
+
//#endregion
|
|
614
|
+
//#region src/server/v1/agent-chat/features/conversations/retention.ts
|
|
615
|
+
const DELETE_CONVERSATION_CALLBACK = "deleteConversationCallback";
|
|
616
|
+
const CONVERSATION_EXPIRED_CLOSE_CODE = 3001;
|
|
617
|
+
const CONVERSATION_EXPIRED_CLOSE_REASON = "Conversation expired due to inactivity.";
|
|
618
|
+
function getConversationRetentionMs(days) {
|
|
619
|
+
if (typeof days !== "number" || !Number.isFinite(days) || days <= 0) return null;
|
|
620
|
+
return Math.floor(days * 24 * 60 * 60 * 1e3);
|
|
621
|
+
}
|
|
622
|
+
function getDeleteConversationScheduleIds(schedules) {
|
|
623
|
+
return schedules.filter((schedule) => schedule.callback === DELETE_CONVERSATION_CALLBACK).map((schedule) => schedule.id);
|
|
624
|
+
}
|
|
625
|
+
//#endregion
|
|
626
|
+
//#region src/server/shared/util/messages.ts
|
|
627
|
+
function filterEphemeralMessages(messages) {
|
|
628
|
+
return messages.flatMap((message) => {
|
|
629
|
+
if (message.role !== "assistant" || !message.parts?.length) return [message];
|
|
630
|
+
const filteredParts = message.parts.filter((part) => !isEphemeralSkillToolPart(part));
|
|
631
|
+
if (filteredParts.length === 0) return [];
|
|
632
|
+
return [{
|
|
633
|
+
...message,
|
|
634
|
+
parts: filteredParts
|
|
635
|
+
}];
|
|
636
|
+
});
|
|
637
|
+
}
|
|
638
|
+
//#endregion
|
|
639
|
+
//#region src/server/v1/agent-chat/features/conversations/rating.ts
|
|
640
|
+
async function rateMessage(db, durableObjectName, messageId, rating, comment) {
|
|
641
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
642
|
+
await db.prepare(`INSERT INTO message_ratings (message_id, durable_object_name, rating, comment, created_at, updated_at)
|
|
643
|
+
VALUES (?, ?, ?, ?, ?, ?)
|
|
644
|
+
ON CONFLICT (message_id, durable_object_name) DO UPDATE SET
|
|
645
|
+
rating = excluded.rating,
|
|
646
|
+
comment = excluded.comment,
|
|
647
|
+
updated_at = excluded.updated_at`).bind(messageId, durableObjectName, rating, comment ?? null, now, now).run();
|
|
648
|
+
}
|
|
649
|
+
async function getMessageRatings(db, durableObjectName) {
|
|
650
|
+
const result = await db.prepare(`SELECT message_id, rating, comment FROM message_ratings WHERE durable_object_name = ?`).bind(durableObjectName).all();
|
|
651
|
+
return Object.fromEntries(result.results.map((row) => [row.message_id, {
|
|
652
|
+
rating: row.rating,
|
|
653
|
+
...row.comment != null ? { comment: row.comment } : {}
|
|
654
|
+
}]));
|
|
655
|
+
}
|
|
656
|
+
//#endregion
|
|
657
|
+
//#region src/server/v1/agent-chat/ChatAgent.ts
|
|
658
|
+
/**
|
|
659
|
+
* Chat agent for Cloudflare Agents SDK: lazy skill loading, message persistence,
|
|
660
|
+
* compaction, and conversation metadata in D1.
|
|
661
|
+
*
|
|
662
|
+
* Handles CF infrastructure concerns: DO SQLite for loaded skill state,
|
|
663
|
+
* stripping skill meta-tool messages before persistence, and history replay to
|
|
664
|
+
* newly connected clients.
|
|
665
|
+
*
|
|
666
|
+
* Skill loading, compaction, and LLM calls use `buildLLMParams` from
|
|
667
|
+
* `@economic/agents` inside `onChatMessage`.
|
|
668
|
+
*/
|
|
669
|
+
var ChatAgent = class extends AIChatAgent {
|
|
670
|
+
initialState = {
|
|
671
|
+
status: "connecting",
|
|
672
|
+
type: "chat"
|
|
673
|
+
};
|
|
674
|
+
/**
|
|
675
|
+
* Number of days of inactivity before the full conversation is deleted.
|
|
676
|
+
*
|
|
677
|
+
* Leave `undefined` to disable automatic retention cleanup.
|
|
678
|
+
*/
|
|
679
|
+
conversationRetentionDays;
|
|
680
|
+
/**
|
|
681
|
+
* Number of recent messages to keep verbatim when compaction runs.
|
|
682
|
+
* Older messages beyond this count are summarised into a single system message.
|
|
683
|
+
* Used as the default when `maxMessagesBeforeCompaction` is not provided to `buildLLMParams`.
|
|
684
|
+
*
|
|
685
|
+
* Default is 15.
|
|
686
|
+
*/
|
|
687
|
+
maxMessagesBeforeCompaction = 15;
|
|
688
|
+
clientIp;
|
|
689
|
+
forwardedFor;
|
|
690
|
+
/**
|
|
691
|
+
* The user context for the session.
|
|
692
|
+
* Define getUserContext to set a user context.
|
|
693
|
+
*/
|
|
694
|
+
get userContext() {
|
|
695
|
+
const { connection } = getCurrentAgent();
|
|
696
|
+
return (connection?.state)?.userContext ?? {};
|
|
697
|
+
}
|
|
698
|
+
/**
|
|
699
|
+
* Returns the user ID from the durable object name.
|
|
700
|
+
*/
|
|
701
|
+
getUserId() {
|
|
702
|
+
return this.name.split(":")[0];
|
|
703
|
+
}
|
|
704
|
+
onStart() {
|
|
705
|
+
this.setState({
|
|
706
|
+
...this.initialState,
|
|
707
|
+
status: "connecting"
|
|
708
|
+
});
|
|
709
|
+
}
|
|
710
|
+
async onClose() {
|
|
711
|
+
this.setState({
|
|
712
|
+
type: "chat",
|
|
713
|
+
status: "disconnected"
|
|
714
|
+
});
|
|
715
|
+
}
|
|
716
|
+
async onConnect(connection, ctx) {
|
|
717
|
+
this.clientIp = ctx.request.headers.get("CF-Connecting-IP") ?? ctx.request.headers.get("X-Forwarded-For")?.split(",")[0]?.trim();
|
|
718
|
+
this.forwardedFor = ctx.request.headers.get("X-Forwarded-For") ?? void 0;
|
|
719
|
+
if (!this.env.AGENT_DB) {
|
|
720
|
+
console.error("[Agent] Connection rejected: no AGENT_DB bound");
|
|
721
|
+
connection.close(3e3, "Could not connect to agent");
|
|
722
|
+
return;
|
|
723
|
+
}
|
|
724
|
+
if (!this.env.AGENTS_AUDIT_LOGS) {
|
|
725
|
+
console.error("[Agent] Connection rejected: no AGENTS_AUDIT_LOGS bound. Audit logs are required.");
|
|
726
|
+
connection.close(3e3, "Could not connect to agent");
|
|
727
|
+
return;
|
|
728
|
+
}
|
|
729
|
+
if (!this.env.AGENTS_ANALYTICS) console.warn("[Agent] No AGENTS_ANALYTICS bound. Analytics will not be collected.");
|
|
730
|
+
if (!this.getUserId()) {
|
|
731
|
+
console.error("[Agent] Connection rejected: name must be in the format userId:uniqueChatId");
|
|
732
|
+
connection.close(3e3, "Could not connect to agent");
|
|
733
|
+
return;
|
|
734
|
+
}
|
|
735
|
+
if (this.getJwtAuthConfig) {
|
|
736
|
+
const config = this.getJwtAuthConfig(ctx.request);
|
|
737
|
+
if (config) {
|
|
738
|
+
let result;
|
|
739
|
+
try {
|
|
740
|
+
result = await verifyJwt(ctx.request, config);
|
|
741
|
+
} catch (error) {
|
|
742
|
+
console.error("[Agent] JWT verification error", error);
|
|
743
|
+
connection.close(4001, "Unauthorized");
|
|
744
|
+
this.setState({
|
|
745
|
+
type: "chat",
|
|
746
|
+
status: "unauthorized"
|
|
747
|
+
});
|
|
748
|
+
return;
|
|
749
|
+
}
|
|
750
|
+
if (!result.success) {
|
|
751
|
+
connection.close(result.status === 401 ? 4001 : 4003, result.message);
|
|
752
|
+
this.setState({
|
|
753
|
+
type: "chat",
|
|
754
|
+
status: "unauthorized"
|
|
755
|
+
});
|
|
756
|
+
return;
|
|
757
|
+
}
|
|
758
|
+
}
|
|
759
|
+
const token = extractTokenFromConnectRequest(ctx.request);
|
|
760
|
+
if (token) this._pendingUserContextRequest = this.getUserContext?.(token).then((userContext) => {
|
|
761
|
+
if (userContext) connection.setState({ userContext });
|
|
762
|
+
});
|
|
763
|
+
}
|
|
764
|
+
this.setState({
|
|
765
|
+
type: "chat",
|
|
766
|
+
status: "connected"
|
|
767
|
+
});
|
|
768
|
+
return super.onConnect(connection, ctx);
|
|
769
|
+
}
|
|
770
|
+
_pendingUserContextRequest;
|
|
771
|
+
/**
|
|
772
|
+
* Builds the parameter object for a `streamText` or `generateText` call,
|
|
773
|
+
* pre-filling `messages`, `activeSkills`, and `fastModel` from this agent instance.
|
|
774
|
+
*
|
|
775
|
+
* **Compaction** runs automatically when `fastModel` is set on the class, using
|
|
776
|
+
* `DEFAULT_MAX_MESSAGES_BEFORE_COMPACTION` (30) as the threshold. Override the
|
|
777
|
+
* threshold by setting `maxMessagesBeforeCompaction` on the class. Disable compaction
|
|
778
|
+
* entirely by setting `maxMessagesBeforeCompaction = undefined` explicitly.
|
|
779
|
+
*/
|
|
780
|
+
async buildLLMParams(config) {
|
|
781
|
+
const activeSkills = await getStoredSkills(this.sql.bind(this));
|
|
782
|
+
const experimental_context = {
|
|
783
|
+
...config.experimental_context,
|
|
784
|
+
...config.options?.body,
|
|
785
|
+
_userContext: this.userContext
|
|
786
|
+
};
|
|
787
|
+
const messages = await convertToModelMessages(this.messages);
|
|
788
|
+
const fastModel = this.getFastModel();
|
|
789
|
+
const processedMessages = fastModel && this.maxMessagesBeforeCompaction !== void 0 ? await compactIfNeeded(messages, fastModel, this.maxMessagesBeforeCompaction) : messages;
|
|
790
|
+
return buildLLMParams({
|
|
791
|
+
...config,
|
|
792
|
+
activeSkills,
|
|
793
|
+
messages: processedMessages,
|
|
794
|
+
experimental_context,
|
|
795
|
+
experimental_telemetry: {
|
|
796
|
+
...config.experimental_telemetry,
|
|
797
|
+
isEnabled: true,
|
|
798
|
+
tracer: createAgentTracer(this.env.AGENTS_AUDIT_LOGS, this.env.AGENTS_ANALYTICS, {
|
|
799
|
+
agentName: this.constructor.name,
|
|
800
|
+
durableObjectName: this.name,
|
|
801
|
+
actorId: this.getUserId(),
|
|
802
|
+
...this.clientIp ? { clientIp: this.clientIp } : {},
|
|
803
|
+
...this.forwardedFor ? { forwardedFor: this.forwardedFor } : {}
|
|
804
|
+
}),
|
|
805
|
+
metadata: {
|
|
806
|
+
agentName: this.constructor.name,
|
|
807
|
+
version: "v1",
|
|
808
|
+
durableObjectName: this.name,
|
|
809
|
+
actorId: this.getUserId(),
|
|
810
|
+
...this.clientIp ? { clientIp: this.clientIp } : {},
|
|
811
|
+
...this.forwardedFor ? { forwardedFor: this.forwardedFor } : {},
|
|
812
|
+
...config.experimental_telemetry?.metadata
|
|
813
|
+
}
|
|
814
|
+
}
|
|
815
|
+
});
|
|
816
|
+
}
|
|
817
|
+
async persistMessages(messages, excludeBroadcastIds = [], options) {
|
|
818
|
+
const filtered = filterEphemeralMessages(messages);
|
|
819
|
+
await super.persistMessages(filtered, excludeBroadcastIds, options);
|
|
820
|
+
saveSkillStateFromMessages(this.sql.bind(this), messages);
|
|
821
|
+
await super.persistMessages(filterEphemeralMessages(messages), excludeBroadcastIds, options);
|
|
822
|
+
}
|
|
823
|
+
async onChatResponse(result) {
|
|
824
|
+
if (result.error) {
|
|
825
|
+
console.error("[Agent] Chat response error", result.error);
|
|
826
|
+
return;
|
|
827
|
+
}
|
|
828
|
+
recordConversationSummary(this.env.AGENT_DB, this.name, this.messages, this.getFastModel());
|
|
829
|
+
this.scheduleConversationForAutoDeletion();
|
|
830
|
+
}
|
|
831
|
+
@callable({ description: "Rate a message by its id" }) async rateMessage(messageId, rating, comment) {
|
|
832
|
+
await rateMessage(this.env.AGENT_DB, this.name, messageId, rating, comment);
|
|
833
|
+
}
|
|
834
|
+
@callable({ description: "Returns all message ratings for the current conversation" }) async getMessageRatings() {
|
|
835
|
+
return getMessageRatings(this.env.AGENT_DB, this.name);
|
|
836
|
+
}
|
|
837
|
+
@callable({ description: "Returns all conversations for the current user" }) async getConversations() {
|
|
838
|
+
return getConversations(this.env.AGENT_DB, this.getUserId());
|
|
839
|
+
}
|
|
840
|
+
/**
|
|
841
|
+
* Exports this conversation's persisted message history for the v1 -> v2
|
|
842
|
+
* migration. Called over DO RPC by the v2 `Assistant` while migrating a
|
|
843
|
+
* user's chats into facets. `this.messages` is loaded from the
|
|
844
|
+
* `cf_ai_chat_agent_messages` table when the DO wakes.
|
|
845
|
+
*
|
|
846
|
+
* Read-only: does not mutate or delete any state.
|
|
847
|
+
*/
|
|
848
|
+
async exportForMigration() {
|
|
849
|
+
return { messages: this.messages };
|
|
850
|
+
}
|
|
851
|
+
@callable({ description: "Delete a conversation by its id" }) async deleteConversation(id) {
|
|
852
|
+
if (!id.startsWith(`${this.getUserId()}:`)) {
|
|
853
|
+
console.error("[Agent] Failed to delete conversation: Not owned by current user", {
|
|
854
|
+
conversationName: id,
|
|
855
|
+
userId: this.getUserId()
|
|
856
|
+
});
|
|
857
|
+
return false;
|
|
858
|
+
}
|
|
859
|
+
try {
|
|
860
|
+
await deleteConversationRow(this.env.AGENT_DB, id);
|
|
861
|
+
} catch (error) {
|
|
862
|
+
console.error("[Agent] Failed to delete conversation row", {
|
|
863
|
+
conversationName: id,
|
|
864
|
+
error
|
|
865
|
+
});
|
|
866
|
+
return false;
|
|
867
|
+
}
|
|
868
|
+
await (id === this.name ? this : this.binding.getByName(id)).destroy();
|
|
869
|
+
return true;
|
|
870
|
+
}
|
|
871
|
+
async destroy() {
|
|
872
|
+
for (const connection of this.getConnections()) try {
|
|
873
|
+
connection.close(CONVERSATION_EXPIRED_CLOSE_CODE, CONVERSATION_EXPIRED_CLOSE_REASON);
|
|
874
|
+
} catch (error) {
|
|
875
|
+
console.error("[Agent] Failed to close expired conversation connection", error);
|
|
876
|
+
}
|
|
877
|
+
return super.destroy();
|
|
878
|
+
}
|
|
879
|
+
async deleteConversationCallback() {
|
|
880
|
+
if (await this.deleteConversation(this.name)) console.log("[Agent] Conversation deleted due to inactivity", {
|
|
881
|
+
conversationName: this.name,
|
|
882
|
+
retentionDays: this.conversationRetentionDays ?? null
|
|
883
|
+
});
|
|
884
|
+
}
|
|
885
|
+
async scheduleConversationForAutoDeletion() {
|
|
886
|
+
const retentionMs = getConversationRetentionMs(this.conversationRetentionDays);
|
|
887
|
+
if (retentionMs === null) return;
|
|
888
|
+
const scheduleIds = getDeleteConversationScheduleIds(this.getSchedules());
|
|
889
|
+
await Promise.all(scheduleIds.map((scheduleId) => this.cancelSchedule(scheduleId)));
|
|
890
|
+
await this.schedule(new Date(Date.now() + retentionMs), DELETE_CONVERSATION_CALLBACK);
|
|
891
|
+
}
|
|
892
|
+
};
|
|
893
|
+
//#endregion
|
|
894
|
+
//#region src/server/v1/agent-chat/ChatAgentHarness.ts
|
|
895
|
+
var ChatAgentHarness = class extends ChatAgent {
|
|
896
|
+
get binding() {
|
|
897
|
+
const className = this.constructor.name;
|
|
898
|
+
return this.env[className];
|
|
899
|
+
}
|
|
900
|
+
conversationRetentionDays = 90;
|
|
901
|
+
/**
|
|
902
|
+
* Returns the tools for the agent.
|
|
903
|
+
* @param ctx - The context object for the agent built from the request body.
|
|
904
|
+
* @returns The tools for the agent.
|
|
905
|
+
*/
|
|
906
|
+
getTools(_ctx) {
|
|
907
|
+
return {};
|
|
908
|
+
}
|
|
909
|
+
/**
|
|
910
|
+
* Returns the skills for the agent.
|
|
911
|
+
* @param ctx - The context object for the agent built from the request body.
|
|
912
|
+
* @returns The skills for the agent.
|
|
913
|
+
*/
|
|
914
|
+
getSkills(_ctx) {
|
|
915
|
+
return [];
|
|
916
|
+
}
|
|
917
|
+
async onChatMessage(onFinish, options) {
|
|
918
|
+
if (this._pendingUserContextRequest) await this._pendingUserContextRequest;
|
|
919
|
+
const ctx = options?.body;
|
|
920
|
+
return streamText(await this.buildLLMParams({
|
|
921
|
+
options,
|
|
922
|
+
onFinish,
|
|
923
|
+
model: this.getModel(ctx),
|
|
924
|
+
system: this.getSystemPrompt(ctx),
|
|
925
|
+
skills: this.getSkills(ctx),
|
|
926
|
+
tools: this.getTools(ctx)
|
|
927
|
+
})).toUIMessageStreamResponse({ sendSources: true });
|
|
928
|
+
}
|
|
929
|
+
};
|
|
930
|
+
//#endregion
|
|
931
|
+
export { Agent, ChatAgent, ChatAgentHarness, buildLLMParams, routeAgentRequest };
|