@economic/agents 0.0.1-alpha.12 → 0.0.1-alpha.13

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
@@ -43,6 +43,9 @@ const searchSkill: Skill = {
43
43
  };
44
44
 
45
45
  export class MyAgent extends AIChatAgent<Env> {
46
+ // Set fastModel to enable automatic compaction and future background summarization.
47
+ protected fastModel = openai("gpt-4o-mini");
48
+
46
49
  async onChatMessage(onFinish, options) {
47
50
  const params = await this.buildLLMParams({
48
51
  options,
@@ -111,10 +114,27 @@ Protected method on `AIChatAgent`. Wraps the standalone `buildLLMParams` functio
111
114
 
112
115
  - `messages` pre-filled from `this.messages`
113
116
  - `activeSkills` pre-filled from `await this.getLoadedSkills()`
117
+ - `fastModel` injected from `this.fastModel`
114
118
  - `log` injected into `experimental_context` alongside `options.body`
115
119
  - Automatic error logging for non-clean finish reasons
120
+ - Compaction threshold defaulting: when `maxMessagesBeforeCompaction` is not in the config, defaults to `30`. Pass `maxMessagesBeforeCompaction: undefined` explicitly to disable compaction.
121
+
122
+ Config is everything accepted by the standalone `buildLLMParams` except `messages`, `activeSkills`, and `fastModel`.
123
+
124
+ ### `fastModel` property
125
+
126
+ Override `fastModel` on your subclass to enable automatic compaction and future background conversation summarization:
127
+
128
+ ```typescript
129
+ export class MyAgent extends AIChatAgent<Env> {
130
+ protected fastModel = openai("gpt-4o-mini");
131
+ // ...
132
+ }
133
+ ```
134
+
135
+ 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`.
116
136
 
117
- Config is everything accepted by the standalone `buildLLMParams` except `messages` and `activeSkills`.
137
+ When `fastModel` is `undefined` (the default), compaction is disabled regardless of `maxMessagesBeforeCompaction`.
118
138
 
119
139
  ### `getLoadedSkills()`
120
140
 
@@ -152,7 +172,6 @@ const params = await buildLLMParams({
152
172
  system: "You are a helpful assistant.",
153
173
  skills: [searchSkill, codeSkill],
154
174
  tools: { myAlwaysOnTool },
155
- compact: { model: openai("gpt-4o-mini"), maxMessages: 30 },
156
175
  stopWhen: stepCountIs(20), // defaults to stepCountIs(20)
157
176
  });
158
177
 
@@ -160,18 +179,18 @@ return streamText(params).toUIMessageStreamResponse();
160
179
  // or: generateText(params);
161
180
  ```
162
181
 
163
- | Parameter | Type | Required | Description |
164
- | -------------- | ----------------------------------------------- | -------- | ------------------------------------------------------------------------------ |
165
- | `options` | `OnChatMessageOptions \| undefined` | Yes | CF options object. Extracts `abortSignal` and `experimental_context`. |
166
- | `onFinish` | `StreamTextOnFinishCallback<ToolSet>` | Yes | Called when the stream completes. |
167
- | `model` | `LanguageModel` | Yes | The language model to use. |
168
- | `messages` | `UIMessage[]` | Yes | Conversation history. Converted to `ModelMessage[]` internally. |
169
- | `activeSkills` | `string[]` | No | Names of skills loaded in previous turns. Pass `await this.getLoadedSkills()`. |
170
- | `skills` | `Skill[]` | No | Skills available for on-demand loading. Wires up meta-tools automatically. |
171
- | `system` | `string` | No | Base system prompt. |
172
- | `tools` | `ToolSet` | No | Always-on tools, active every turn regardless of loaded skills. |
173
- | `compact` | `{ model: LanguageModel; maxMessages: number }` | No | When provided, compacts old messages before sending to the model. |
174
- | `stopWhen` | `StopCondition` | No | Stop condition. Defaults to `stepCountIs(20)`. |
182
+ | Parameter | Type | Required | Description |
183
+ | ----------------------------- | ------------------------------------- | -------- | ------------------------------------------------------------------------------------------------- |
184
+ | `options` | `OnChatMessageOptions \| undefined` | Yes | CF options object. Extracts `abortSignal` and `experimental_context`. |
185
+ | `onFinish` | `StreamTextOnFinishCallback<ToolSet>` | Yes | Called when the stream completes. |
186
+ | `model` | `LanguageModel` | Yes | The language model to use. |
187
+ | `messages` | `UIMessage[]` | Yes | Conversation history. Converted to `ModelMessage[]` internally. |
188
+ | `activeSkills` | `string[]` | No | Names of skills loaded in previous turns. Pass `await this.getLoadedSkills()`. |
189
+ | `skills` | `Skill[]` | No | Skills available for on-demand loading. Wires up meta-tools automatically. |
190
+ | `system` | `string` | No | Base system prompt. |
191
+ | `tools` | `ToolSet` | No | Always-on tools, active every turn regardless of loaded skills. |
192
+ | `maxMessagesBeforeCompaction` | `number \| undefined` | No | Verbatim tail kept during compaction. Defaults to `30` when omitted. Pass `undefined` to disable. |
193
+ | `stopWhen` | `StopCondition` | No | Stop condition. Defaults to `stepCountIs(20)`. |
175
194
 
176
195
  When `skills` are provided, `buildLLMParams`:
177
196
 
@@ -244,27 +263,62 @@ export const datetimeSkill: Skill = {
244
263
 
245
264
  ## Compaction
246
265
 
247
- When `compact` is provided to `buildLLMParams`, it compacts `messages` before converting and sending to the model:
266
+ When `fastModel` is set on the agent class, compaction runs automatically before each turn:
248
267
 
249
- 1. The message list is split into an older window and a recent verbatim tail (`maxMessages`).
250
- 2. A model call generates a concise summary of the older window.
268
+ 1. The message list is split into an older window and a recent verbatim tail.
269
+ 2. `fastModel` generates a concise summary of the older window.
251
270
  3. That summary + the verbatim tail is what gets sent to the LLM.
252
271
  4. Full history in DO SQLite is unaffected — compaction is in-memory only.
253
272
 
273
+ ### Enabling compaction
274
+
275
+ Override `fastModel` on your subclass. Compaction runs automatically with a default threshold of 30 messages — no per-call config needed:
276
+
277
+ ```typescript
278
+ export class MyAgent extends AIChatAgent<Env> {
279
+ protected fastModel = openai("gpt-4o-mini");
280
+
281
+ async onChatMessage(onFinish, options) {
282
+ const params = await this.buildLLMParams({
283
+ options,
284
+ onFinish,
285
+ model: openai("gpt-4o"),
286
+ system: "...",
287
+ // No compaction config needed — runs automatically with default threshold
288
+ });
289
+ return streamText(params).toUIMessageStreamResponse();
290
+ }
291
+ }
292
+ ```
293
+
294
+ ### Customising the threshold
295
+
296
+ Pass `maxMessagesBeforeCompaction` to override the default of 30:
297
+
254
298
  ```typescript
255
299
  const params = await this.buildLLMParams({
256
300
  options,
257
301
  onFinish,
258
302
  model: openai("gpt-4o"),
259
- system: "...",
260
- compact: {
261
- model: openai("gpt-4o-mini"), // cheaper model for summarisation
262
- maxMessages: 30, // keep last 30 messages verbatim
263
- },
303
+ maxMessagesBeforeCompaction: 50, // keep last 50 messages verbatim
304
+ });
305
+ ```
306
+
307
+ ### Disabling compaction
308
+
309
+ Pass `maxMessagesBeforeCompaction: undefined` explicitly to disable compaction for that call, even when `fastModel` is set:
310
+
311
+ ```typescript
312
+ const params = await this.buildLLMParams({
313
+ options,
314
+ onFinish,
315
+ model: openai("gpt-4o"),
316
+ maxMessagesBeforeCompaction: undefined, // compaction off
264
317
  });
265
- return streamText(params).toUIMessageStreamResponse();
266
318
  ```
267
319
 
320
+ Compaction is always off when `fastModel` is `undefined` (the base class default).
321
+
268
322
  ---
269
323
 
270
324
  ## Built-in meta tools
@@ -317,13 +371,13 @@ execute: async (args, { experimental_context }) => {
317
371
  };
318
372
  ```
319
373
 
320
- `log` is a no-op when `AUDIT_DB` is not bound — so no changes are needed in tools when running without a D1 database.
374
+ `log` is a no-op when `AGENT_DB` is not bound — so no changes are needed in tools when running without a D1 database.
321
375
 
322
376
  ---
323
377
 
324
378
  ## Audit logging — D1 setup
325
379
 
326
- `AIChatAgent` writes audit events to a Cloudflare D1 database when `AUDIT_DB` is bound on the environment. The table is shared across all agent workers — create it once.
380
+ `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.
327
381
 
328
382
  ### 1. Create the D1 database
329
383
 
@@ -336,14 +390,15 @@ Open the database in the D1 dashboard, select **Console**, and run the contents
336
390
  ```sql
337
391
  CREATE TABLE IF NOT EXISTS audit_events (
338
392
  id TEXT PRIMARY KEY,
339
- agent_name TEXT NOT NULL,
340
393
  durable_object_id TEXT NOT NULL,
394
+ user_id TEXT NOT NULL,
341
395
  message TEXT NOT NULL,
342
396
  payload TEXT,
343
397
  created_at TEXT NOT NULL
344
398
  );
345
- CREATE INDEX IF NOT EXISTS audit_events_do ON audit_events(durable_object_id);
346
- CREATE INDEX IF NOT EXISTS audit_events_ts ON audit_events(created_at);
399
+ CREATE INDEX IF NOT EXISTS audit_events_user ON audit_events(user_id);
400
+ CREATE INDEX IF NOT EXISTS audit_events_do ON audit_events(durable_object_id);
401
+ CREATE INDEX IF NOT EXISTS audit_events_ts ON audit_events(created_at);
347
402
  ```
348
403
 
349
404
  Safe to re-run — all statements use `IF NOT EXISTS`.
@@ -352,7 +407,7 @@ Safe to re-run — all statements use `IF NOT EXISTS`.
352
407
 
353
408
  ```jsonc
354
409
  "d1_databases": [
355
- { "binding": "AUDIT_DB", "database_name": "agents", "database_id": "YOUR_DB_ID" }
410
+ { "binding": "AGENT_DB", "database_name": "agents", "database_id": "YOUR_DB_ID" }
356
411
  ]
357
412
  ```
358
413
 
@@ -366,7 +421,81 @@ npm run db:setup
366
421
 
367
422
  This runs the schema SQL against the local D1 SQLite file (`.wrangler/state/`). Re-running is harmless.
368
423
 
369
- If `AUDIT_DB` is not bound, all `log()` calls are silent no-ops — the agent works without it.
424
+ If `AGENT_DB` is not bound, all `log()` calls are silent no-ops — the agent works without it.
425
+
426
+ ### Providing `userId`
427
+
428
+ 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`:
429
+
430
+ ```typescript
431
+ useAgentChat({
432
+ agent,
433
+ body: {
434
+ userId: "148583_matt", // compose from agreement number + user identifier
435
+ // ...other fields
436
+ },
437
+ });
438
+ ```
439
+
440
+ 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.
441
+
442
+ ---
443
+
444
+ ## Conversations — D1 setup
445
+
446
+ `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.
447
+
448
+ ### Schema
449
+
450
+ Run the contents of [`schema/conversations.sql`](schema/conversations.sql) in the D1 dashboard console (same database as `audit_events`):
451
+
452
+ ```sql
453
+ CREATE TABLE IF NOT EXISTS conversations (
454
+ durable_object_id TEXT PRIMARY KEY,
455
+ user_id TEXT NOT NULL,
456
+ title TEXT,
457
+ summary TEXT,
458
+ created_at TEXT NOT NULL,
459
+ updated_at TEXT NOT NULL
460
+ );
461
+ CREATE INDEX IF NOT EXISTS conversations_user ON conversations(user_id);
462
+ CREATE INDEX IF NOT EXISTS conversations_ts ON conversations(updated_at);
463
+ ```
464
+
465
+ Safe to re-run — all statements use `IF NOT EXISTS`.
466
+
467
+ ### Upsert behaviour
468
+
469
+ - **First turn**: a new row is inserted with `created_at` and `updated_at` both set to now. `title` and `summary` are `NULL`.
470
+ - **Subsequent turns**: only `user_id` and `updated_at` are updated. `created_at`, `title`, and `summary` are never overwritten by the upsert.
471
+ - `title` and `summary` are populated automatically after the conversation goes idle (see below).
472
+
473
+ ### Automatic title and summary generation
474
+
475
+ 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.
476
+
477
+ When `generateSummary` fires it:
478
+
479
+ 1. Fetches the current summary from D1 (if any).
480
+ 2. Takes the last 30 messages (`SUMMARY_CONTEXT_MESSAGES`) to keep the prompt bounded.
481
+ 3. Calls `fastModel` with `Output.object()` to generate a structured `{ title, summary }`.
482
+ 4. If a previous summary exists, it is included in the prompt so the model can detect direction changes.
483
+ 5. Writes the result back to the `conversations` row.
484
+
485
+ No subclass code is needed — this runs automatically when `AGENT_DB` is bound and `fastModel` is set on the class.
486
+
487
+ ### Querying conversation lists
488
+
489
+ To fetch all conversations for a user, ordered by most recent:
490
+
491
+ ```sql
492
+ SELECT durable_object_id, title, summary, created_at, updated_at
493
+ FROM conversations
494
+ WHERE user_id = '148583_matt'
495
+ ORDER BY updated_at DESC;
496
+ ```
497
+
498
+ 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.
370
499
 
371
500
  ---
372
501
 
@@ -389,7 +518,6 @@ If `AUDIT_DB` is not bound, all `log()` calls are silent no-ops — the agent wo
389
518
  | Export | Description |
390
519
  | ---------------------- | ------------------------------------------------------------------------------- |
391
520
  | `Skill` | A named group of tools with optional guidance. |
392
- | `CompactOptions` | `{ model: LanguageModel; maxMessages: number }` |
393
521
  | `AgentContext<TBody>` | Request body type merged with `log`. Use as the type of `experimental_context`. |
394
522
  | `BuildLLMParamsConfig` | Config type for the standalone `buildLLMParams` function. |
395
523
 
package/dist/index.d.mts CHANGED
@@ -22,20 +22,32 @@ interface Skill {
22
22
  tools: ToolSet;
23
23
  }
24
24
  //#endregion
25
- //#region src/features/compaction/index.d.ts
26
- type CompactOptions = {
27
- /** Model used to generate the compaction summary */model: LanguageModel; /** Number of recent messages to keep verbatim; older messages are summarised */
28
- maxMessages: number;
29
- };
30
- //#endregion
31
25
  //#region src/llm.d.ts
32
26
  type LLMParams = Parameters<typeof streamText>[0] & Parameters<typeof generateText>[0];
33
27
  type BuildLLMParamsConfig = Omit<LLMParams, "messages" | "experimental_context" | "abortSignal"> & {
34
28
  /** CF options object — extracts `abortSignal` and `experimental_context` (from `body`). */options: OnChatMessageOptions | undefined; /** Conversation history (`this.messages`). Converted to `ModelMessage[]` internally. */
35
29
  messages: UIMessage[]; /** Skill names loaded in previous turns. Pass `await this.getLoadedSkills()`. */
36
30
  activeSkills?: string[]; /** Skills available for on-demand loading this turn. */
37
- skills?: Skill[]; /** When provided, compacts old messages before sending to the model. */
38
- compact?: CompactOptions;
31
+ skills?: Skill[];
32
+ /**
33
+ * Number of recent messages to keep verbatim during compaction. Older messages
34
+ * beyond this count are summarised by `fastModel` before being sent to the LLM.
35
+ *
36
+ * Defaults to `DEFAULT_MAX_MESSAGES_BEFORE_COMPACTION` (30) when not provided.
37
+ * Set explicitly to `undefined` to disable compaction entirely.
38
+ *
39
+ * Compaction only runs when `fastModel` is also set on the agent class.
40
+ *
41
+ * @internal Injected by `AIChatAgent.buildLLMParams` — do not set this directly.
42
+ */
43
+ maxMessagesBeforeCompaction?: number;
44
+ /**
45
+ * The fast/cheap model used for compaction and background summarization.
46
+ * Provided automatically from `AIChatAgent.fastModel` — do not set this directly.
47
+ *
48
+ * @internal
49
+ */
50
+ fastModel?: LanguageModel;
39
51
  };
40
52
  /**
41
53
  * Builds the parameter object for a Vercel AI SDK `streamText` or `generateText` call.
@@ -67,7 +79,32 @@ declare function buildLLMParams(config: BuildLLMParamsConfig): Promise<LLMParams
67
79
  */
68
80
  declare abstract class AIChatAgent<Env extends Cloudflare.Env = Cloudflare.Env> extends AIChatAgent$1<Env> {
69
81
  /**
70
- * Writes an audit event to D1 if `AUDIT_DB` is bound on the environment,
82
+ * Composed user identifier extracted from `options.body.userId` during
83
+ * `buildLLMParams`. Expected format: `{agreementNumber}_{userId}`, e.g. `148583_matt`.
84
+ * Undefined if the client did not include `userId` in the request body.
85
+ */
86
+ protected _userId: string | undefined;
87
+ /**
88
+ * Fast/cheap language model used for background tasks: compaction and conversation summarization.
89
+ *
90
+ * Declare this on every subclass:
91
+ *
92
+ * ```typescript
93
+ * protected fastModel = google("gemini-2.0-flash");
94
+ * ```
95
+ *
96
+ * To disable compaction for a specific call, pass `maxMessagesBeforeCompaction: undefined`
97
+ * to `buildLLMParams` rather than omitting or nulling out `fastModel`.
98
+ */
99
+ protected abstract fastModel: LanguageModel;
100
+ /**
101
+ * Resolves the D1 database binding and userId required for all D1 writes.
102
+ * Returns null and silently no-ops if AGENT_DB is not bound.
103
+ * Returns null and logs an error if userId is missing from the request body.
104
+ */
105
+ private resolveD1Context;
106
+ /**
107
+ * Writes an audit event to D1 if `AGENT_DB` is bound on the environment,
71
108
  * otherwise silently does nothing.
72
109
  *
73
110
  * Called automatically after every turn (from `persistMessages`) and on
@@ -75,17 +112,53 @@ declare abstract class AIChatAgent<Env extends Cloudflare.Env = Cloudflare.Env>
75
112
  * `experimental_context.log` in tool `execute` functions.
76
113
  */
77
114
  protected log(message: string, payload?: Record<string, unknown>): Promise<void>;
115
+ /**
116
+ * Records this conversation in the `conversations` D1 table and resets
117
+ * the idle summarization timer. Called automatically from `persistMessages`
118
+ * after every turn.
119
+ *
120
+ * After each upsert, any pending `generateSummary` schedule is cancelled
121
+ * and a new one is set for 30 minutes from now. If the user sends another
122
+ * message before the timer fires, the schedule is cancelled and reset again
123
+ * (debounce). When the conversation goes idle, `generateSummary` fires and
124
+ * writes the LLM-generated title and summary to D1.
125
+ */
126
+ private recordConversation;
127
+ /**
128
+ * Generates a title and summary for the conversation after 30 minutes of
129
+ * inactivity. Invoked automatically by the Cloudflare Agents SDK scheduler
130
+ * — do not call this directly.
131
+ *
132
+ * Delegates to `generateConversationSummary` in `features/conversations`,
133
+ * which fetches the previous summary, slices to the last
134
+ * `SUMMARY_CONTEXT_MESSAGES` messages, calls `fastModel` with a structured
135
+ * output schema, and writes the result back to D1.
136
+ */
137
+ generateSummary(): Promise<void>;
78
138
  /**
79
139
  * Builds the parameter object for a `streamText` or `generateText` call,
80
- * pre-filling `messages` and `activeSkills` from this agent instance.
140
+ * pre-filling `messages`, `activeSkills`, and `fastModel` from this agent instance.
81
141
  * Injects `log` into `experimental_context` and logs non-clean finish reasons.
82
142
  *
143
+ * **Compaction** runs automatically when `fastModel` is set on the class, using
144
+ * `DEFAULT_MAX_MESSAGES_BEFORE_COMPACTION` (30) as the threshold. Override the
145
+ * threshold by passing `maxMessagesBeforeCompaction`. Disable compaction entirely
146
+ * by passing `maxMessagesBeforeCompaction: undefined` explicitly.
147
+ *
83
148
  * ```typescript
149
+ * // Compaction on (default threshold):
84
150
  * const params = await this.buildLLMParams({ options, onFinish, model, system: "..." });
151
+ *
152
+ * // Compaction with custom threshold:
153
+ * const params = await this.buildLLMParams({ options, onFinish, model, maxMessagesBeforeCompaction: 50 });
154
+ *
155
+ * // Compaction off:
156
+ * const params = await this.buildLLMParams({ options, onFinish, model, maxMessagesBeforeCompaction: undefined });
157
+ *
85
158
  * return streamText(params).toUIMessageStreamResponse();
86
159
  * ```
87
160
  */
88
- protected buildLLMParams(config: Omit<BuildLLMParamsConfig, "messages" | "activeSkills">): ReturnType<typeof buildLLMParams>;
161
+ protected buildLLMParams(config: Omit<BuildLLMParamsConfig, "messages" | "activeSkills" | "fastModel">): ReturnType<typeof buildLLMParams>;
89
162
  /**
90
163
  * Skill names persisted from previous turns, read from DO SQLite.
91
164
  * Returns an empty array if no skills have been loaded yet.
@@ -108,9 +181,16 @@ declare abstract class AIChatAgent<Env extends Cloudflare.Env = Cloudflare.Env>
108
181
  persistMessages(messages: UIMessage[], excludeBroadcastIds?: string[], options?: {
109
182
  _deleteStaleRows?: boolean;
110
183
  }): Promise<void>;
111
- private ensureSkillTableExists;
112
184
  }
113
185
  //#endregion
186
+ //#region src/features/compaction/index.d.ts
187
+ /**
188
+ * Number of recent messages to keep verbatim when compaction runs.
189
+ * Older messages beyond this count are summarised into a single system message.
190
+ * Used as the default when `maxMessagesBeforeCompaction` is not provided to `buildLLMParams`.
191
+ */
192
+ declare const DEFAULT_MAX_MESSAGES_BEFORE_COMPACTION = 30;
193
+ //#endregion
114
194
  //#region src/types.d.ts
115
195
  /**
116
196
  * The context object available throughout an agent's lifetime — passed via
@@ -127,4 +207,4 @@ type AgentContext<TBody = Record<string, unknown>> = TBody & {
127
207
  log: (message: string, payload?: Record<string, unknown>) => void | Promise<void>;
128
208
  };
129
209
  //#endregion
130
- export { AIChatAgent, type AgentContext, type BuildLLMParamsConfig, type CompactOptions, type Skill, buildLLMParams };
210
+ export { AIChatAgent, type AgentContext, type BuildLLMParamsConfig, DEFAULT_MAX_MESSAGES_BEFORE_COMPACTION, type Skill, buildLLMParams };