@economic/agents 2.1.4 → 2.1.6

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/dist/index.d.mts CHANGED
@@ -135,6 +135,10 @@ interface MessageRating {
135
135
  * `@economic/agents` inside `onChatMessage`.
136
136
  */
137
137
  declare abstract class ChatAgent<Env extends Cloudflare.Env = Cloudflare.Env, TUserContext extends Record<string, unknown> = Record<string, unknown>> extends AIChatAgent<Env & ChatAgentEnv> {
138
+ initialState: {
139
+ status: string;
140
+ type: string;
141
+ };
138
142
  /**
139
143
  * The binding of the Durable Object instance for this agent.
140
144
  */
@@ -196,6 +200,8 @@ declare abstract class ChatAgent<Env extends Cloudflare.Env = Cloudflare.Env, TU
196
200
  * Returns the user ID from the durable object name.
197
201
  */
198
202
  protected getUserId(): string;
203
+ onStart(): void;
204
+ onClose(): Promise<void>;
199
205
  onConnect(connection: Connection, ctx: ConnectionContext): Promise<void>;
200
206
  protected _pendingUserContextRequest?: Promise<void>;
201
207
  /**
@@ -217,6 +223,17 @@ declare abstract class ChatAgent<Env extends Cloudflare.Env = Cloudflare.Env, TU
217
223
  rateMessage(messageId: string, rating: number, comment?: string): Promise<void>;
218
224
  getMessageRatings(): Promise<Record<string, MessageRating>>;
219
225
  getConversations(): Promise<Record<string, unknown>[]>;
226
+ /**
227
+ * Exports this conversation's persisted message history for the v1 -> v2
228
+ * migration. Called over DO RPC by the v2 `Assistant` while migrating a
229
+ * user's chats into facets. `this.messages` is loaded from the
230
+ * `cf_ai_chat_agent_messages` table when the DO wakes.
231
+ *
232
+ * Read-only: does not mutate or delete any state.
233
+ */
234
+ exportForMigration(): Promise<{
235
+ messages: UIMessage[];
236
+ }>;
220
237
  deleteConversation(id: string): Promise<boolean>;
221
238
  destroy(): Promise<void>;
222
239
  private deleteConversationCallback;
package/dist/index.mjs CHANGED
@@ -667,6 +667,10 @@ async function getMessageRatings(db, durableObjectName) {
667
667
  * `@economic/agents` inside `onChatMessage`.
668
668
  */
669
669
  var ChatAgent = class extends AIChatAgent {
670
+ initialState = {
671
+ status: "connecting",
672
+ type: "chat"
673
+ };
670
674
  /**
671
675
  * Number of days of inactivity before the full conversation is deleted.
672
676
  *
@@ -697,6 +701,18 @@ var ChatAgent = class extends AIChatAgent {
697
701
  getUserId() {
698
702
  return this.name.split(":")[0];
699
703
  }
704
+ onStart() {
705
+ this.setState({
706
+ ...this.initialState,
707
+ status: "connecting"
708
+ });
709
+ }
710
+ async onClose() {
711
+ this.setState({
712
+ type: "chat",
713
+ status: "disconnected"
714
+ });
715
+ }
700
716
  async onConnect(connection, ctx) {
701
717
  this.clientIp = ctx.request.headers.get("CF-Connecting-IP") ?? ctx.request.headers.get("X-Forwarded-For")?.split(",")[0]?.trim();
702
718
  this.forwardedFor = ctx.request.headers.get("X-Forwarded-For") ?? void 0;
@@ -725,10 +741,18 @@ var ChatAgent = class extends AIChatAgent {
725
741
  } catch (error) {
726
742
  console.error("[Agent] JWT verification error", error);
727
743
  connection.close(4001, "Unauthorized");
744
+ this.setState({
745
+ type: "chat",
746
+ status: "unauthorized"
747
+ });
728
748
  return;
729
749
  }
730
750
  if (!result.success) {
731
751
  connection.close(result.status === 401 ? 4001 : 4003, result.message);
752
+ this.setState({
753
+ type: "chat",
754
+ status: "unauthorized"
755
+ });
732
756
  return;
733
757
  }
734
758
  }
@@ -737,6 +761,10 @@ var ChatAgent = class extends AIChatAgent {
737
761
  if (userContext) connection.setState({ userContext });
738
762
  });
739
763
  }
764
+ this.setState({
765
+ type: "chat",
766
+ status: "connected"
767
+ });
740
768
  return super.onConnect(connection, ctx);
741
769
  }
742
770
  _pendingUserContextRequest;
@@ -809,6 +837,17 @@ var ChatAgent = class extends AIChatAgent {
809
837
  @callable({ description: "Returns all conversations for the current user" }) async getConversations() {
810
838
  return getConversations(this.env.AGENT_DB, this.getUserId());
811
839
  }
840
+ /**
841
+ * Exports this conversation's persisted message history for the v1 -> v2
842
+ * migration. Called over DO RPC by the v2 `Assistant` while migrating a
843
+ * user's chats into facets. `this.messages` is loaded from the
844
+ * `cf_ai_chat_agent_messages` table when the DO wakes.
845
+ *
846
+ * Read-only: does not mutate or delete any state.
847
+ */
848
+ async exportForMigration() {
849
+ return { messages: this.messages };
850
+ }
812
851
  @callable({ description: "Delete a conversation by its id" }) async deleteConversation(id) {
813
852
  if (!id.startsWith(`${this.getUserId()}:`)) {
814
853
  console.error("[Agent] Failed to delete conversation: Not owned by current user", {
package/dist/v2.d.mts CHANGED
@@ -18,6 +18,7 @@ interface AgentEnv {
18
18
  AGENTS_ANALYTICS: AnalyticsEngineDataset;
19
19
  SKILLS_BUCKET: R2Bucket;
20
20
  }
21
+ type SqlFn = InstanceType<typeof Agent$1>["sql"];
21
22
  type AgentConnectionStatus = "connecting" | "connected" | "disconnected" | "unauthorized";
22
23
  type AgentConnectionType = "agent" | "chat" | "assistant";
23
24
  type AgentConnectionState = {
@@ -126,6 +127,56 @@ type MessageFeedback = {
126
127
  updated_at: number;
127
128
  };
128
129
  //#endregion
130
+ //#region src/server/v2/features/migration.d.ts
131
+ /** Per-message feedback carried over from a v1 conversation during migration. */
132
+ type LegacyMessageFeedback = {
133
+ messageId: string;
134
+ rating: number;
135
+ comment?: string;
136
+ };
137
+ /** Minimal RPC surface of a v1 chat DO needed to read its history. */
138
+ interface LegacyChatStub {
139
+ exportForMigration(): Promise<{
140
+ messages: UIMessage[];
141
+ }>;
142
+ }
143
+ /** Minimal RPC surface of a freshly created v2 chat facet needed to seed history. */
144
+ interface FacetStub {
145
+ importLegacyMessages(messages: UIMessage[], feedback: LegacyMessageFeedback[]): Promise<void>;
146
+ }
147
+ /**
148
+ * Dependencies for {@link migrateUserFromV1}, injected by the `Assistant` so
149
+ * this module never needs to touch the DO's protected members directly.
150
+ */
151
+ type MigrationDeps = {
152
+ /** The `Assistant` DO's bound `sql` tag (used to register migrated chats). */sql: SqlFn; /** Shared D1 database holding v1 `conversations` and `message_ratings`. */
153
+ db: D1Database; /** The user being migrated (the `Assistant` DO name). */
154
+ userId: string; /** Resolves a v1 chat DO stub by its `userId:chatId` name. */
155
+ legacyNamespace: {
156
+ getByName(name: string): LegacyChatStub;
157
+ }; /** Creates (or resolves) a v2 chat facet for the given chat id and returns its stub. */
158
+ createFacet: (chatId: string) => Promise<FacetStub>;
159
+ };
160
+ /**
161
+ * Migrates all of a user's v1 chats into v2 facets, lazily and idempotently.
162
+ *
163
+ * v1 chats are enumerated from the shared D1 `conversations` table (DOs cannot
164
+ * be listed directly). For each chat we read its persisted messages from the v1
165
+ * DO over RPC, create a fresh facet, seed its history and feedback, register it
166
+ * on the parent `Assistant` with its original title/summary/timestamps, and
167
+ * finally delete the v1 `conversations` (+ `message_ratings`) rows.
168
+ *
169
+ * The conversations row IS the to-do marker: deleting it last means a migrated
170
+ * chat never reappears, while a chat that errored keeps its row and is simply
171
+ * retried on the next connection. No extra bookkeeping tables are needed. The
172
+ * v1 DO is left untouched and self-expires via v1 retention, so its messages
173
+ * remain as a backstop until then.
174
+ */
175
+ declare function migrateUserFromV1(deps: MigrationDeps): Promise<{
176
+ migrated: number;
177
+ failed: number;
178
+ }>;
179
+ //#endregion
129
180
  //#region src/server/v2/agents/ChatAgent.d.ts
130
181
  declare abstract class ChatAgent<RequestContext extends Record<string, unknown> = Record<string, unknown>, UserContext extends Record<string, unknown> = Record<string, unknown>> extends Agent<RequestContext, UserContext> {
131
182
  initialState: AgentConnectionState;
@@ -144,6 +195,19 @@ declare abstract class ChatAgent<RequestContext extends Record<string, unknown>
144
195
  * @returns All message feedback for the current chat.
145
196
  */
146
197
  getMessageFeedback(): Promise<Record<string, MessageFeedback>>;
198
+ /**
199
+ * Imports a v1 conversation's history into this facet's session storage.
200
+ *
201
+ * Called over DO RPC by the v2 `Assistant` during the lazy v1 -> v2
202
+ * migration. Messages are appended in order as a single linear thread
203
+ * (each message parented to the previous one) using
204
+ * `appendMessageToHistory`, which writes durably to the session WITHOUT
205
+ * triggering a model turn. Any carried-over feedback is then written to
206
+ * `assistant_messages_feedback`.
207
+ *
208
+ * Safe to skip persisting if there is nothing to import.
209
+ */
210
+ importLegacyMessages(messages: UIMessage[], feedback?: LegacyMessageFeedback[]): Promise<void>;
147
211
  }
148
212
  //#endregion
149
213
  //#region src/server/v2/features/chats.d.ts
@@ -161,9 +225,26 @@ declare abstract class Assistant extends Agent$1<Cloudflare.Env, AgentConnection
161
225
  initialState: AgentConnectionState;
162
226
  protected abstract agent: SubAgentClass<ChatAgent>;
163
227
  protected abstract fastModel: LanguageModel;
228
+ /**
229
+ * Binding name of the legacy v1 chat Durable Object class, used to migrate a
230
+ * user's v1 chats into facets the first time they connect. Set this on the
231
+ * concrete subclass to enable lazy v1 -> v2 migration; leave undefined to
232
+ * disable it (e.g. for greenfield deployments with no v1 data).
233
+ */
234
+ protected legacyBinding?: keyof Cloudflare.Env;
235
+ /** In-flight migration, shared across concurrent connections to this DO. */
236
+ private _migrationPromise?;
164
237
  onStart(): void;
165
238
  onClose(): Promise<void>;
166
239
  onConnect(connection: Connection, ctx: ConnectionContext): Promise<void>;
240
+ /**
241
+ * Runs the lazy v1 -> v2 migration for this user. Concurrent connections to
242
+ * this DO share a single in-flight run. Idempotency across runs/restarts is
243
+ * handled by `migrateUserFromV1` deleting each chat's v1 `conversations` row,
244
+ * so an already-migrated chat is never re-enumerated.
245
+ */
246
+ private ensureMigrated;
247
+ private runMigration;
167
248
  createChat(): Promise<string>;
168
249
  deleteChat(id: string): Promise<void>;
169
250
  getChats(): Promise<Chat[]>;
@@ -172,4 +253,4 @@ declare abstract class Assistant extends Agent$1<Cloudflare.Env, AgentConnection
172
253
  private scheduleChatForAutoDeletion;
173
254
  }
174
255
  //#endregion
175
- export { Agent, type AgentConnectionState, type AgentConnectionStatus, type AgentConnectionType, type AgentEnv, Assistant, ChatAgent, type Skill, type Tool, type ToolContext, type ToolSet, getCurrentToolContext, skill, tool };
256
+ export { Agent, type AgentConnectionState, type AgentConnectionStatus, type AgentConnectionType, type AgentEnv, Assistant, ChatAgent, type FacetStub, type LegacyChatStub, type LegacyMessageFeedback, type MigrationDeps, type Skill, type Tool, type ToolContext, type ToolSet, getCurrentToolContext, migrateUserFromV1, skill, tool };
package/dist/v2.mjs CHANGED
@@ -297,6 +297,21 @@ function registerChat(sql, durableObjectName, dateTime) {
297
297
  sql`INSERT INTO chats (durable_object_name, created_at, updated_at)
298
298
  VALUES (${durableObjectName}, ${dateTime}, ${dateTime})`;
299
299
  }
300
+ /**
301
+ * Registers a chat while preserving metadata carried over from a v1
302
+ * conversation during migration: its title, summary, and original
303
+ * timestamps. Used so migrated chats keep their titles instead of waiting
304
+ * for the AI summariser to regenerate them.
305
+ *
306
+ * Idempotent: an existing row for the same chat id is left untouched.
307
+ */
308
+ function registerChatWithMetadata(sql, durableObjectName, metadata) {
309
+ const { title, summary, createdAt } = metadata;
310
+ const updatedAt = metadata.updatedAt ?? createdAt;
311
+ sql`INSERT INTO chats (durable_object_name, title, summary, created_at, updated_at)
312
+ VALUES (${durableObjectName}, ${title ?? null}, ${summary ?? null}, ${createdAt}, ${updatedAt})
313
+ ON CONFLICT (durable_object_name) DO NOTHING`;
314
+ }
300
315
  function deleteChat(sql, durableObjectName) {
301
316
  sql`DELETE FROM chats WHERE durable_object_name = ${durableObjectName}`;
302
317
  }
@@ -368,12 +383,99 @@ function getChatRetentionMs(days) {
368
383
  return Math.floor(days * 24 * 60 * 60 * 1e3);
369
384
  }
370
385
  //#endregion
386
+ //#region src/server/v2/features/migration.ts
387
+ async function listLegacyConversations(db, userId) {
388
+ const { results } = await db.prepare(`SELECT durable_object_name, title, summary, created_at, updated_at
389
+ FROM conversations WHERE durable_object_name LIKE ? ORDER BY updated_at ASC`).bind(`${userId}:%`).all();
390
+ return results ?? [];
391
+ }
392
+ async function listLegacyFeedback(db, durableObjectName) {
393
+ const { results } = await db.prepare(`SELECT message_id, rating, comment FROM message_ratings WHERE durable_object_name = ?`).bind(durableObjectName).all();
394
+ return (results ?? []).map((row) => ({
395
+ messageId: row.message_id,
396
+ rating: row.rating,
397
+ ...row.comment != null ? { comment: row.comment } : {}
398
+ }));
399
+ }
400
+ /**
401
+ * Removes a migrated v1 chat's D1 rows. Deleting the `conversations` row is what
402
+ * makes the migration idempotent: it drops out of the enumeration so it is never
403
+ * migrated again. The v1 DO itself is left to self-expire via v1 retention.
404
+ */
405
+ async function deleteLegacyConversation(db, durableObjectName) {
406
+ await db.prepare(`DELETE FROM conversations WHERE durable_object_name = ?`).bind(durableObjectName).run();
407
+ await db.prepare(`DELETE FROM message_ratings WHERE durable_object_name = ?`).bind(durableObjectName).run();
408
+ }
409
+ function toEpochMs(value) {
410
+ if (!value) return Date.now();
411
+ const parsed = Date.parse(value);
412
+ return Number.isNaN(parsed) ? Date.now() : parsed;
413
+ }
414
+ /**
415
+ * Migrates all of a user's v1 chats into v2 facets, lazily and idempotently.
416
+ *
417
+ * v1 chats are enumerated from the shared D1 `conversations` table (DOs cannot
418
+ * be listed directly). For each chat we read its persisted messages from the v1
419
+ * DO over RPC, create a fresh facet, seed its history and feedback, register it
420
+ * on the parent `Assistant` with its original title/summary/timestamps, and
421
+ * finally delete the v1 `conversations` (+ `message_ratings`) rows.
422
+ *
423
+ * The conversations row IS the to-do marker: deleting it last means a migrated
424
+ * chat never reappears, while a chat that errored keeps its row and is simply
425
+ * retried on the next connection. No extra bookkeeping tables are needed. The
426
+ * v1 DO is left untouched and self-expires via v1 retention, so its messages
427
+ * remain as a backstop until then.
428
+ */
429
+ async function migrateUserFromV1(deps) {
430
+ const { sql, db, userId, legacyNamespace, createFacet } = deps;
431
+ const conversations = await listLegacyConversations(db, userId);
432
+ let migrated = 0;
433
+ let failed = 0;
434
+ for (const conversation of conversations) {
435
+ const legacyName = conversation.durable_object_name;
436
+ try {
437
+ const { messages } = await legacyNamespace.getByName(legacyName).exportForMigration();
438
+ const feedback = await listLegacyFeedback(db, legacyName);
439
+ const newChatId = nanoid();
440
+ const facet = await createFacet(newChatId);
441
+ if (messages.length > 0) await facet.importLegacyMessages(messages, feedback);
442
+ registerChatWithMetadata(sql, newChatId, {
443
+ title: conversation.title ?? void 0,
444
+ summary: conversation.summary ?? void 0,
445
+ createdAt: toEpochMs(conversation.created_at),
446
+ updatedAt: toEpochMs(conversation.updated_at)
447
+ });
448
+ await deleteLegacyConversation(db, legacyName);
449
+ migrated++;
450
+ } catch (error) {
451
+ failed++;
452
+ console.error("[Migration] Failed to migrate v1 chat", {
453
+ legacyName,
454
+ error
455
+ });
456
+ }
457
+ }
458
+ return {
459
+ migrated,
460
+ failed
461
+ };
462
+ }
463
+ //#endregion
371
464
  //#region src/server/v2/agents/Assistant.ts
372
465
  var Assistant = class extends Agent$1 {
373
466
  initialState = {
374
467
  status: "connecting",
375
468
  type: "assistant"
376
469
  };
470
+ /**
471
+ * Binding name of the legacy v1 chat Durable Object class, used to migrate a
472
+ * user's v1 chats into facets the first time they connect. Set this on the
473
+ * concrete subclass to enable lazy v1 -> v2 migration; leave undefined to
474
+ * disable it (e.g. for greenfield deployments with no v1 data).
475
+ */
476
+ legacyBinding;
477
+ /** In-flight migration, shared across concurrent connections to this DO. */
478
+ _migrationPromise;
377
479
  onStart() {
378
480
  this.setState({
379
481
  ...this.initialState,
@@ -423,12 +525,53 @@ var Assistant = class extends Agent$1 {
423
525
  });
424
526
  }
425
527
  }
528
+ await this.ensureMigrated();
426
529
  this.setState({
427
530
  ...this.initialState,
428
531
  status: "connected",
429
532
  subAgentName: this.agent.name
430
533
  });
431
534
  }
535
+ /**
536
+ * Runs the lazy v1 -> v2 migration for this user. Concurrent connections to
537
+ * this DO share a single in-flight run. Idempotency across runs/restarts is
538
+ * handled by `migrateUserFromV1` deleting each chat's v1 `conversations` row,
539
+ * so an already-migrated chat is never re-enumerated.
540
+ */
541
+ async ensureMigrated() {
542
+ if (!this.legacyBinding) return;
543
+ this._migrationPromise ??= this.runMigration().finally(() => {
544
+ this._migrationPromise = void 0;
545
+ });
546
+ await this._migrationPromise;
547
+ }
548
+ async runMigration() {
549
+ const legacyNamespace = this.env[this.legacyBinding];
550
+ if (!legacyNamespace?.getByName) {
551
+ console.error("[Assistant] Migration skipped: legacy binding not found", { legacyBinding: this.legacyBinding });
552
+ return;
553
+ }
554
+ try {
555
+ const result = await migrateUserFromV1({
556
+ sql: this.sql.bind(this),
557
+ db: this.env.AGENT_DB,
558
+ userId: this.name,
559
+ legacyNamespace,
560
+ createFacet: async (chatId) => {
561
+ return await this.subAgent(this.agent, chatId);
562
+ }
563
+ });
564
+ if (result.migrated > 0 || result.failed > 0) console.info("[Assistant] v1 -> v2 migration complete", {
565
+ userId: this.name,
566
+ ...result
567
+ });
568
+ } catch (error) {
569
+ console.error("[Assistant] v1 -> v2 migration failed", {
570
+ userId: this.name,
571
+ error
572
+ });
573
+ }
574
+ }
432
575
  @callable() async createChat() {
433
576
  const id = nanoid();
434
577
  const now = Date.now();
@@ -549,6 +692,26 @@ var ChatAgent = class extends Agent {
549
692
  @callable({ description: "Returns all message feedback for the current chat" }) async getMessageFeedback() {
550
693
  return getMessageFeedback(this.sql.bind(this));
551
694
  }
695
+ /**
696
+ * Imports a v1 conversation's history into this facet's session storage.
697
+ *
698
+ * Called over DO RPC by the v2 `Assistant` during the lazy v1 -> v2
699
+ * migration. Messages are appended in order as a single linear thread
700
+ * (each message parented to the previous one) using
701
+ * `appendMessageToHistory`, which writes durably to the session WITHOUT
702
+ * triggering a model turn. Any carried-over feedback is then written to
703
+ * `assistant_messages_feedback`.
704
+ *
705
+ * Safe to skip persisting if there is nothing to import.
706
+ */
707
+ async importLegacyMessages(messages, feedback = []) {
708
+ let parentId = null;
709
+ for (const message of messages) {
710
+ await this.appendMessageToHistory(message, parentId);
711
+ parentId = message.id;
712
+ }
713
+ for (const item of feedback) submitMessageFeedback(this.sql.bind(this), item.messageId, item.rating, item.comment);
714
+ }
552
715
  };
553
716
  //#endregion
554
717
  //#region src/server/v2/util/skills.ts
@@ -559,4 +722,4 @@ function skill(definition) {
559
722
  return definition;
560
723
  }
561
724
  //#endregion
562
- export { Agent, Assistant, ChatAgent, getCurrentToolContext, skill, tool };
725
+ export { Agent, Assistant, ChatAgent, getCurrentToolContext, migrateUserFromV1, skill, tool };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@economic/agents",
3
- "version": "2.1.4",
3
+ "version": "2.1.6",
4
4
  "description": "A starter for creating a TypeScript package.",
5
5
  "license": "MIT",
6
6
  "bin": {
@@ -27,7 +27,10 @@
27
27
  },
28
28
  "dependencies": {
29
29
  "@clack/prompts": "^1.2.0",
30
+ "@cloudflare/ai-chat": ">=0.7.2 <1.0.0",
31
+ "@cloudflare/think": ">=0.7.3 <1.0.0",
30
32
  "@opentelemetry/sdk-trace-base": "^2.7.1",
33
+ "agents": ">=0.13.3 <1.0.0",
31
34
  "nanoid": "^5.1.11"
32
35
  },
33
36
  "devDependencies": {
@@ -44,9 +47,6 @@
44
47
  "vitest": "^4.1.4"
45
48
  },
46
49
  "peerDependencies": {
47
- "@cloudflare/ai-chat": ">=0.7.2 <1.0.0",
48
- "@cloudflare/think": ">=0.7.3 <1.0.0",
49
- "agents": ">=0.13.3 <1.0.0",
50
50
  "ai": "^6.0.0",
51
51
  "jose": "^6.0.0"
52
52
  },