@agentuity/runtime 0.0.60 → 0.0.62

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.
Files changed (61) hide show
  1. package/dist/_context.d.ts +11 -7
  2. package/dist/_context.d.ts.map +1 -1
  3. package/dist/_context.js +9 -2
  4. package/dist/_context.js.map +1 -1
  5. package/dist/_server.d.ts +4 -2
  6. package/dist/_server.d.ts.map +1 -1
  7. package/dist/_server.js +79 -31
  8. package/dist/_server.js.map +1 -1
  9. package/dist/_services.d.ts +1 -1
  10. package/dist/_services.d.ts.map +1 -1
  11. package/dist/_services.js +4 -2
  12. package/dist/_services.js.map +1 -1
  13. package/dist/_waituntil.d.ts.map +1 -1
  14. package/dist/_waituntil.js +5 -2
  15. package/dist/_waituntil.js.map +1 -1
  16. package/dist/agent.d.ts +647 -19
  17. package/dist/agent.d.ts.map +1 -1
  18. package/dist/agent.js +55 -6
  19. package/dist/agent.js.map +1 -1
  20. package/dist/app.d.ts +205 -28
  21. package/dist/app.d.ts.map +1 -1
  22. package/dist/app.js +181 -13
  23. package/dist/app.js.map +1 -1
  24. package/dist/index.d.ts +41 -2
  25. package/dist/index.d.ts.map +1 -1
  26. package/dist/index.js +2 -2
  27. package/dist/index.js.map +1 -1
  28. package/dist/io/email.d.ts.map +1 -1
  29. package/dist/io/email.js +11 -3
  30. package/dist/io/email.js.map +1 -1
  31. package/dist/router.d.ts +282 -32
  32. package/dist/router.d.ts.map +1 -1
  33. package/dist/router.js +110 -35
  34. package/dist/router.js.map +1 -1
  35. package/dist/services/evalrun/http.d.ts.map +1 -1
  36. package/dist/services/evalrun/http.js +7 -5
  37. package/dist/services/evalrun/http.js.map +1 -1
  38. package/dist/services/local/_util.d.ts.map +1 -1
  39. package/dist/services/local/_util.js +3 -1
  40. package/dist/services/local/_util.js.map +1 -1
  41. package/dist/services/session/http.d.ts.map +1 -1
  42. package/dist/services/session/http.js +4 -3
  43. package/dist/services/session/http.js.map +1 -1
  44. package/dist/session.d.ts +308 -6
  45. package/dist/session.d.ts.map +1 -1
  46. package/dist/session.js +331 -23
  47. package/dist/session.js.map +1 -1
  48. package/package.json +8 -5
  49. package/src/_context.ts +37 -9
  50. package/src/_server.ts +96 -36
  51. package/src/_services.ts +9 -2
  52. package/src/_waituntil.ts +13 -2
  53. package/src/agent.ts +856 -68
  54. package/src/app.ts +238 -38
  55. package/src/index.ts +42 -2
  56. package/src/io/email.ts +23 -5
  57. package/src/router.ts +359 -83
  58. package/src/services/evalrun/http.ts +15 -4
  59. package/src/services/local/_util.ts +7 -1
  60. package/src/services/session/http.ts +5 -2
  61. package/src/session.ts +686 -26
package/src/session.ts CHANGED
@@ -3,6 +3,9 @@
3
3
  import type { Context } from 'hono';
4
4
  import { getCookie, setCookie } from 'hono/cookie';
5
5
  import { type Env, fireEvent } from './app';
6
+ import type { AppState } from './index';
7
+ import { getServiceUrls } from '@agentuity/server';
8
+ import { WebSocket } from 'ws';
6
9
 
7
10
  export type ThreadEventName = 'destroyed';
8
11
  export type SessionEventName = 'completed';
@@ -17,44 +20,342 @@ type SessionEventCallback<T extends Session> = (
17
20
  session: T
18
21
  ) => Promise<void> | void;
19
22
 
23
+ /**
24
+ * Represents a conversation thread that persists across multiple sessions.
25
+ * Threads maintain state and can contain multiple request-response sessions.
26
+ *
27
+ * Threads are automatically managed by the runtime and stored in cookies.
28
+ * They expire after 1 hour of inactivity by default.
29
+ *
30
+ * @example
31
+ * ```typescript
32
+ * // Access thread in agent handler
33
+ * const agent = createAgent({
34
+ * handler: async (ctx, input) => {
35
+ * // Get thread ID
36
+ * console.log('Thread:', ctx.thread.id);
37
+ *
38
+ * // Store data in thread state (persists across sessions)
39
+ * ctx.thread.state.set('conversationCount',
40
+ * (ctx.thread.state.get('conversationCount') as number || 0) + 1
41
+ * );
42
+ *
43
+ * // Listen for thread destruction
44
+ * ctx.thread.addEventListener('destroyed', (eventName, thread) => {
45
+ * console.log('Thread destroyed:', thread.id);
46
+ * });
47
+ *
48
+ * return 'Response';
49
+ * }
50
+ * });
51
+ * ```
52
+ */
20
53
  export interface Thread {
54
+ /**
55
+ * Unique thread identifier (e.g., "thrd_a1b2c3d4...").
56
+ * Stored in cookie and persists across requests.
57
+ */
21
58
  id: string;
59
+
60
+ /**
61
+ * Thread-scoped state storage that persists across multiple sessions.
62
+ * Use this to maintain conversation history or user preferences.
63
+ *
64
+ * @example
65
+ * ```typescript
66
+ * // Store conversation count
67
+ * ctx.thread.state.set('messageCount',
68
+ * (ctx.thread.state.get('messageCount') as number || 0) + 1
69
+ * );
70
+ * ```
71
+ */
22
72
  state: Map<string, unknown>;
73
+
74
+ /**
75
+ * Register an event listener for when the thread is destroyed.
76
+ * Thread is destroyed when it expires or is manually destroyed.
77
+ *
78
+ * @param eventName - Must be 'destroyed'
79
+ * @param callback - Function called when thread is destroyed
80
+ *
81
+ * @example
82
+ * ```typescript
83
+ * ctx.thread.addEventListener('destroyed', (eventName, thread) => {
84
+ * console.log('Cleaning up thread:', thread.id);
85
+ * });
86
+ * ```
87
+ */
23
88
  addEventListener(
24
89
  eventName: 'destroyed',
25
90
  callback: (eventName: 'destroyed', thread: Thread) => Promise<void> | void
26
91
  ): void;
92
+
93
+ /**
94
+ * Remove a previously registered 'destroyed' event listener.
95
+ *
96
+ * @param eventName - Must be 'destroyed'
97
+ * @param callback - The callback function to remove
98
+ */
27
99
  removeEventListener(
28
100
  eventName: 'destroyed',
29
101
  callback: (eventName: 'destroyed', thread: Thread) => Promise<void> | void
30
102
  ): void;
103
+
104
+ /**
105
+ * Manually destroy the thread and clean up resources.
106
+ * Fires the 'destroyed' event and removes thread from storage.
107
+ *
108
+ * @example
109
+ * ```typescript
110
+ * // End conversation
111
+ * await ctx.thread.destroy();
112
+ * ```
113
+ */
31
114
  destroy(): Promise<void>;
32
115
  }
33
116
 
117
+ /**
118
+ * Represents a single request-response session within a thread.
119
+ * Sessions are scoped to a single agent execution and its sub-agent calls.
120
+ *
121
+ * Each HTTP request creates a new session with a unique ID, but shares the same thread.
122
+ *
123
+ * @example
124
+ * ```typescript
125
+ * const agent = createAgent({
126
+ * handler: async (ctx, input) => {
127
+ * // Get session ID (unique per request)
128
+ * console.log('Session:', ctx.session.id);
129
+ *
130
+ * // Store data in session state (only for this request)
131
+ * ctx.session.state.set('startTime', Date.now());
132
+ *
133
+ * // Access parent thread
134
+ * console.log('Thread:', ctx.session.thread.id);
135
+ *
136
+ * // Listen for session completion
137
+ * ctx.session.addEventListener('completed', (eventName, session) => {
138
+ * const duration = Date.now() - (session.state.get('startTime') as number);
139
+ * console.log(`Session completed in ${duration}ms`);
140
+ * });
141
+ *
142
+ * return 'Response';
143
+ * }
144
+ * });
145
+ * ```
146
+ */
34
147
  export interface Session {
148
+ /**
149
+ * Unique session identifier for this request.
150
+ * Changes with each HTTP request, even within the same thread.
151
+ */
35
152
  id: string;
153
+
154
+ /**
155
+ * The parent thread this session belongs to.
156
+ * Multiple sessions can share the same thread.
157
+ */
36
158
  thread: Thread;
159
+
160
+ /**
161
+ * Session-scoped state storage that only exists for this request.
162
+ * Use this for temporary data that shouldn't persist across requests.
163
+ *
164
+ * @example
165
+ * ```typescript
166
+ * ctx.session.state.set('requestStartTime', Date.now());
167
+ * ```
168
+ */
37
169
  state: Map<string, unknown>;
170
+
171
+ /**
172
+ * Register an event listener for when the session completes.
173
+ * Fired after the agent handler returns and response is sent.
174
+ *
175
+ * @param eventName - Must be 'completed'
176
+ * @param callback - Function called when session completes
177
+ *
178
+ * @example
179
+ * ```typescript
180
+ * ctx.session.addEventListener('completed', (eventName, session) => {
181
+ * console.log('Session finished:', session.id);
182
+ * });
183
+ * ```
184
+ */
38
185
  addEventListener(
39
186
  eventName: 'completed',
40
187
  callback: (eventName: 'completed', session: Session) => Promise<void> | void
41
188
  ): void;
189
+
190
+ /**
191
+ * Remove a previously registered 'completed' event listener.
192
+ *
193
+ * @param eventName - Must be 'completed'
194
+ * @param callback - The callback function to remove
195
+ */
42
196
  removeEventListener(
43
197
  eventName: 'completed',
44
198
  callback: (eventName: 'completed', session: Session) => Promise<void> | void
45
199
  ): void;
200
+
201
+ /**
202
+ * Return the session data as a serializable string or return undefined if not
203
+ * data should be serialized.
204
+ */
205
+ serializeUserData(): string | undefined;
46
206
  }
47
207
 
208
+ /**
209
+ * Provider interface for managing thread lifecycle and persistence.
210
+ * Implement this to customize how threads are stored and retrieved.
211
+ *
212
+ * The default implementation (DefaultThreadProvider) stores threads in-memory
213
+ * with cookie-based identification and 1-hour expiration.
214
+ *
215
+ * @example
216
+ * ```typescript
217
+ * class RedisThreadProvider implements ThreadProvider {
218
+ * private redis: Redis;
219
+ *
220
+ * async initialize(appState: AppState): Promise<void> {
221
+ * this.redis = await connectRedis();
222
+ * }
223
+ *
224
+ * async restore(ctx: Context<Env>): Promise<Thread> {
225
+ * const threadId = getCookie(ctx, 'atid') || generateId('thrd');
226
+ * const data = await this.redis.get(`thread:${threadId}`);
227
+ * const thread = new DefaultThread(this, threadId);
228
+ * if (data) {
229
+ * thread.state = new Map(JSON.parse(data));
230
+ * }
231
+ * return thread;
232
+ * }
233
+ *
234
+ * async save(thread: Thread): Promise<void> {
235
+ * await this.redis.setex(
236
+ * `thread:${thread.id}`,
237
+ * 3600,
238
+ * JSON.stringify([...thread.state])
239
+ * );
240
+ * }
241
+ *
242
+ * async destroy(thread: Thread): Promise<void> {
243
+ * await this.redis.del(`thread:${thread.id}`);
244
+ * }
245
+ * }
246
+ *
247
+ * // Use custom provider
248
+ * const app = await createApp({
249
+ * services: {
250
+ * thread: new RedisThreadProvider()
251
+ * }
252
+ * });
253
+ * ```
254
+ */
48
255
  export interface ThreadProvider {
49
- initialize(): Promise<void>;
256
+ /**
257
+ * Initialize the provider when the app starts.
258
+ * Use this to set up connections, start cleanup intervals, etc.
259
+ *
260
+ * @param appState - The app state from createApp setup function
261
+ */
262
+ initialize(appState: AppState): Promise<void>;
263
+
264
+ /**
265
+ * Restore or create a thread from the HTTP request context.
266
+ * Should check cookies for existing thread ID or create a new one.
267
+ *
268
+ * @param ctx - Hono request context
269
+ * @returns The restored or newly created thread
270
+ */
50
271
  restore(ctx: Context<Env>): Promise<Thread>;
272
+
273
+ /**
274
+ * Persist thread state to storage.
275
+ * Called periodically to save thread data.
276
+ *
277
+ * @param thread - The thread to save
278
+ */
51
279
  save(thread: Thread): Promise<void>;
280
+
281
+ /**
282
+ * Destroy a thread and clean up resources.
283
+ * Should fire the 'destroyed' event and remove from storage.
284
+ *
285
+ * @param thread - The thread to destroy
286
+ */
52
287
  destroy(thread: Thread): Promise<void>;
53
288
  }
54
289
 
290
+ /**
291
+ * Provider interface for managing session lifecycle and persistence.
292
+ * Implement this to customize how sessions are stored and retrieved.
293
+ *
294
+ * The default implementation (DefaultSessionProvider) stores sessions in-memory
295
+ * and automatically cleans them up after completion.
296
+ *
297
+ * @example
298
+ * ```typescript
299
+ * class PostgresSessionProvider implements SessionProvider {
300
+ * private db: Database;
301
+ *
302
+ * async initialize(appState: AppState): Promise<void> {
303
+ * this.db = appState.db;
304
+ * }
305
+ *
306
+ * async restore(thread: Thread, sessionId: string): Promise<Session> {
307
+ * const row = await this.db.query(
308
+ * 'SELECT state FROM sessions WHERE id = $1',
309
+ * [sessionId]
310
+ * );
311
+ * const session = new DefaultSession(thread, sessionId);
312
+ * if (row) {
313
+ * session.state = new Map(JSON.parse(row.state));
314
+ * }
315
+ * return session;
316
+ * }
317
+ *
318
+ * async save(session: Session): Promise<void> {
319
+ * await this.db.query(
320
+ * 'INSERT INTO sessions (id, thread_id, state) VALUES ($1, $2, $3)',
321
+ * [session.id, session.thread.id, JSON.stringify([...session.state])]
322
+ * );
323
+ * }
324
+ * }
325
+ *
326
+ * // Use custom provider
327
+ * const app = await createApp({
328
+ * services: {
329
+ * session: new PostgresSessionProvider()
330
+ * }
331
+ * });
332
+ * ```
333
+ */
55
334
  export interface SessionProvider {
56
- initialize(): Promise<void>;
335
+ /**
336
+ * Initialize the provider when the app starts.
337
+ * Use this to set up database connections or other resources.
338
+ *
339
+ * @param appState - The app state from createApp setup function
340
+ */
341
+ initialize(appState: AppState): Promise<void>;
342
+
343
+ /**
344
+ * Restore or create a session for the given thread and session ID.
345
+ * Should load existing session data or create a new session.
346
+ *
347
+ * @param thread - The parent thread for this session
348
+ * @param sessionId - The unique session identifier
349
+ * @returns The restored or newly created session
350
+ */
57
351
  restore(thread: Thread, sessionId: string): Promise<Session>;
352
+
353
+ /**
354
+ * Persist session state and fire completion events.
355
+ * Called after the agent handler completes.
356
+ *
357
+ * @param session - The session to save
358
+ */
58
359
  save(session: Session): Promise<void>;
59
360
  }
60
361
 
@@ -103,15 +404,17 @@ export function generateId(prefix?: string): string {
103
404
 
104
405
  export class DefaultThread implements Thread {
105
406
  #lastUsed: number;
407
+ #initialStateJson: string | undefined;
106
408
  readonly id: string;
107
409
  readonly state: Map<string, unknown>;
108
410
  private provider: ThreadProvider;
109
411
 
110
- constructor(provider: ThreadProvider, id: string) {
412
+ constructor(provider: ThreadProvider, id: string, initialStateJson?: string) {
111
413
  this.provider = provider;
112
414
  this.id = id;
113
415
  this.state = new Map();
114
416
  this.#lastUsed = Date.now();
417
+ this.#initialStateJson = initialStateJson;
115
418
  }
116
419
 
117
420
  addEventListener(eventName: ThreadEventName, callback: ThreadEventCallback<any>): void {
@@ -141,7 +444,7 @@ export class DefaultThread implements Thread {
141
444
  }
142
445
 
143
446
  async destroy(): Promise<void> {
144
- this.provider.destroy(this);
447
+ await this.provider.destroy(this);
145
448
  }
146
449
 
147
450
  touch() {
@@ -151,6 +454,28 @@ export class DefaultThread implements Thread {
151
454
  expired() {
152
455
  return Date.now() - this.#lastUsed >= 3.6e6; // 1 hour
153
456
  }
457
+
458
+ /**
459
+ * Check if thread state has been modified since restore
460
+ * @internal
461
+ */
462
+ isDirty(): boolean {
463
+ if (this.state.size === 0 && !this.#initialStateJson) {
464
+ return false;
465
+ }
466
+
467
+ const currentJson = JSON.stringify(Object.fromEntries(this.state));
468
+
469
+ return currentJson !== this.#initialStateJson;
470
+ }
471
+
472
+ /**
473
+ * Get serialized state for saving
474
+ * @internal
475
+ */
476
+ getSerializedState(): string {
477
+ return JSON.stringify(Object.fromEntries(this.state));
478
+ }
154
479
  }
155
480
 
156
481
  export class DefaultSession implements Session {
@@ -189,25 +514,305 @@ export class DefaultSession implements Session {
189
514
  async fireEvent(eventName: SessionEventName): Promise<void> {
190
515
  await fireSessionEvent(this, eventName);
191
516
  }
517
+
518
+ /**
519
+ * Serialize session state to JSON string for persistence.
520
+ * Returns undefined if state is empty or exceeds 1MB limit.
521
+ * @internal
522
+ */
523
+ serializeUserData(): string | undefined {
524
+ if (this.state.size === 0) {
525
+ return undefined;
526
+ }
527
+
528
+ try {
529
+ const obj = Object.fromEntries(this.state);
530
+ const json = JSON.stringify(obj);
531
+
532
+ // Check 1MB limit (1,048,576 bytes)
533
+ const sizeInBytes = new TextEncoder().encode(json).length;
534
+ if (sizeInBytes > 1048576) {
535
+ console.error(
536
+ `Session ${this.id} user_data exceeds 1MB limit (${sizeInBytes} bytes), data will not be persisted`
537
+ );
538
+ return undefined;
539
+ }
540
+
541
+ return json;
542
+ } catch (err) {
543
+ console.error(`Failed to serialize session ${this.id} user_data:`, err);
544
+ return undefined;
545
+ }
546
+ }
192
547
  }
193
548
 
194
- export class DefaultThreadProvider implements ThreadProvider {
195
- private threads = new Map<string, DefaultThread>();
196
-
197
- async initialize(): Promise<void> {
198
- setInterval(() => {
199
- for (const [, thread] of this.threads) {
200
- if (thread.expired()) {
201
- void (async () => {
202
- try {
203
- await this.destroy(thread);
204
- } catch (err) {
205
- console.error('Failed to destroy expired thread', err);
549
+ /**
550
+ * WebSocket client for thread state persistence
551
+ * @internal
552
+ */
553
+ class ThreadWebSocketClient {
554
+ private ws: WebSocket | null = null;
555
+ private authenticated = false;
556
+ private pendingRequests = new Map<
557
+ string,
558
+ { resolve: (data?: string) => void; reject: (err: Error) => void }
559
+ >();
560
+ private requestCounter = 0;
561
+ private reconnectAttempts = 0;
562
+ private maxReconnectAttempts = 5;
563
+ private apiKey: string;
564
+ private wsUrl: string;
565
+ private wsConnecting: Promise<void> | null = null;
566
+
567
+ constructor(apiKey: string, wsUrl: string) {
568
+ this.apiKey = apiKey;
569
+ this.wsUrl = wsUrl;
570
+ }
571
+
572
+ async connect(): Promise<void> {
573
+ return new Promise((resolve, reject) => {
574
+ // Set connection timeout
575
+ const connectionTimeout = setTimeout(() => {
576
+ this.cleanup();
577
+ reject(new Error('WebSocket connection timeout (10s)'));
578
+ }, 10000);
579
+
580
+ try {
581
+ this.ws = new WebSocket(this.wsUrl);
582
+
583
+ this.ws.on('open', () => {
584
+ // Send authentication (do NOT clear timeout yet - wait for auth response)
585
+ this.ws?.send(JSON.stringify({ authorization: this.apiKey }));
586
+ });
587
+
588
+ this.ws.on('message', (data: any) => {
589
+ try {
590
+ const message = JSON.parse(data.toString());
591
+
592
+ // Handle auth response
593
+ if ('success' in message && !this.authenticated) {
594
+ clearTimeout(connectionTimeout);
595
+ if (message.success) {
596
+ this.authenticated = true;
597
+ this.reconnectAttempts = 0;
598
+ resolve();
599
+ } else {
600
+ const err = new Error(
601
+ `WebSocket authentication failed: ${message.error || 'Unknown error'}`
602
+ );
603
+ this.cleanup();
604
+ reject(err);
605
+ }
606
+ return;
206
607
  }
207
- })();
208
- }
608
+
609
+ // Handle action response
610
+ if ('id' in message && this.pendingRequests.has(message.id)) {
611
+ const pending = this.pendingRequests.get(message.id)!;
612
+ this.pendingRequests.delete(message.id);
613
+
614
+ if (message.success) {
615
+ pending.resolve(message.data);
616
+ } else {
617
+ pending.reject(new Error(message.error || 'Request failed'));
618
+ }
619
+ }
620
+ } catch {
621
+ // Ignore parse errors
622
+ }
623
+ });
624
+
625
+ this.ws.on('error', (err: Error) => {
626
+ clearTimeout(connectionTimeout);
627
+ if (!this.authenticated) {
628
+ reject(new Error(`WebSocket error: ${err.message}`));
629
+ }
630
+ });
631
+
632
+ this.ws.on('close', () => {
633
+ clearTimeout(connectionTimeout);
634
+ const wasAuthenticated = this.authenticated;
635
+ this.authenticated = false;
636
+
637
+ // Reject all pending requests
638
+ for (const [id, pending] of this.pendingRequests) {
639
+ pending.reject(new Error('WebSocket connection closed'));
640
+ this.pendingRequests.delete(id);
641
+ }
642
+
643
+ // Reject connecting promise if still pending
644
+ if (!wasAuthenticated) {
645
+ reject(new Error('WebSocket closed before authentication'));
646
+ }
647
+
648
+ // Attempt reconnection only if we were previously authenticated
649
+ if (wasAuthenticated && this.reconnectAttempts < this.maxReconnectAttempts) {
650
+ this.reconnectAttempts++;
651
+ const delay = Math.min(1000 * Math.pow(2, this.reconnectAttempts), 30000);
652
+
653
+ // Schedule reconnection with backoff delay
654
+ setTimeout(() => {
655
+ // Create new connection promise for reconnection
656
+ this.wsConnecting = this.connect().catch(() => {
657
+ // Reconnection failed, reset
658
+ this.wsConnecting = null;
659
+ });
660
+ }, delay);
661
+ }
662
+ });
663
+ } catch (err) {
664
+ clearTimeout(connectionTimeout);
665
+ reject(err);
209
666
  }
210
- }, 60_000).unref();
667
+ });
668
+ }
669
+
670
+ async restore(threadId: string): Promise<string | undefined> {
671
+ // Wait for connection/reconnection if in progress
672
+ if (this.wsConnecting) {
673
+ await this.wsConnecting;
674
+ }
675
+
676
+ if (!this.authenticated || !this.ws) {
677
+ throw new Error('WebSocket not connected or authenticated');
678
+ }
679
+
680
+ return new Promise((resolve, reject) => {
681
+ const requestId = `req_${++this.requestCounter}`;
682
+ this.pendingRequests.set(requestId, { resolve, reject });
683
+
684
+ const message = {
685
+ id: requestId,
686
+ action: 'restore',
687
+ data: { thread_id: threadId },
688
+ };
689
+
690
+ this.ws!.send(JSON.stringify(message));
691
+
692
+ // Timeout after 10 seconds
693
+ setTimeout(() => {
694
+ if (this.pendingRequests.has(requestId)) {
695
+ this.pendingRequests.delete(requestId);
696
+ reject(new Error('Request timeout'));
697
+ }
698
+ }, 10000);
699
+ });
700
+ }
701
+
702
+ async save(threadId: string, userData: string): Promise<void> {
703
+ // Wait for connection/reconnection if in progress
704
+ if (this.wsConnecting) {
705
+ await this.wsConnecting;
706
+ }
707
+
708
+ if (!this.authenticated || !this.ws) {
709
+ throw new Error('WebSocket not connected or authenticated');
710
+ }
711
+
712
+ // Check 1MB limit
713
+ const sizeInBytes = new TextEncoder().encode(userData).length;
714
+ if (sizeInBytes > 1048576) {
715
+ console.error(
716
+ `Thread ${threadId} user_data exceeds 1MB limit (${sizeInBytes} bytes), data will not be persisted`
717
+ );
718
+ return;
719
+ }
720
+
721
+ return new Promise((resolve, reject) => {
722
+ const requestId = crypto.randomUUID();
723
+ this.pendingRequests.set(requestId, {
724
+ resolve: () => resolve(),
725
+ reject,
726
+ });
727
+
728
+ const message = {
729
+ id: requestId,
730
+ action: 'save',
731
+ data: { thread_id: threadId, user_data: userData },
732
+ };
733
+
734
+ this.ws!.send(JSON.stringify(message));
735
+
736
+ // Timeout after 10 seconds
737
+ setTimeout(() => {
738
+ if (this.pendingRequests.has(requestId)) {
739
+ this.pendingRequests.delete(requestId);
740
+ reject(new Error('Request timeout'));
741
+ }
742
+ }, 10_000);
743
+ });
744
+ }
745
+
746
+ async delete(threadId: string): Promise<void> {
747
+ // Wait for connection/reconnection if in progress
748
+ if (this.wsConnecting) {
749
+ await this.wsConnecting;
750
+ }
751
+
752
+ if (!this.authenticated || !this.ws) {
753
+ throw new Error('WebSocket not connected or authenticated');
754
+ }
755
+
756
+ return new Promise((resolve, reject) => {
757
+ const requestId = crypto.randomUUID();
758
+ this.pendingRequests.set(requestId, {
759
+ resolve: () => resolve(),
760
+ reject,
761
+ });
762
+
763
+ const message = {
764
+ id: requestId,
765
+ action: 'delete',
766
+ data: { thread_id: threadId },
767
+ };
768
+
769
+ this.ws!.send(JSON.stringify(message));
770
+
771
+ // Timeout after 10 seconds
772
+ setTimeout(() => {
773
+ if (this.pendingRequests.has(requestId)) {
774
+ this.pendingRequests.delete(requestId);
775
+ reject(new Error('Request timeout'));
776
+ }
777
+ }, 10_000);
778
+ });
779
+ }
780
+
781
+ cleanup(): void {
782
+ if (this.ws) {
783
+ this.ws.close();
784
+ this.ws = null;
785
+ }
786
+ this.authenticated = false;
787
+ this.pendingRequests.clear();
788
+ }
789
+ }
790
+
791
+ export class DefaultThreadProvider implements ThreadProvider {
792
+ private wsClient: ThreadWebSocketClient | null = null;
793
+ private wsConnecting: Promise<void> | null = null;
794
+
795
+ async initialize(_appState: AppState): Promise<void> {
796
+ // Initialize WebSocket connection for thread persistence (async, non-blocking)
797
+ const apiKey = process.env.AGENTUITY_SDK_KEY;
798
+ if (apiKey) {
799
+ const serviceUrls = getServiceUrls();
800
+ const catalystUrl = serviceUrls.catalyst;
801
+ const wsUrl = new URL('/thread/ws', catalystUrl.replace(/^http/, 'ws'));
802
+
803
+ this.wsClient = new ThreadWebSocketClient(apiKey, wsUrl.toString());
804
+ // Connect in background, don't block initialization
805
+ this.wsConnecting = this.wsClient
806
+ .connect()
807
+ .then(() => {
808
+ this.wsConnecting = null;
809
+ })
810
+ .catch((err) => {
811
+ console.error('Failed to connect to thread WebSocket:', err);
812
+ this.wsClient = null;
813
+ this.wsConnecting = null;
814
+ });
815
+ }
211
816
  }
212
817
 
213
818
  async restore(ctx: Context<Env>): Promise<Thread> {
@@ -222,14 +827,40 @@ export class DefaultThreadProvider implements ThreadProvider {
222
827
 
223
828
  if (threadId) {
224
829
  setCookie(ctx, 'atid', threadId);
225
- const existing = this.threads.get(threadId);
226
- if (existing) {
227
- return existing;
830
+ }
831
+
832
+ // Wait for WebSocket connection if still connecting
833
+ if (this.wsConnecting) {
834
+ await this.wsConnecting;
835
+ }
836
+
837
+ // Restore thread state from WebSocket if available
838
+ let initialStateJson: string | undefined;
839
+ if (this.wsClient) {
840
+ try {
841
+ const restoredData = await this.wsClient.restore(threadId);
842
+ if (restoredData) {
843
+ initialStateJson = restoredData;
844
+ }
845
+ } catch {
846
+ // Continue with empty state rather than failing
847
+ }
848
+ }
849
+
850
+ const thread = new DefaultThread(this, threadId, initialStateJson);
851
+
852
+ // Populate thread state from restored data
853
+ if (initialStateJson) {
854
+ try {
855
+ const data = JSON.parse(initialStateJson);
856
+ for (const [key, value] of Object.entries(data)) {
857
+ thread.state.set(key, value);
858
+ }
859
+ } catch {
860
+ // Continue with empty state if parsing fails
228
861
  }
229
862
  }
230
863
 
231
- const thread = new DefaultThread(this, threadId);
232
- this.threads.set(thread.id, thread);
233
864
  await fireEvent('thread.created', thread);
234
865
  return thread;
235
866
  }
@@ -237,16 +868,45 @@ export class DefaultThreadProvider implements ThreadProvider {
237
868
  async save(thread: Thread): Promise<void> {
238
869
  if (thread instanceof DefaultThread) {
239
870
  thread.touch();
871
+
872
+ // Wait for WebSocket connection if still connecting
873
+ if (this.wsConnecting) {
874
+ await this.wsConnecting;
875
+ }
876
+
877
+ // Only save to WebSocket if state has changed
878
+ if (this.wsClient && thread.isDirty()) {
879
+ try {
880
+ const serialized = thread.getSerializedState();
881
+ await this.wsClient.save(thread.id, serialized);
882
+ } catch {
883
+ // Don't throw - allow request to complete even if save fails
884
+ }
885
+ }
240
886
  }
241
887
  }
242
888
 
243
889
  async destroy(thread: Thread): Promise<void> {
244
890
  if (thread instanceof DefaultThread) {
245
891
  try {
892
+ // Wait for WebSocket connection if still connecting
893
+ if (this.wsConnecting) {
894
+ await this.wsConnecting;
895
+ }
896
+
897
+ // Delete thread from remote storage
898
+ if (this.wsClient) {
899
+ try {
900
+ await this.wsClient.delete(thread.id);
901
+ } catch (err) {
902
+ console.error(`Failed to delete thread ${thread.id} from remote storage:`, err);
903
+ // Continue with local cleanup even if remote delete fails
904
+ }
905
+ }
906
+
246
907
  await thread.fireEvent('destroyed');
247
908
  await fireEvent('thread.destroyed', thread);
248
909
  } finally {
249
- this.threads.delete(thread.id);
250
910
  threadEventListeners.delete(thread);
251
911
  }
252
912
  }
@@ -256,7 +916,7 @@ export class DefaultThreadProvider implements ThreadProvider {
256
916
  export class DefaultSessionProvider implements SessionProvider {
257
917
  private sessions = new Map<string, DefaultSession>();
258
918
 
259
- async initialize(): Promise<void> {
919
+ async initialize(_appState: AppState): Promise<void> {
260
920
  // No initialization needed for in-memory provider
261
921
  }
262
922