@economic/agents 0.0.1-alpha.9 → 0.0.1-beta.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,87 +1,608 @@
1
1
  # @economic/agents
2
2
 
3
- Base classes and utilities for building LLM agents on Cloudflare's Agents SDK with lazy tool loading.
3
+ Base class and utilities for building LLM chat agents on Cloudflare's Agents SDK with lazy skill loading, optional message compaction, and built-in audit logging.
4
4
 
5
- ## Exports
5
+ ```bash
6
+ npm install @economic/agents ai @cloudflare/ai-chat agents
7
+ ```
8
+
9
+ For React UIs that import `@economic/agents/react`, also install `react`. It is an **optional** peer of the package so Workers-only installs are not required to add React. The hook still needs `agents`, `ai`, and `@cloudflare/ai-chat` at runtime (same as the Cloudflare SDK imports it wraps).
6
10
 
7
- - **`AIChatAgent`** — base class that owns the full `onChatMessage` lifecycle. Implement `getModel()`, `getTools()`, `getSkills()`, and `getSystemPrompt()`. Compaction is **enabled by default** (uses `getModel()` for summarisation).
8
- - **`AIChatAgentBase`** — base class for when you need full control over `streamText`. Implement `getTools()`, `getSkills()`, and your own `onChatMessage` decorated with `@withSkills`. Compaction is **disabled by default**.
9
- - **`withSkills`** — method decorator used with `AIChatAgentBase`.
10
- - **`createSkills`** — lower-level factory for wiring lazy skill loading into any agent subclass yourself.
11
- - **`filterEphemeralMessages`**, **`injectGuidance`** — utilities used internally, exported for custom wiring.
12
- - **`compactIfNeeded`**, **`compactMessages`**, **`estimateMessagesTokens`**, **`COMPACT_TOKEN_THRESHOLD`** — compaction utilities, exported for use with `AIChatAgentBase` or fully custom agents.
13
- - Types: `Tool`, `Skill`, `SkillsConfig`, `SkillsResult`, `SkillContext`.
11
+ ---
14
12
 
15
- See [COMPARISON.md](./COMPARISON.md) for a side-by-side code example of both base classes.
13
+ ## Overview
16
14
 
17
- See [src/features/skills/README.md](./src/features/skills/README.md) for full `createSkills` documentation.
15
+ `@economic/agents` provides:
18
16
 
19
- ## Development
17
+ - **`AIChatAgent`** — an abstract Cloudflare Durable Object base class. Implement `onChatMessage`, call `this.buildLLMParams()`, and pass the result to `streamText` from the AI SDK.
18
+ - **`guard`** — optional TypeScript 5+ method decorator for `onChatMessage`. Runs your function with `options.body`; return a `Response` to short-circuit (e.g. auth), or nothing to continue.
19
+ - **`buildLLMParams`** — the standalone version of the above, for use outside of `AIChatAgent` or in custom agent implementations.
20
+ - **`useAIChatAgent`** (subpath `@economic/agents/react`) — React hook that wraps `useAgent` (`agents/react`) and `useAgentChat` (`@cloudflare/ai-chat/react`). Connection status is **callback-only** (`onConnectionStatusChange`). On WebSocket close codes **`>= 3000`**, the hook calls `agent.close()` to stop reconnection, then forwards `onClose` (use `event.reason` for the server message). Pass-through **`onOpen`**, **`onClose`**, **`onError`** are supported.
20
21
 
21
- ```bash
22
- vp install # install dependencies
23
- vp test # run tests
24
- vp pack # build
22
+ Skills and compaction are AI SDK concerns — they control what goes to the LLM. The CF layer is responsible for WebSockets, Durable Objects, and message persistence. These are kept separate.
23
+
24
+ ### React client
25
+
26
+ ```typescript
27
+ import { useAIChatAgent, type AgentConnectionStatus } from "@economic/agents/react";
28
+ import { useState } from "react";
29
+
30
+ const [connectionStatus, setConnectionStatus] = useState<AgentConnectionStatus>("connecting");
31
+
32
+ const { agent, chat } = useAIChatAgent({
33
+ agent: "MyAgent",
34
+ host: "localhost:8787",
35
+ chatId: "session-id",
36
+ toolContext: {},
37
+ connectionParams: { userId: "…" },
38
+ onConnectionStatusChange: setConnectionStatus,
39
+ onOpen: (event) => {},
40
+ onClose: (event) => {},
41
+ onError: (event) => {},
42
+ });
43
+
44
+ const { messages, sendMessage, status, stop } = chat;
25
45
  ```
26
46
 
27
- ---
47
+ Server-side agent code still imports only from `@economic/agents`; the `/react` entry is a separate build output and does not pull Workers runtime code into the client bundle.
28
48
 
29
- ## Implementing your own agent
49
+ ---
30
50
 
31
- Extend `AIChatAgent` and implement the four required methods:
51
+ ## Quick start
32
52
 
33
53
  ```typescript
54
+ import { streamText } from "ai";
55
+ import { openai } from "@ai-sdk/openai";
56
+ import { tool } from "ai";
57
+ import { z } from "zod";
34
58
  import { AIChatAgent } from "@economic/agents";
59
+ import type { Skill } from "@economic/agents";
35
60
 
36
- export class MyAgent extends AIChatAgent {
37
- getModel() {
38
- return openai("gpt-4o");
39
- }
40
- getTools() {
41
- return [myAlwaysOnTool];
61
+ const searchSkill: Skill = {
62
+ name: "search",
63
+ description: "Web search tools",
64
+ guidance: "Use search_web for any queries requiring up-to-date information.",
65
+ tools: {
66
+ search_web: tool({
67
+ description: "Search the web",
68
+ inputSchema: z.object({ query: z.string() }),
69
+ execute: async ({ query }) => `Results for: ${query}`,
70
+ }),
71
+ },
72
+ };
73
+
74
+ export class MyAgent extends AIChatAgent<Env> {
75
+ // Set fastModel to enable automatic compaction and future background summarization.
76
+ protected fastModel = openai("gpt-4o-mini");
77
+
78
+ async onChatMessage(onFinish, options) {
79
+ const params = await this.buildLLMParams({
80
+ options,
81
+ onFinish,
82
+ model: openai("gpt-4o"),
83
+ system: "You are a helpful assistant.",
84
+ skills: [searchSkill],
85
+ });
86
+ return streamText(params).toUIMessageStreamResponse();
42
87
  }
43
- getSkills() {
44
- return [searchSkill, codeSkill];
88
+ }
89
+ ```
90
+
91
+ No D1 database needed — skill state is persisted to Durable Object SQLite automatically.
92
+
93
+ ---
94
+
95
+ ## Prerequisites
96
+
97
+ ### Cloudflare environment
98
+
99
+ Your agent class is a Durable Object. Declare it in `wrangler.jsonc`:
100
+
101
+ ```jsonc
102
+ {
103
+ "durable_objects": {
104
+ "bindings": [{ "name": "MyAgent", "class_name": "MyAgent" }],
105
+ },
106
+ "migrations": [{ "tag": "v1", "new_sqlite_classes": ["MyAgent"] }],
107
+ }
108
+ ```
109
+
110
+ Run `wrangler types` after to generate typed `Env` bindings.
111
+
112
+ ---
113
+
114
+ ## `AIChatAgent`
115
+
116
+ Extend this class and implement `onChatMessage`. Call `this.buildLLMParams()` to prepare the call, then pass the result to `streamText` or `generateText`.
117
+
118
+ ```typescript
119
+ import { streamText } from "ai";
120
+ import { AIChatAgent } from "@economic/agents";
121
+
122
+ export class ChatAgent extends AIChatAgent<Env> {
123
+ async onChatMessage(onFinish, options) {
124
+ const body = (options?.body ?? {}) as { userTier: "free" | "pro" };
125
+ const model = body.userTier === "pro" ? openai("gpt-4o") : openai("gpt-4o-mini");
126
+
127
+ const params = await this.buildLLMParams({
128
+ options,
129
+ onFinish,
130
+ model,
131
+ system: "You are a helpful assistant.",
132
+ skills: [searchSkill, calcSkill], // available for on-demand loading
133
+ tools: { alwaysOnTool }, // always active, regardless of loaded skills
134
+ });
135
+ return streamText(params).toUIMessageStreamResponse();
45
136
  }
46
- getSystemPrompt() {
47
- return "You are a helpful assistant.";
137
+ }
138
+ ```
139
+
140
+ ### `this.buildLLMParams(config)`
141
+
142
+ Protected method on `AIChatAgent`. Wraps the standalone `buildLLMParams` function with:
143
+
144
+ - `messages` pre-filled from `this.messages`
145
+ - `activeSkills` pre-filled from `await this.getLoadedSkills()`
146
+ - `fastModel` injected from `this.fastModel`
147
+ - `log` injected into `experimental_context` alongside `options.body`
148
+ - Automatic error logging for non-clean finish reasons
149
+ - Compaction threshold defaulting: when `maxMessagesBeforeCompaction` is not in the config, defaults to `30`. Pass `maxMessagesBeforeCompaction: undefined` explicitly to disable compaction.
150
+
151
+ Config is everything accepted by the standalone `buildLLMParams` except `messages`, `activeSkills`, and `fastModel`.
152
+
153
+ ### `guard`
154
+
155
+ Method decorator (TypeScript 5+ stage-3) for handlers shaped like `onChatMessage(onFinish, options?)`. Before your method runs, it calls your `GuardFn` with `options?.body` (the same custom body the client sends via `useAgentChat` / `body` on the chat request).
156
+
157
+ - Return **`undefined` / nothing** — the decorated method runs as usual.
158
+ - Return a **`Response`** — that response is returned immediately; `onChatMessage` is not called.
159
+
160
+ All policy (tokens, tiers, rate limits) lives in the guard function; the decorator only forwards `body` and branches on whether a `Response` was returned.
161
+
162
+ ```typescript
163
+ import { streamText } from "ai";
164
+ import { openai } from "@ai-sdk/openai";
165
+ import { AIChatAgent, guard, type GuardFn } from "@economic/agents";
166
+
167
+ const requireToken: GuardFn = async (body) => {
168
+ const token = body?.token;
169
+ if (typeof token !== "string" || !(await isValidToken(token))) {
170
+ return new Response("Unauthorized", { status: 401 });
48
171
  }
172
+ };
49
173
 
50
- // Return the D1 binding — typed in Cloudflare.Env after `wrangler types`
51
- protected getDB() {
52
- return this.env.AGENT_DB;
174
+ export class ChatAgent extends AIChatAgent<Env> {
175
+ protected fastModel = openai("gpt-4o-mini");
176
+
177
+ @guard(requireToken)
178
+ async onChatMessage(onFinish, options) {
179
+ const params = await this.buildLLMParams({
180
+ options,
181
+ onFinish,
182
+ model: openai("gpt-4o"),
183
+ system: "You are a helpful assistant.",
184
+ });
185
+ return streamText(params).toUIMessageStreamResponse();
53
186
  }
54
187
  }
55
188
  ```
56
189
 
57
- If you need control over the response — custom model options, middleware, varying the model per request — use `AIChatAgentBase` with the `@withSkills` decorator instead. See [COMPARISON.md](./COMPARISON.md) for a side-by-side example and `src/features/skills/README.md` for full `createSkills` documentation.
190
+ ### `fastModel` property
191
+
192
+ Override `fastModel` on your subclass to enable automatic compaction and future background conversation summarization:
193
+
194
+ ```typescript
195
+ export class MyAgent extends AIChatAgent<Env> {
196
+ protected fastModel = openai("gpt-4o-mini");
197
+ // ...
198
+ }
199
+ ```
200
+
201
+ When `fastModel` is set, compaction runs automatically with a default threshold of 30 messages. No per-call configuration is needed in the common case. You can still customise or disable it per-call via `maxMessagesBeforeCompaction`.
58
202
 
59
- ### Message compaction
203
+ When `fastModel` is `undefined` (the default), compaction is disabled regardless of `maxMessagesBeforeCompaction`.
60
204
 
61
- `AIChatAgent` automatically compacts the conversation history when it approaches the token limit (140k tokens). Older messages are summarised by the LLM into a single system message; the most recent messages are kept verbatim. The verbatim tail size is `maxPersistedMessages - 1` (default: 49 messages + 1 summary message).
205
+ ### `getConversations` (callable)
62
206
 
63
- The compaction model defaults to `getModel()`. To use a cheaper model for summarisation, override `getCompactionModel()`:
207
+ `AIChatAgent` exposes a [callable method](https://developers.cloudflare.com/agents/api-reference/callable-methods/) via the Agents SDK `@callable()` decorator (`agents` package). From any connected client (for example the object returned by `useAgent` / `useAIChatAgent`), invoke:
64
208
 
65
209
  ```typescript
66
- protected override getCompactionModel(): LanguageModel {
67
- return openai("gpt-4o-mini"); // cheaper model for summarisation
68
- }
210
+ const rows = await agent.call("getConversations");
69
211
  ```
70
212
 
71
- To disable compaction entirely, override `getCompactionModel()` to return `undefined`:
213
+ - **User scope**: `userId` is taken from the segment before the first `:` in the Durable Object name (`userId:chatId`), the same format enforced in `onConnect`.
214
+ - **Data**: Reads from `AGENT_DB`, returning all `conversations` rows whose `durable_object_name` matches `userId:%`, ordered by `updated_at` descending (newest first). Each row includes `durable_object_name`, `title`, `summary`, `created_at`, and `updated_at`.
215
+ - **No D1**: If `AGENT_DB` is not bound, the method returns `undefined` and does not throw.
216
+
217
+ ### `getLoadedSkills()`
218
+
219
+ Protected method on `AIChatAgent`. Returns skill names persisted from previous turns (read from DO SQLite). Used internally by `this.buildLLMParams()`.
220
+
221
+ ### `persistMessages` (automatic)
222
+
223
+ When `persistMessages` runs at the end of each turn, it:
224
+
225
+ 1. Scans `activate_skill` tool results for newly loaded skill state.
226
+ 2. Writes the updated skill name list to DO SQLite (no D1 needed).
227
+ 3. Logs a turn summary via `log()`.
228
+ 4. Strips all `activate_skill` and `list_capabilities` messages from history.
229
+ 5. Delegates to the CF base `persistMessages` for message storage and WS broadcast.
230
+
231
+ ### `onConnect` (automatic)
232
+
233
+ Replays the full message history to newly connected clients — without this, a page refresh would show an empty UI even though history is in DO SQLite.
234
+
235
+ ---
236
+
237
+ ## `buildLLMParams` (standalone)
238
+
239
+ The standalone `buildLLMParams` builds the full parameter object for a Vercel AI SDK `streamText` or `generateText` call. Use this directly only if you are not extending `AIChatAgent`, or need fine-grained control.
240
+
241
+ ```typescript
242
+ import { buildLLMParams } from "@economic/agents";
243
+
244
+ const params = await buildLLMParams({
245
+ options, // OnChatMessageOptions — extracts abortSignal and body
246
+ onFinish, // StreamTextOnFinishCallback<ToolSet>
247
+ model, // LanguageModel
248
+ messages: this.messages, // UIMessage[] — converted to ModelMessage[] internally
249
+ activeSkills: await this.getLoadedSkills(),
250
+ system: "You are a helpful assistant.",
251
+ skills: [searchSkill, codeSkill],
252
+ tools: { myAlwaysOnTool },
253
+ stopWhen: stepCountIs(20), // defaults to stepCountIs(20)
254
+ });
255
+
256
+ return streamText(params).toUIMessageStreamResponse();
257
+ // or: generateText(params);
258
+ ```
259
+
260
+ | Parameter | Type | Required | Description |
261
+ | ----------------------------- | ------------------------------------- | -------- | ------------------------------------------------------------------------------------------------- |
262
+ | `options` | `OnChatMessageOptions \| undefined` | Yes | CF options object. Extracts `abortSignal` and `experimental_context`. |
263
+ | `onFinish` | `StreamTextOnFinishCallback<ToolSet>` | Yes | Called when the stream completes. |
264
+ | `model` | `LanguageModel` | Yes | The language model to use. |
265
+ | `messages` | `UIMessage[]` | Yes | Conversation history. Converted to `ModelMessage[]` internally. |
266
+ | `activeSkills` | `string[]` | No | Names of skills loaded in previous turns. Pass `await this.getLoadedSkills()`. |
267
+ | `skills` | `Skill[]` | No | Skills available for on-demand loading. Wires up meta-tools automatically. |
268
+ | `system` | `string` | No | Base system prompt. |
269
+ | `tools` | `ToolSet` | No | Always-on tools, active every turn regardless of loaded skills. |
270
+ | `maxMessagesBeforeCompaction` | `number \| undefined` | No | Verbatim tail kept during compaction. Defaults to `30` when omitted. Pass `undefined` to disable. |
271
+ | `stopWhen` | `StopCondition` | No | Stop condition. Defaults to `stepCountIs(20)`. |
272
+
273
+ When `skills` are provided, `buildLLMParams`:
274
+
275
+ - Registers `activate_skill` and `list_capabilities` meta-tools.
276
+ - Sets initial `activeTools` (meta + always-on + loaded skill tools).
277
+ - Wires up `prepareStep` to update `activeTools` after each step.
278
+ - Composes `system` with guidance from loaded skills.
279
+
280
+ ---
281
+
282
+ ## Defining skills
283
+
284
+ ```typescript
285
+ import { tool } from "ai";
286
+ import { z } from "zod";
287
+ import type { Skill } from "@economic/agents";
288
+
289
+ // Skill with guidance — injected into the system prompt when the skill is loaded
290
+ export const calculatorSkill: Skill = {
291
+ name: "calculator",
292
+ description: "Mathematical calculation and expression evaluation",
293
+ guidance:
294
+ "Use the calculate tool for any arithmetic or algebraic expressions. " +
295
+ "Always show the expression you are evaluating.",
296
+ tools: {
297
+ calculate: tool({
298
+ description: "Evaluate a mathematical expression and return the result.",
299
+ inputSchema: z.object({
300
+ expression: z.string().describe('e.g. "2 + 2", "Math.sqrt(144)"'),
301
+ }),
302
+ execute: async ({ expression }) => {
303
+ const result = new Function(`"use strict"; return (${expression})`)();
304
+ return `${expression} = ${result}`;
305
+ },
306
+ }),
307
+ },
308
+ };
309
+
310
+ // Skill without guidance — tools are self-explanatory
311
+ export const datetimeSkill: Skill = {
312
+ name: "datetime",
313
+ description: "Current date and time information in any timezone",
314
+ tools: {
315
+ get_current_datetime: tool({
316
+ description: "Get the current date and time in an optional IANA timezone.",
317
+ inputSchema: z.object({
318
+ timezone: z.string().optional().describe('e.g. "Europe/Copenhagen"'),
319
+ }),
320
+ execute: async ({ timezone = "UTC" }) =>
321
+ new Date().toLocaleString("en-GB", {
322
+ timeZone: timezone,
323
+ dateStyle: "full",
324
+ timeStyle: "long",
325
+ }),
326
+ }),
327
+ },
328
+ };
329
+ ```
330
+
331
+ ### `Skill` fields
332
+
333
+ | Field | Type | Required | Description |
334
+ | ------------- | --------- | -------- | ---------------------------------------------------------------------------- |
335
+ | `name` | `string` | Yes | Unique identifier used by `activate_skill` and for DO SQLite persistence. |
336
+ | `description` | `string` | Yes | One-line description shown in the `activate_skill` schema. |
337
+ | `guidance` | `string` | No | Instructions appended to the `system` prompt when this skill is loaded. |
338
+ | `tools` | `ToolSet` | Yes | Record of tool names to `tool()` definitions. Names must be globally unique. |
339
+
340
+ ---
341
+
342
+ ## Compaction
343
+
344
+ When `fastModel` is set on the agent class, compaction runs automatically before each turn:
345
+
346
+ 1. The message list is split into an older window and a recent verbatim tail.
347
+ 2. `fastModel` generates a concise summary of the older window.
348
+ 3. That summary + the verbatim tail is what gets sent to the LLM.
349
+ 4. Full history in DO SQLite is unaffected — compaction is in-memory only.
350
+
351
+ ### Enabling compaction
352
+
353
+ Override `fastModel` on your subclass. Compaction runs automatically with a default threshold of 30 messages — no per-call config needed:
72
354
 
73
355
  ```typescript
74
- protected override getCompactionModel(): LanguageModel | undefined {
75
- return undefined; // no compaction — older messages are dropped at maxPersistedMessages
356
+ export class MyAgent extends AIChatAgent<Env> {
357
+ protected fastModel = openai("gpt-4o-mini");
358
+
359
+ async onChatMessage(onFinish, options) {
360
+ const params = await this.buildLLMParams({
361
+ options,
362
+ onFinish,
363
+ model: openai("gpt-4o"),
364
+ system: "...",
365
+ // No compaction config needed — runs automatically with default threshold
366
+ });
367
+ return streamText(params).toUIMessageStreamResponse();
368
+ }
76
369
  }
77
370
  ```
78
371
 
79
- `AIChatAgentBase` does not enable compaction by default. To add it, override `getCompactionModel()` to return a model — the `persistMessages` override will pick it up automatically:
372
+ ### Customising the threshold
373
+
374
+ Pass `maxMessagesBeforeCompaction` to override the default of 30:
375
+
376
+ ```typescript
377
+ const params = await this.buildLLMParams({
378
+ options,
379
+ onFinish,
380
+ model: openai("gpt-4o"),
381
+ maxMessagesBeforeCompaction: 50, // keep last 50 messages verbatim
382
+ });
383
+ ```
384
+
385
+ ### Disabling compaction
386
+
387
+ Pass `maxMessagesBeforeCompaction: undefined` explicitly to disable compaction for that call, even when `fastModel` is set:
80
388
 
81
389
  ```typescript
82
- protected override getCompactionModel(): LanguageModel {
83
- return openai("gpt-4o-mini");
390
+ const params = await this.buildLLMParams({
391
+ options,
392
+ onFinish,
393
+ model: openai("gpt-4o"),
394
+ maxMessagesBeforeCompaction: undefined, // compaction off
395
+ });
396
+ ```
397
+
398
+ Compaction is always off when `fastModel` is `undefined` (the base class default).
399
+
400
+ ---
401
+
402
+ ## Built-in meta tools
403
+
404
+ Two meta tools are automatically registered when `skills` are provided. You do not need to define or wire them.
405
+
406
+ ### `activate_skill`
407
+
408
+ Loads one or more skills by name, making their tools available for the rest of the conversation. The LLM calls this when it needs capabilities it does not currently have.
409
+
410
+ - Loading is idempotent — calling for an already-loaded skill is a no-op.
411
+ - The skills available are exactly those passed as `skills` — filter by request body to control access.
412
+ - When skills are successfully loaded, the new state is embedded in the tool result. `persistMessages` extracts it and writes to DO SQLite.
413
+ - All `activate_skill` messages are stripped from history before persistence — state is restored from DO SQLite, not from message history.
414
+
415
+ ### `list_capabilities`
416
+
417
+ Returns a summary of active tools, loaded skills, and skills available to load. Always stripped from history before persistence.
418
+
419
+ ---
420
+
421
+ ## Passing request context to tools
422
+
423
+ Pass arbitrary data via the `body` option of `useAgentChat`. It arrives as `experimental_context` in tool `execute` functions.
424
+
425
+ When using `this.buildLLMParams()`, the context is automatically composed: your body fields plus a `log` function for writing audit events. Use `AgentContext<TBody>` to type it:
426
+
427
+ ```typescript
428
+ // types.ts
429
+ import type { AgentContext } from "@economic/agents";
430
+
431
+ interface AgentBody {
432
+ authorization: string;
433
+ userId: string;
84
434
  }
435
+
436
+ export type ToolContext = AgentContext<AgentBody>;
437
+ ```
438
+
439
+ ```typescript
440
+ // Client
441
+ useAgentChat({ body: { authorization: token, userId: "u_123" } });
442
+
443
+ // Tool
444
+ execute: async (args, { experimental_context }) => {
445
+ const ctx = experimental_context as ToolContext;
446
+ await ctx.log("tool called", { userId: ctx.userId });
447
+ const data = await fetchSomething(ctx.authorization);
448
+ return data;
449
+ };
450
+ ```
451
+
452
+ `log` is a no-op when `AGENT_DB` is not bound — so no changes are needed in tools when running without a D1 database.
453
+
454
+ ---
455
+
456
+ ## Audit logging — D1 setup
457
+
458
+ `AIChatAgent` writes audit events to a Cloudflare D1 database when `AGENT_DB` is bound on the environment. Each agent worker has its own dedicated D1 database.
459
+
460
+ ### 1. Create the D1 database
461
+
462
+ In the [Cloudflare dashboard](https://dash.cloudflare.com) → **Workers & Pages** → **D1** → **Create database**. Note the database name and ID.
463
+
464
+ ### 2. Create the schema
465
+
466
+ Open the database in the D1 dashboard, select **Console**, and run the contents of [`schema/schema.sql`](schema/schema.sql) — this creates both the `audit_events` and `conversations` tables in one step:
467
+
468
+ ```sql
469
+ CREATE TABLE IF NOT EXISTS audit_events (
470
+ id TEXT PRIMARY KEY,
471
+ durable_object_id TEXT NOT NULL,
472
+ user_id TEXT NOT NULL,
473
+ message TEXT NOT NULL,
474
+ payload TEXT,
475
+ created_at TEXT NOT NULL
476
+ );
477
+ CREATE INDEX IF NOT EXISTS audit_events_user ON audit_events(user_id);
478
+ CREATE INDEX IF NOT EXISTS audit_events_do ON audit_events(durable_object_id);
479
+ CREATE INDEX IF NOT EXISTS audit_events_ts ON audit_events(created_at);
480
+
481
+ CREATE TABLE IF NOT EXISTS conversations (
482
+ durable_object_id TEXT PRIMARY KEY,
483
+ user_id TEXT NOT NULL,
484
+ title TEXT,
485
+ summary TEXT,
486
+ created_at TEXT NOT NULL,
487
+ updated_at TEXT NOT NULL
488
+ );
489
+ CREATE INDEX IF NOT EXISTS conversations_user ON conversations(user_id);
490
+ CREATE INDEX IF NOT EXISTS conversations_ts ON conversations(updated_at);
491
+ ```
492
+
493
+ Safe to re-run — all statements use `IF NOT EXISTS`.
494
+
495
+ ### 3. Bind it in `wrangler.jsonc`
496
+
497
+ ```jsonc
498
+ "d1_databases": [
499
+ { "binding": "AGENT_DB", "database_name": "agents", "database_id": "YOUR_DB_ID" }
500
+ ]
501
+ ```
502
+
503
+ Then run `wrangler types` to regenerate the `Env` type.
504
+
505
+ ### 4. Seed local development
506
+
507
+ ```bash
508
+ npm run db:setup
509
+ ```
510
+
511
+ This runs the schema SQL against the local D1 SQLite file (`.wrangler/state/`). Re-running is harmless.
512
+
513
+ If `AGENT_DB` is not bound, all `log()` calls are silent no-ops — the agent works without it.
514
+
515
+ ### Providing `userId`
516
+
517
+ The `user_id` column is `NOT NULL`. The base class reads `userId` automatically from `options.body` — no subclass override is needed. The client must include it in the `body` passed to `useAgentChat`:
518
+
519
+ ```typescript
520
+ useAgentChat({
521
+ agent,
522
+ body: {
523
+ userId: "148583_matt", // compose from agreement number + user identifier
524
+ // ...other fields
525
+ },
526
+ });
85
527
  ```
86
528
 
87
- Alternatively, import `compactIfNeeded` and `COMPACT_TOKEN_THRESHOLD` from `@economic/agents` and call them yourself inside a custom `persistMessages` override for full control over the compaction logic.
529
+ If the client omits `userId`, the audit insert is skipped and a `console.error` is emitted. This will be visible in Wrangler's output during local development and in Workers Logs in production.
530
+
531
+ ---
532
+
533
+ ## Conversations — D1 setup
534
+
535
+ `AIChatAgent` maintains a `conversations` table in `AGENT_DB` alongside `audit_events`. One row is kept per Durable Object instance (i.e. per conversation). The row is upserted automatically after every turn — no subclass code needed.
536
+
537
+ The `conversations` table is created by the same `schema/schema.sql` file used for audit events — no separate setup step needed.
538
+
539
+ ### Upsert behaviour
540
+
541
+ - **First turn**: a new row is inserted with `created_at` and `updated_at` both set to now. `title` and `summary` are `NULL`.
542
+ - **Subsequent turns**: only `user_id` and `updated_at` are updated. `created_at`, `title`, and `summary` are never overwritten by the upsert.
543
+ - `title` and `summary` are populated automatically after the conversation goes idle (see below).
544
+
545
+ ### Automatic title and summary generation
546
+
547
+ After every turn, `AIChatAgent` schedules a `generateSummary` callback to fire 30 minutes in the future. If another message arrives before the timer fires, the schedule is cancelled and reset — so the callback only runs once the conversation has been idle for 30 minutes.
548
+
549
+ When `generateSummary` fires it:
550
+
551
+ 1. Fetches the current summary from D1 (if any).
552
+ 2. Takes the last 30 messages (`SUMMARY_CONTEXT_MESSAGES`) to keep the prompt bounded.
553
+ 3. Calls `fastModel` with `Output.object()` to generate a structured `{ title, summary }`.
554
+ 4. If a previous summary exists, it is included in the prompt so the model can detect direction changes.
555
+ 5. Writes the result back to the `conversations` row.
556
+
557
+ No subclass code is needed — this runs automatically when `AGENT_DB` is bound and `fastModel` is set on the class.
558
+
559
+ ### Querying conversation lists
560
+
561
+ From a connected agent client, prefer the built-in callable (see **`getConversations` (callable)** under [`AIChatAgent`](#aichatagent)): `await agent.call("getConversations")`.
562
+
563
+ To query D1 directly (same logic as the callable), filter by `durable_object_name` prefix — one row per chat, keyed as `userId:chatId`:
564
+
565
+ ```sql
566
+ SELECT durable_object_name, title, summary, created_at, updated_at
567
+ FROM conversations
568
+ WHERE durable_object_name LIKE '148583_matt:%'
569
+ ORDER BY updated_at DESC;
570
+ ```
571
+
572
+ If `userId` is not set on the request body, the upsert is skipped and a `console.error` is emitted — the same behaviour as audit logging.
573
+
574
+ ---
575
+
576
+ ## API reference
577
+
578
+ ### Classes
579
+
580
+ | Export | Description |
581
+ | ------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
582
+ | `AIChatAgent` | Abstract CF Durable Object base class. Implement `onChatMessage`. Manages skill state, history replay, audit log, and D1 `conversations` upserts. Exposes callable `getConversations` for listing a user’s conversations from the client. |
583
+
584
+ ### Functions
585
+
586
+ | Export | Signature | Description |
587
+ | ---------------- | -------------------------------------- | ------------------------------------------------------------------------------------------------------ |
588
+ | `guard` | `(guardFn: GuardFn)` | Method decorator: runs `guardFn` with `options.body`; a returned `Response` short-circuits the method. |
589
+ | `buildLLMParams` | `async (config) => Promise<LLMParams>` | Builds the full parameter object for `streamText` or `generateText`. |
590
+
591
+ ### Types
592
+
593
+ | Export | Description |
594
+ | ---------------------- | ---------------------------------------------------------------------------------------------------------------- |
595
+ | `GuardFn` | `(body) => Response \| void \| Promise<...>`. Receives chat request `body`; return `Response` to block the turn. |
596
+ | `Skill` | A named group of tools with optional guidance. |
597
+ | `AgentContext<TBody>` | Request body type merged with `log`. Use as the type of `experimental_context`. |
598
+ | `BuildLLMParamsConfig` | Config type for the standalone `buildLLMParams` function. |
599
+
600
+ ---
601
+
602
+ ## Development
603
+
604
+ ```bash
605
+ npm install # install dependencies
606
+ npm test # run tests
607
+ npm pack # build
608
+ ```