@economic/agents 0.0.1 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,62 +1,26 @@
1
1
  # @economic/agents
2
2
 
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.
3
+ A batteries-included toolkit for building AI agents on Cloudflare Workers. Provides Durable Object base classes for both chat and non-chat agents, with on-demand skill loading, automatic message compaction, conversation management, and audit logging to D1.
4
4
 
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).
10
-
11
- ---
12
-
13
- ## Overview
14
-
15
- `@economic/agents` provides:
16
-
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.
5
+ For chat agents, extend `ChatAgentHarness` (recommended) or `ChatAgent` (lower-level). For headless agents, extend `Agent`.
21
6
 
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.
7
+ For React integration, see [`@economic/agents-react`](../react/README.md).
23
8
 
24
- ### React client
9
+ ## Install
25
10
 
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;
11
+ ```sh
12
+ npm install @economic/agents @cloudflare/ai-chat ai agents
45
13
  ```
46
14
 
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.
48
-
49
- ---
15
+ ## Quick Start
50
16
 
51
- ## Quick start
17
+ ### Server
52
18
 
53
19
  ```typescript
54
- import { streamText } from "ai";
55
20
  import { openai } from "@ai-sdk/openai";
56
21
  import { tool } from "ai";
57
22
  import { z } from "zod";
58
- import { AIChatAgent } from "@economic/agents";
59
- import type { Skill } from "@economic/agents";
23
+ import { ChatAgentHarness, type AgentToolContext, type Skill } from "@economic/agents";
60
24
 
61
25
  const searchSkill: Skill = {
62
26
  name: "search",
@@ -71,32 +35,28 @@ const searchSkill: Skill = {
71
35
  },
72
36
  };
73
37
 
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();
38
+ export class MyAgent extends ChatAgentHarness<Env> {
39
+ getModel(ctx: AgentToolContext) {
40
+ return openai("gpt-4o");
87
41
  }
88
- }
89
- ```
90
42
 
91
- No D1 database needed — skill state is persisted to Durable Object SQLite automatically.
43
+ getFastModel() {
44
+ return openai("gpt-4o-mini");
45
+ }
92
46
 
93
- ---
47
+ getSystemPrompt(ctx: AgentToolContext) {
48
+ return "You are a helpful assistant.";
49
+ }
94
50
 
95
- ## Prerequisites
51
+ getSkills(ctx: AgentToolContext) {
52
+ return [searchSkill];
53
+ }
54
+ }
55
+ ```
96
56
 
97
- ### Cloudflare environment
57
+ For lower-level control (custom `onChatMessage` implementations), extend `ChatAgent` directly — see [ChatAgent](#chatagent).
98
58
 
99
- Your agent class is a Durable Object. Declare it in `wrangler.jsonc`:
59
+ ### Wrangler Config
100
60
 
101
61
  ```jsonc
102
62
  {
@@ -109,184 +69,234 @@ Your agent class is a Durable Object. Declare it in `wrangler.jsonc`:
109
69
 
110
70
  Run `wrangler types` after to generate typed `Env` bindings.
111
71
 
72
+ ### Client
73
+
74
+ ```typescript
75
+ import { useAIChatAgent, type AgentConnectionStatus } from "@economic/agents-react";
76
+ import { useState } from "react";
77
+
78
+ const [connectionStatus, setConnectionStatus] = useState<AgentConnectionStatus>("connecting");
79
+
80
+ const { agent, chat } = useAIChatAgent({
81
+ agent: "MyAgent",
82
+ host: "localhost:8787",
83
+ chatId: "user_123:session-1",
84
+ toolContext: {},
85
+ connectionParams: { userId: "…" },
86
+ onConnectionStatusChange: setConnectionStatus,
87
+ });
88
+
89
+ const { messages, sendMessage, status, stop } = chat;
90
+ ```
91
+
92
+ `chatId` is the Durable Object name — use `userId:uniqueChatId` (see [Providing userId](#providing-userid)).
93
+
94
+ > **Note:** React hooks are in a separate package. Install with `npm install @economic/agents-react`.
95
+
112
96
  ---
113
97
 
114
- ## `AIChatAgent`
98
+ ## Harnesses
99
+
100
+ The server-side API is built around Durable Object base classes — `Agent` for headless workflows and `ChatAgent`/`ChatAgentHarness` for conversational UIs — plus a skill system that lets the LLM load tools on demand.
101
+
102
+ ### ChatAgentHarness
115
103
 
116
- Extend this class and implement `onChatMessage`. Call `this.buildLLMParams()` to prepare the call, then pass the result to `streamText` or `generateText`.
104
+ The recommended starting point for chat agents. Extends `ChatAgent` with an opinionated structure: implement abstract methods for model selection, system prompt, tools, and skills. The harness handles `onChatMessage` for you.
117
105
 
118
106
  ```typescript
119
- import { streamText } from "ai";
120
- import { AIChatAgent } from "@economic/agents";
107
+ import { openai } from "@ai-sdk/openai";
108
+ import { ChatAgentHarness, type AgentToolContext, type Skill } from "@economic/agents";
121
109
 
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");
110
+ interface RequestBody {
111
+ userTier: "free" | "pro";
112
+ }
126
113
 
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();
114
+ export class MyAgent extends ChatAgentHarness<Env, RequestBody> {
115
+ getModel(ctx: AgentToolContext<RequestBody>) {
116
+ return ctx.userTier === "pro" ? openai("gpt-4o") : openai("gpt-4o-mini");
117
+ }
118
+
119
+ getFastModel() {
120
+ return openai("gpt-4o-mini");
121
+ }
122
+
123
+ getSystemPrompt(ctx: AgentToolContext<RequestBody>) {
124
+ return "You are a helpful assistant.";
125
+ }
126
+
127
+ getTools(ctx: AgentToolContext<RequestBody>) {
128
+ return { myTool };
129
+ }
130
+
131
+ getSkills(ctx: AgentToolContext<RequestBody>) {
132
+ return [searchSkill, calculatorSkill];
136
133
  }
137
134
  }
138
135
  ```
139
136
 
140
- ### `this.buildLLMParams(config)`
137
+ - `getModel(ctx)` — returns the primary language model. Context includes request body for tier-based model selection.
138
+ - `getFastModel()` — returns a fast/cheap model for compaction and conversation summarization.
139
+ - `getSystemPrompt(ctx)` — returns the system prompt.
140
+ - `getTools(ctx)` — returns always-on tools (optional, defaults to `{}`).
141
+ - `getSkills(ctx)` — returns skills available for on-demand loading (optional, defaults to `[]`).
142
+ - `conversationRetentionDays` — defaults to 90. Set to `undefined` to disable auto-deletion.
141
143
 
142
- Protected method on `AIChatAgent`. Wraps the standalone `buildLLMParams` function with:
144
+ #### Binding Name
143
145
 
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.
146
+ `ChatAgentHarness` automatically derives the Durable Object binding name from the class name. **The binding name in your `wrangler.jsonc` must exactly match your class name:**
150
147
 
151
- Config is everything accepted by the standalone `buildLLMParams` except `messages`, `activeSkills`, and `fastModel`.
148
+ ```typescript
149
+ // Class name is "MyAgent"
150
+ export class MyAgent extends ChatAgentHarness<Env> {
151
+ /* ... */
152
+ }
153
+ ```
152
154
 
153
- ### `guard`
155
+ ```jsonc
156
+ // wrangler.jsonc — binding name must be "MyAgent" to match
157
+ {
158
+ "durable_objects": {
159
+ "bindings": [{ "name": "MyAgent", "class_name": "MyAgent" }],
160
+ },
161
+ }
162
+ ```
163
+
164
+ If the names don't match, the harness won't be able to resolve the binding and will throw at runtime. If you need a different binding name, override the `binding` getter:
165
+
166
+ ````typescript
167
+ export class MyAgent extends ChatAgentHarness<Env> {
168
+ protected get binding() {
169
+ return this.env.CUSTOM_BINDING_NAME;
170
+ }
171
+ // ...
172
+ }
154
173
 
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).
174
+ ---
156
175
 
157
- - Return **`undefined` / nothing** — the decorated method runs as usual.
158
- - Return a **`Response`** — that response is returned immediately; `onChatMessage` is not called.
176
+ ### ChatAgent
159
177
 
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.
178
+ Lower-level base class for chat agents. Use when you need full control over `onChatMessage` custom streaming, multiple LLM calls per turn, or non-standard response formats.
161
179
 
162
180
  ```typescript
163
181
  import { streamText } from "ai";
164
182
  import { openai } from "@ai-sdk/openai";
165
- import { AIChatAgent, guard, type GuardFn } from "@economic/agents";
183
+ import { ChatAgent } from "@economic/agents";
166
184
 
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 });
185
+ export class MyAgent extends ChatAgent<Env> {
186
+ protected get binding() {
187
+ return this.env.MyAgent;
171
188
  }
172
- };
173
189
 
174
- export class ChatAgent extends AIChatAgent<Env> {
175
- protected fastModel = openai("gpt-4o-mini");
190
+ protected getFastModel() {
191
+ return openai("gpt-4o-mini");
192
+ }
176
193
 
177
- @guard(requireToken)
178
194
  async onChatMessage(onFinish, options) {
179
195
  const params = await this.buildLLMParams({
180
196
  options,
181
197
  onFinish,
182
198
  model: openai("gpt-4o"),
183
199
  system: "You are a helpful assistant.",
200
+ skills: [searchSkill],
201
+ tools: { alwaysOnTool },
184
202
  });
185
203
  return streamText(params).toUIMessageStreamResponse();
186
204
  }
187
205
  }
188
- ```
206
+ ````
189
207
 
190
- ### `fastModel` property
208
+ - `binding` — abstract getter returning the DO namespace binding. Required on every subclass.
209
+ - `getFastModel()` — abstract method returning the fast model for compaction and summarization.
210
+ - `maxMessagesBeforeCompaction` — class property to override the default threshold (15). Set to `undefined` to disable.
211
+ - `conversationRetentionDays` — class property to auto-delete inactive conversations after N days.
212
+ - `this.buildLLMParams()` — pre-fills `messages`, `activeSkills`, and injects `logEvent` into `experimental_context`.
213
+ - `getConversations()` / `deleteConversation(id)` — callable methods for listing/deleting a user's conversations.
191
214
 
192
- Override `fastModel` on your subclass to enable automatic compaction and future background conversation summarization:
215
+ ---
216
+
217
+ ### Agent
218
+
219
+ Abstract Durable Object base for non-chat agents. Use for headless workflows driven from HTTP handlers, schedules, or alarms.
193
220
 
194
221
  ```typescript
195
- export class MyAgent extends AIChatAgent<Env> {
196
- protected fastModel = openai("gpt-4o-mini");
197
- // ...
222
+ import { generateText } from "ai";
223
+ import { openai } from "@ai-sdk/openai";
224
+ import { callable } from "agents";
225
+ import { Agent } from "@economic/agents";
226
+
227
+ export class MyAgent extends Agent<Env> {
228
+ @callable
229
+ async summarize(document: string) {
230
+ const params = await this.buildLLMParams({
231
+ model: openai("gpt-4o"),
232
+ messages: [{ role: "user", content: `Summarise: ${document}` }],
233
+ system: "You are a helpful assistant.",
234
+ skills: [searchSkill],
235
+ });
236
+ const result = await generateText(params);
237
+ return result.text;
238
+ }
198
239
  }
199
240
  ```
200
241
 
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`.
242
+ - `this.buildLLMParams()` pre-fills `activeSkills` from DO SQLite and injects `logEvent` into `experimental_context`.
243
+ - `this.logEvent(message, payload?)` writes audit events to D1 when `AGENT_DB` is bound, silent no-op otherwise.
202
244
 
203
- When `fastModel` is `undefined` (the default), compaction is disabled regardless of `maxMessagesBeforeCompaction`.
245
+ ---
204
246
 
205
- ### `getConversations` (callable)
247
+ ### Tool context
206
248
 
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:
249
+ Pass data via the `body` option of `useAgentChat` (with `useAIChatAgent`, use `toolContext` it is forwarded as `body`). It arrives as `experimental_context` in tool `execute` functions. Use `AgentToolContext<TBody>` to type it:
208
250
 
209
251
  ```typescript
210
- const rows = await agent.call("getConversations");
211
- ```
212
-
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()`
252
+ import type { AgentToolContext } from "@economic/agents";
218
253
 
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:
254
+ interface AgentBody {
255
+ authorization: string;
256
+ userId: string;
257
+ }
224
258
 
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.
259
+ type ToolContext = AgentToolContext<AgentBody>;
230
260
 
231
- ### `onConnect` (automatic)
261
+ // Tool
262
+ execute: async (args, { experimental_context }) => {
263
+ const ctx = experimental_context as ToolContext;
264
+ await ctx.logEvent("tool called", { userId: ctx.userId });
265
+ return await fetchSomething(ctx.authorization);
266
+ };
267
+ ```
232
268
 
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.
269
+ `logEvent` is a no-op when `AGENT_DB` is not bound.
234
270
 
235
271
  ---
236
272
 
237
- ## `buildLLMParams` (standalone)
273
+ ### Source URLs from Tools
238
274
 
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.
275
+ Any tool can surface source URLs into the message stream by including a `sources` array in its return value. Detected automatically by `buildLLMParams` no additional wiring needed.
240
276
 
241
277
  ```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);
278
+ execute: async ({ query }) => {
279
+ const data = await fetchResults(query);
280
+ return {
281
+ results: data.results,
282
+ sources: data.results.map((r) => ({ url: r.url, title: r.title })),
283
+ };
284
+ };
258
285
  ```
259
286
 
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.
287
+ Each source entry: `{ url: string, title?: string }`.
279
288
 
280
289
  ---
281
290
 
282
- ## Defining skills
291
+ ### Skills
292
+
293
+ Named groups of tools loaded on demand by the LLM. The agent starts with only always-on tools active. When the LLM needs more, it calls `activate_skill`.
283
294
 
284
295
  ```typescript
285
296
  import { tool } from "ai";
286
297
  import { z } from "zod";
287
298
  import type { Skill } from "@economic/agents";
288
299
 
289
- // Skill with guidance — injected into the system prompt when the skill is loaded
290
300
  export const calculatorSkill: Skill = {
291
301
  name: "calculator",
292
302
  description: "Mathematical calculation and expression evaluation",
@@ -295,7 +305,7 @@ export const calculatorSkill: Skill = {
295
305
  "Always show the expression you are evaluating.",
296
306
  tools: {
297
307
  calculate: tool({
298
- description: "Evaluate a mathematical expression and return the result.",
308
+ description: "Evaluate a mathematical expression",
299
309
  inputSchema: z.object({
300
310
  expression: z.string().describe('e.g. "2 + 2", "Math.sqrt(144)"'),
301
311
  }),
@@ -306,343 +316,227 @@ export const calculatorSkill: Skill = {
306
316
  }),
307
317
  },
308
318
  };
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
319
  ```
330
320
 
331
- ### `Skill` fields
321
+ When `skills` are provided to `buildLLMParams`, two meta-tools are registered automatically:
332
322
 
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. |
323
+ - **`activate_skill`** loads skills by name, making their tools available for the rest of the conversation. Idempotent. State is persisted to DO SQLite.
324
+ - **`list_capabilities`** returns active tools, loaded skills, and skills available to load.
339
325
 
340
- ---
341
-
342
- ## Surfacing source URLs from tools
343
-
344
- Any tool can surface source URLs into the message stream by including a `sources` array in its return value. `buildLLMParams` automatically detects this and emits `source-url` stream parts that the playground's Sources block renders.
345
-
346
- ```typescript
347
- execute: async ({ query }) => {
348
- const data = await fetchResults(query);
349
- return {
350
- results: data.results, // LLM receives full content
351
- sources: data.results.map(r => ({ url: r.url, title: r.title })), // rendered as source links
352
- };
353
- },
354
- ```
355
-
356
- The `sources` array is picked up by a built-in `experimental_transform` inside `buildLLMParams` — no agent changes, no writer passed to the tool, no additional wiring. The LLM continues to receive the full result object including `sources`. The transform fires on every `tool-result` stream part and emits a `source` part for each entry.
357
-
358
- Each source entry shape:
359
-
360
- | Field | Type | Required | Description |
361
- | ------- | -------- | -------- | ----------------------- |
362
- | `url` | `string` | Yes | The URL to link to. |
363
- | `title` | `string` | No | Display name in the UI. |
364
-
365
- The playground's `UIMessageRenderer` collects all `source-url` parts from a message and displays them in a collapsible **Sources** block above the response text.
326
+ The `activate_skill` and `list_capabilities` meta-tools are stripped from message history before persistence.
366
327
 
367
328
  ---
368
329
 
369
- ## Compaction
330
+ ### Audit Logging (D1)
370
331
 
371
- When `fastModel` is set on the agent class, compaction runs automatically before each turn:
332
+ All agent base classes write audit events to a D1 database when `AGENT_DB` is bound. If not bound, `logEvent` is a no-op.
372
333
 
373
- 1. The message list is split into an older window and a recent verbatim tail.
374
- 2. `fastModel` generates a concise summary of the older window.
375
- 3. That summary + the verbatim tail is what gets sent to the LLM.
376
- 4. Full history in DO SQLite is unaffected — compaction is in-memory only.
334
+ #### D1 Setup
377
335
 
378
- ### Enabling compaction
336
+ 1. Create a D1 database in the Cloudflare dashboard.
337
+ 2. Run the schema in the D1 console. For `Agent`, use [`schema/agent.sql`](schema/agent.sql). For `ChatAgent`/`ChatAgentHarness`, use [`schema/chat.sql`](schema/chat.sql) (includes the conversations table).
338
+ 3. Bind it in `wrangler.jsonc`:
379
339
 
380
- Override `fastModel` on your subclass. Compaction runs automatically with a default threshold of 30 messages — no per-call config needed:
381
-
382
- ```typescript
383
- export class MyAgent extends AIChatAgent<Env> {
384
- protected fastModel = openai("gpt-4o-mini");
385
-
386
- async onChatMessage(onFinish, options) {
387
- const params = await this.buildLLMParams({
388
- options,
389
- onFinish,
390
- model: openai("gpt-4o"),
391
- system: "...",
392
- // No compaction config needed — runs automatically with default threshold
393
- });
394
- return streamText(params).toUIMessageStreamResponse();
395
- }
396
- }
340
+ ```jsonc
341
+ "d1_databases": [
342
+ { "binding": "AGENT_DB", "database_name": "agents", "database_id": "YOUR_DB_ID" }
343
+ ]
397
344
  ```
398
345
 
399
- ### Customising the threshold
346
+ 4. For local dev, apply the schema to your local D1 (from your app’s directory), e.g. `wrangler d1 execute <database_name> --local --file=node_modules/@economic/agents/schema/chat.sql`. You can wrap that in a `db:setup` npm script if you prefer.
400
347
 
401
- Pass `maxMessagesBeforeCompaction` to override the default of 30:
348
+ #### Providing userId
402
349
 
403
- ```typescript
404
- const params = await this.buildLLMParams({
405
- options,
406
- onFinish,
407
- model: openai("gpt-4o"),
408
- maxMessagesBeforeCompaction: 50, // keep last 50 messages verbatim
409
- });
410
- ```
411
-
412
- ### Disabling compaction
413
-
414
- Pass `maxMessagesBeforeCompaction: undefined` explicitly to disable compaction for that call, even when `fastModel` is set:
350
+ The client's `chatId` becomes the Durable Object name. Use `userId:uniqueChatId` so the first segment is your stable user id (audit and conversations key off `getUserId()`, i.e. the substring before the first `:`). If that segment is empty (e.g. `:chat-1`), the connection is rejected. Same idea as [Quick Start](#quick-start) (`chatId`).
415
351
 
416
352
  ```typescript
417
- const params = await this.buildLLMParams({
418
- options,
419
- onFinish,
420
- model: openai("gpt-4o"),
421
- maxMessagesBeforeCompaction: undefined, // compaction off
353
+ import { useAIChatAgent } from "@economic/agents-react";
354
+
355
+ const { agent, chat } = useAIChatAgent({
356
+ agent: "MyAgent",
357
+ host: "localhost:8787",
358
+ chatId: "148583_matt:conversation-1",
422
359
  });
423
360
  ```
424
361
 
425
- Compaction is always off when `fastModel` is `undefined` (the base class default).
426
-
427
362
  ---
428
363
 
429
- ## Built-in meta tools
430
-
431
- Two meta tools are automatically registered when `skills` are provided. You do not need to define or wire them.
432
-
433
- ### `activate_skill`
364
+ ### Chat Features
434
365
 
435
- 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.
366
+ Compaction and the conversation list (below) require `getFastModel()` on your subclass.
436
367
 
437
- - Loading is idempotent — calling for an already-loaded skill is a no-op.
438
- - The skills available are exactly those passed as `skills` — filter by request body to control access.
439
- - When skills are successfully loaded, the new state is embedded in the tool result. `persistMessages` extracts it and writes to DO SQLite.
440
- - All `activate_skill` messages are stripped from history before persistence — state is restored from DO SQLite, not from message history.
441
-
442
- ### `list_capabilities`
443
-
444
- Returns a summary of active tools, loaded skills, and skills available to load. Always stripped from history before persistence.
445
-
446
- ---
368
+ #### Compaction
447
369
 
448
- ## Passing request context to tools
449
-
450
- Pass arbitrary data via the `body` option of `useAgentChat`. It arrives as `experimental_context` in tool `execute` functions.
451
-
452
- 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:
370
+ Compaction summarises older messages before each turn. Full history in DO SQLite is unaffected — compaction is in-memory only. The default threshold is **15** recent messages (`maxMessagesBeforeCompaction` on the class).
453
371
 
454
372
  ```typescript
455
- // types.ts
456
- import type { AgentContext } from "@economic/agents";
373
+ export class MyAgent extends ChatAgentHarness<Env> {
374
+ getModel() {
375
+ return openai("gpt-4o");
376
+ }
457
377
 
458
- interface AgentBody {
459
- authorization: string;
460
- userId: string;
461
- }
378
+ getFastModel() {
379
+ return openai("gpt-4o-mini");
380
+ }
462
381
 
463
- export type ToolContext = AgentContext<AgentBody>;
464
- ```
382
+ getSystemPrompt() {
383
+ return "You are a helpful assistant.";
384
+ }
465
385
 
466
- ```typescript
467
- // Client
468
- useAgentChat({ body: { authorization: token, userId: "u_123" } });
386
+ // Optional: keep more messages verbatim before summarising (default 15).
387
+ // protected maxMessagesBeforeCompaction = 50;
469
388
 
470
- // Tool
471
- execute: async (args, { experimental_context }) => {
472
- const ctx = experimental_context as ToolContext;
473
- await ctx.log("tool called", { userId: ctx.userId });
474
- const data = await fetchSomething(ctx.authorization);
475
- return data;
476
- };
389
+ // Optional: disable compaction (still uses fastModel for conversation title/summary).
390
+ // protected maxMessagesBeforeCompaction = undefined;
391
+ }
477
392
  ```
478
393
 
479
- `log` is a no-op when `AGENT_DB` is not bound — so no changes are needed in tools when running without a D1 database.
480
-
481
- ---
482
-
483
- ## Audit logging — D1 setup
394
+ #### Conversations (D1)
484
395
 
485
- `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.
396
+ `ChatAgent` and `ChatAgentHarness` maintain a `conversations` table in `AGENT_DB`. One row per Durable Object instance, upserted automatically after every turn. Requires [`schema/chat.sql`](schema/chat.sql).
486
397
 
487
- ### 1. Create the D1 database
398
+ **Automatic title and summary** — On the first turn, title and summary are generated and inserted. On subsequent turns, only `updated_at` is refreshed. Title and summary are regenerated periodically as the conversation grows.
488
399
 
489
- In the [Cloudflare dashboard](https://dash.cloudflare.com) → **Workers & Pages** **D1** **Create database**. Note the database name and ID.
400
+ **Retention** Set `conversationRetentionDays` to auto-delete inactive conversations:
490
401
 
491
- ### 2. Create the schema
492
-
493
- 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:
402
+ ```typescript
403
+ export class MyAgent extends ChatAgentHarness<Env> {
404
+ getModel() {
405
+ return openai("gpt-4o");
406
+ }
407
+ getFastModel() {
408
+ return openai("gpt-4o-mini");
409
+ }
410
+ getSystemPrompt() {
411
+ return "You are a helpful assistant.";
412
+ }
494
413
 
495
- ```sql
496
- CREATE TABLE IF NOT EXISTS audit_events (
497
- id TEXT PRIMARY KEY,
498
- durable_object_id TEXT NOT NULL,
499
- user_id TEXT NOT NULL,
500
- message TEXT NOT NULL,
501
- payload TEXT,
502
- created_at TEXT NOT NULL
503
- );
504
- CREATE INDEX IF NOT EXISTS audit_events_user ON audit_events(user_id);
505
- CREATE INDEX IF NOT EXISTS audit_events_do ON audit_events(durable_object_id);
506
- CREATE INDEX IF NOT EXISTS audit_events_ts ON audit_events(created_at);
507
-
508
- CREATE TABLE IF NOT EXISTS conversations (
509
- durable_object_id TEXT PRIMARY KEY,
510
- user_id TEXT NOT NULL,
511
- title TEXT,
512
- summary TEXT,
513
- created_at TEXT NOT NULL,
514
- updated_at TEXT NOT NULL
515
- );
516
- CREATE INDEX IF NOT EXISTS conversations_user ON conversations(user_id);
517
- CREATE INDEX IF NOT EXISTS conversations_ts ON conversations(updated_at);
414
+ // ChatAgentHarness defaults to 90 days. Override or set to undefined to disable.
415
+ protected conversationRetentionDays = 30;
416
+ }
518
417
  ```
519
418
 
520
- Safe to re-run all statements use `IF NOT EXISTS`.
419
+ When the retention period expires, the D1 row is deleted, WebSocket connections are closed, and the DO's SQLite storage is wiped.
521
420
 
522
- ### 3. Bind it in `wrangler.jsonc`
421
+ **Querying** From a connected client:
523
422
 
524
- ```jsonc
525
- "d1_databases": [
526
- { "binding": "AGENT_DB", "database_name": "agents", "database_id": "YOUR_DB_ID" }
527
- ]
423
+ ```typescript
424
+ const conversations = await agent.call("getConversations");
528
425
  ```
529
426
 
530
- Then run `wrangler types` to regenerate the `Env` type.
531
-
532
- ### 4. Seed local development
533
-
534
- ```bash
535
- npm run db:setup
536
- ```
427
+ ---
537
428
 
538
- This runs the schema SQL against the local D1 SQLite file (`.wrangler/state/`). Re-running is harmless.
429
+ ## Hono
539
430
 
540
- If `AGENT_DB` is not bound, all `log()` calls are silent no-ops — the agent works without it.
431
+ Hono tooling is exported on `@economic/agents/hono`.
541
432
 
542
- ### Providing `userId`
433
+ ### JWT Auth Middleware
543
434
 
544
- 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`:
435
+ Bearer JWT verification middleware for Hono, imported from `@economic/agents/hono`. Verifies tokens via JWKS derived from the token's `iss` claim.
545
436
 
546
437
  ```typescript
547
- useAgentChat({
548
- agent,
549
- body: {
550
- userId: "148583_matt", // compose from agreement number + user identifier
551
- // ...other fields
552
- },
553
- });
438
+ import { jwtAuth } from "@economic/agents/hono";
439
+
440
+ app.use(
441
+ "/api/*",
442
+ jwtAuth({
443
+ allowedIssuers: ["https://login.example.com"],
444
+ audience: "my-api",
445
+ requiredScopes: ["read", "write"],
446
+ }),
447
+ );
554
448
  ```
555
449
 
556
- 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.
557
-
558
450
  ---
559
451
 
560
- ## Conversations — D1 setup
452
+ ## API Reference
561
453
 
562
- `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.
454
+ ### `@economic/agents`
563
455
 
564
- The `conversations` table is created by the same `schema/schema.sql` file used for audit events — no separate setup step needed.
456
+ | Export | Description |
457
+ | ---------------------- | -------------------------------------------------------------------------- |
458
+ | `Agent` | Abstract DO base for non-chat agents with audit logging and buildLLMParams |
459
+ | `ChatAgent` | Abstract chat DO with compaction, conversations, and custom onChatMessage |
460
+ | `ChatAgentHarness` | Opinionated chat harness with getModel/getSystemPrompt/getTools/getSkills |
461
+ | `buildLLMParams` | Standalone function to build streamText/generateText params |
462
+ | `Skill` | Type: named group of tools with optional guidance |
463
+ | `AgentToolContext` | Type: request body merged with `logEvent` for tool context |
464
+ | `OnChatMessageOptions` | Type: options passed to `onChatMessage` |
465
+ | `BuildLLMParamsConfig` | Type: config for standalone `buildLLMParams` |
565
466
 
566
- ### Upsert behaviour
467
+ ### `@economic/agents-react`
567
468
 
568
- - **First turn**: `AIChatAgent` generates `title` and `summary` first, then inserts the row with `created_at` and `updated_at` both set to now and `title`/`summary` already populated.
569
- - **Subsequent turns**: the upsert only refreshes `updated_at`. `created_at`, `title`, and `summary` are preserved by the upsert path.
570
- - Every `SUMMARY_CONTEXT_MESSAGES` messages, `AIChatAgent` separately re-generates `title` and `summary` and writes them back without changing `created_at`.
469
+ React hooks are in a separate package. See [`@economic/agents-react`](../react/README.md) for full documentation.
571
470
 
572
- ### Automatic title and summary generation
471
+ | Export | Description |
472
+ | ----------------------- | ------------------------------------------------------------------------------------------------------------------------- |
473
+ | `useAIChatAgent` | React hook wrapping `useAgent` + `useAgentChat` |
474
+ | `UseAIChatAgentOptions` | Type: options for `useAIChatAgent` (`agent`, `host`, `chatId`, optional `basePath`, `toolContext`, `connectionParams`, …) |
475
+ | `AgentConnectionStatus` | Type: `"connecting" \| "connected" \| "disconnected" \| "unauthorized"` |
573
476
 
574
- On the first persisted turn, `AIChatAgent` generates a title and summary from the current conversation and inserts them into the new D1 row.
477
+ ### `@economic/agents/hono`
575
478
 
576
- On later turns, it always refreshes `updated_at`, and it re-generates the title/summary every `SUMMARY_CONTEXT_MESSAGES` messages using the latest window plus the previous summary.
479
+ | Export | Description |
480
+ | --------------- | ------------------------------------------- |
481
+ | `jwtAuth` | Hono middleware for Bearer JWT verification |
482
+ | `JwtAuthConfig` | Type: config for `jwtAuth` |
577
483
 
578
- No subclass code is needed — this runs automatically when `AGENT_DB` is bound and `fastModel` is set on the class.
484
+ ### CLI
579
485
 
580
- ### Automatic conversation retention
486
+ | Command | Description |
487
+ | -------------------------------------------- | ---------------------------------------- |
488
+ | `npx @economic/agents generate skill <name>` | Scaffold a new skill with tools |
489
+ | `npx @economic/agents generate tool <name>` | Scaffold a new tool (global or in skill) |
581
490
 
582
- Set `conversationRetentionDays` on your subclass to automatically delete inactive conversations after that many days:
491
+ ---
583
492
 
584
- ```typescript
585
- export class MyAgent extends AIChatAgent<Env> {
586
- protected fastModel = openai("gpt-4o-mini");
587
- protected conversationRetentionDays = 90;
588
- }
589
- ```
493
+ ## CLI
590
494
 
591
- After each persisted turn, the base class resets a per-conversation scheduled callback on the Durable Object. When it fires, the callback:
495
+ The package includes a CLI for scaffolding skills and tools.
592
496
 
593
- 1. Deletes the matching row from the D1 `conversations` table.
594
- 2. Closes any active WebSocket connections for that conversation.
595
- 3. Wipes the Durable Object's SQLite storage with `deleteAll()`.
497
+ ### Generate a Skill
596
498
 
597
- If `conversationRetentionDays` is `undefined`, retention cleanup is disabled and old conversation URLs stay resumable indefinitely.
499
+ ```bash
500
+ npx @economic/agents generate skill weather
501
+ ```
598
502
 
599
- ### Querying conversation lists
503
+ This will:
600
504
 
601
- From a connected agent client, prefer the built-in callable (see **`getConversations` (callable)** under [`AIChatAgent`](#aichatagent)): `await agent.call("getConversations")`.
505
+ 1. Prompt for a skill description
506
+ 2. Ask for initial tool names (comma-separated)
507
+ 3. Prompt for each tool's description and whether it needs `AgentToolContext`
508
+ 4. Create the skill file at `src/skills/weather/weather.ts`
509
+ 5. Create tool files at `src/skills/weather/tools/*.ts`
510
+ 6. Auto-register the skill in your agent's `getSkills()` method
602
511
 
603
- To query D1 directly (same logic as the callable), filter by `durable_object_name` prefix — one row per chat, keyed as `userId:chatId`:
512
+ ### Generate a Tool
604
513
 
605
- ```sql
606
- SELECT durable_object_name, title, summary, created_at, updated_at
607
- FROM conversations
608
- WHERE durable_object_name LIKE '148583_matt:%'
609
- ORDER BY updated_at DESC;
514
+ ```bash
515
+ npx @economic/agents generate tool geocode
610
516
  ```
611
517
 
612
- 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.
613
-
614
- ---
615
-
616
- ## API reference
617
-
618
- ### Classes
518
+ This will:
619
519
 
620
- | Export | Description |
621
- | ------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
622
- | `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. |
520
+ 1. Prompt for a tool description
521
+ 2. Ask where to create it (global `src/tools/` or within an existing skill)
522
+ 3. Ask whether it needs `AgentToolContext`
523
+ 4. Create the tool file
524
+ 5. Auto-register the tool in your agent's `getTools()` or the skill's `tools` object
623
525
 
624
- ### Functions
526
+ ### Auto-registration
625
527
 
626
- | Export | Signature | Description |
627
- | ---------------- | -------------------------------------- | ------------------------------------------------------------------------------------------------------ |
628
- | `guard` | `(guardFn: GuardFn)` | Method decorator: runs `guardFn` with `options.body`; a returned `Response` short-circuits the method. |
629
- | `buildLLMParams` | `async (config) => Promise<LLMParams>` | Builds the full parameter object for `streamText` or `generateText`. |
528
+ The CLI automatically detects agent files by scanning `src/` for classes extending `ChatAgentHarness`, `ChatAgent`, or `Agent` from `@economic/agents`. If one agent is found, it's used automatically. If multiple are found, you'll be prompted to select one.
630
529
 
631
- ### Types
530
+ For `ChatAgentHarness`, the CLI modifies `getSkills()` or `getTools()` methods. For `ChatAgent` or `Agent`, it modifies `buildLLMParams()` calls.
632
531
 
633
- | Export | Description |
634
- | ---------------------- | ---------------------------------------------------------------------------------------------------------------- |
635
- | `GuardFn` | `(body) => Response \| void \| Promise<...>`. Receives chat request `body`; return `Response` to block the turn. |
636
- | `Skill` | A named group of tools with optional guidance. |
637
- | `AgentContext<TBody>` | Request body type merged with `log`. Use as the type of `experimental_context`. |
638
- | `BuildLLMParamsConfig` | Config type for the standalone `buildLLMParams` function. |
532
+ If the CLI detects complex patterns (spread operators, function calls, variables), it will print manual registration instructions instead.
639
533
 
640
534
  ---
641
535
 
642
536
  ## Development
643
537
 
644
538
  ```bash
645
- npm install # install dependencies
646
- npm test # run tests
647
- npm pack # build
539
+ npm install
540
+ npm test
541
+ npm pack
648
542
  ```