@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.
- package/dist/_context.d.ts +11 -7
- package/dist/_context.d.ts.map +1 -1
- package/dist/_context.js +9 -2
- package/dist/_context.js.map +1 -1
- package/dist/_server.d.ts +4 -2
- package/dist/_server.d.ts.map +1 -1
- package/dist/_server.js +79 -31
- package/dist/_server.js.map +1 -1
- package/dist/_services.d.ts +1 -1
- package/dist/_services.d.ts.map +1 -1
- package/dist/_services.js +4 -2
- package/dist/_services.js.map +1 -1
- package/dist/_waituntil.d.ts.map +1 -1
- package/dist/_waituntil.js +5 -2
- package/dist/_waituntil.js.map +1 -1
- package/dist/agent.d.ts +647 -19
- package/dist/agent.d.ts.map +1 -1
- package/dist/agent.js +55 -6
- package/dist/agent.js.map +1 -1
- package/dist/app.d.ts +205 -28
- package/dist/app.d.ts.map +1 -1
- package/dist/app.js +181 -13
- package/dist/app.js.map +1 -1
- package/dist/index.d.ts +41 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -2
- package/dist/index.js.map +1 -1
- package/dist/io/email.d.ts.map +1 -1
- package/dist/io/email.js +11 -3
- package/dist/io/email.js.map +1 -1
- package/dist/router.d.ts +282 -32
- package/dist/router.d.ts.map +1 -1
- package/dist/router.js +110 -35
- package/dist/router.js.map +1 -1
- package/dist/services/evalrun/http.d.ts.map +1 -1
- package/dist/services/evalrun/http.js +7 -5
- package/dist/services/evalrun/http.js.map +1 -1
- package/dist/services/local/_util.d.ts.map +1 -1
- package/dist/services/local/_util.js +3 -1
- package/dist/services/local/_util.js.map +1 -1
- package/dist/services/session/http.d.ts.map +1 -1
- package/dist/services/session/http.js +4 -3
- package/dist/services/session/http.js.map +1 -1
- package/dist/session.d.ts +308 -6
- package/dist/session.d.ts.map +1 -1
- package/dist/session.js +331 -23
- package/dist/session.js.map +1 -1
- package/package.json +8 -5
- package/src/_context.ts +37 -9
- package/src/_server.ts +96 -36
- package/src/_services.ts +9 -2
- package/src/_waituntil.ts +13 -2
- package/src/agent.ts +856 -68
- package/src/app.ts +238 -38
- package/src/index.ts +42 -2
- package/src/io/email.ts +23 -5
- package/src/router.ts +359 -83
- package/src/services/evalrun/http.ts +15 -4
- package/src/services/local/_util.ts +7 -1
- package/src/services/session/http.ts +5 -2
- 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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
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
|
-
}
|
|
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
|
-
|
|
226
|
-
|
|
227
|
-
|
|
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
|
|