@agentuity/runtime 0.0.61 → 0.0.63
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/_server.js +12 -4
- package/dist/_server.js.map +1 -1
- package/dist/agent.d.ts.map +1 -1
- package/dist/agent.js +1 -7
- package/dist/agent.js.map +1 -1
- package/dist/session.d.ts +68 -13
- package/dist/session.d.ts.map +1 -1
- package/dist/session.js +365 -36
- package/dist/session.js.map +1 -1
- package/package.json +6 -4
- package/src/_server.ts +12 -4
- package/src/agent.ts +1 -7
- package/src/session.ts +469 -44
package/src/session.ts
CHANGED
|
@@ -4,6 +4,8 @@ import type { Context } from 'hono';
|
|
|
4
4
|
import { getCookie, setCookie } from 'hono/cookie';
|
|
5
5
|
import { type Env, fireEvent } from './app';
|
|
6
6
|
import type { AppState } from './index';
|
|
7
|
+
import { getServiceUrls } from '@agentuity/server';
|
|
8
|
+
import { WebSocket } from 'ws';
|
|
7
9
|
|
|
8
10
|
export type ThreadEventName = 'destroyed';
|
|
9
11
|
export type SessionEventName = 'completed';
|
|
@@ -31,7 +33,7 @@ type SessionEventCallback<T extends Session> = (
|
|
|
31
33
|
* const agent = createAgent({
|
|
32
34
|
* handler: async (ctx, input) => {
|
|
33
35
|
* // Get thread ID
|
|
34
|
-
*
|
|
36
|
+
* ctx.logger.info('Thread: %s', ctx.thread.id);
|
|
35
37
|
*
|
|
36
38
|
* // Store data in thread state (persists across sessions)
|
|
37
39
|
* ctx.thread.state.set('conversationCount',
|
|
@@ -40,7 +42,7 @@ type SessionEventCallback<T extends Session> = (
|
|
|
40
42
|
*
|
|
41
43
|
* // Listen for thread destruction
|
|
42
44
|
* ctx.thread.addEventListener('destroyed', (eventName, thread) => {
|
|
43
|
-
*
|
|
45
|
+
* ctx.logger.info('Thread destroyed: %s', thread.id);
|
|
44
46
|
* });
|
|
45
47
|
*
|
|
46
48
|
* return 'Response';
|
|
@@ -79,7 +81,7 @@ export interface Thread {
|
|
|
79
81
|
* @example
|
|
80
82
|
* ```typescript
|
|
81
83
|
* ctx.thread.addEventListener('destroyed', (eventName, thread) => {
|
|
82
|
-
*
|
|
84
|
+
* ctx.logger.info('Cleaning up thread: %s', thread.id);
|
|
83
85
|
* });
|
|
84
86
|
* ```
|
|
85
87
|
*/
|
|
@@ -105,7 +107,7 @@ export interface Thread {
|
|
|
105
107
|
*
|
|
106
108
|
* @example
|
|
107
109
|
* ```typescript
|
|
108
|
-
* //
|
|
110
|
+
* // Permanently delete the thread from storage
|
|
109
111
|
* await ctx.thread.destroy();
|
|
110
112
|
* ```
|
|
111
113
|
*/
|
|
@@ -123,18 +125,18 @@ export interface Thread {
|
|
|
123
125
|
* const agent = createAgent({
|
|
124
126
|
* handler: async (ctx, input) => {
|
|
125
127
|
* // Get session ID (unique per request)
|
|
126
|
-
*
|
|
128
|
+
* ctx.logger.info('Session: %s', ctx.session.id);
|
|
127
129
|
*
|
|
128
130
|
* // Store data in session state (only for this request)
|
|
129
131
|
* ctx.session.state.set('startTime', Date.now());
|
|
130
132
|
*
|
|
131
133
|
* // Access parent thread
|
|
132
|
-
*
|
|
134
|
+
* ctx.logger.info('Thread: %s', ctx.session.thread.id);
|
|
133
135
|
*
|
|
134
136
|
* // Listen for session completion
|
|
135
137
|
* ctx.session.addEventListener('completed', (eventName, session) => {
|
|
136
138
|
* const duration = Date.now() - (session.state.get('startTime') as number);
|
|
137
|
-
*
|
|
139
|
+
* ctx.logger.info('Session completed in %dms', duration);
|
|
138
140
|
* });
|
|
139
141
|
*
|
|
140
142
|
* return 'Response';
|
|
@@ -176,7 +178,7 @@ export interface Session {
|
|
|
176
178
|
* @example
|
|
177
179
|
* ```typescript
|
|
178
180
|
* ctx.session.addEventListener('completed', (eventName, session) => {
|
|
179
|
-
*
|
|
181
|
+
* ctx.logger.info('Session finished: %s', session.id);
|
|
180
182
|
* });
|
|
181
183
|
* ```
|
|
182
184
|
*/
|
|
@@ -195,6 +197,31 @@ export interface Session {
|
|
|
195
197
|
eventName: 'completed',
|
|
196
198
|
callback: (eventName: 'completed', session: Session) => Promise<void> | void
|
|
197
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;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* Represent an interface for handling how thread ids are generated or restored.
|
|
210
|
+
*/
|
|
211
|
+
export interface ThreadIDProvider {
|
|
212
|
+
/**
|
|
213
|
+
* A function that should return a thread id to be used for the incoming request.
|
|
214
|
+
* The returning thread id must be globally unique and must start with the prefix
|
|
215
|
+
* thrd_ such as `thrd_212c16896b974ffeb21a748f0eeba620`. The max length of the
|
|
216
|
+
* string is 64 characters and the min length is 32 characters long
|
|
217
|
+
* (including the prefix). The characters after the prefix must match the
|
|
218
|
+
* regular expression [a-zA-Z0-9-].
|
|
219
|
+
*
|
|
220
|
+
* @param appState - The app state from createApp setup function
|
|
221
|
+
* @param ctx - Hono request context
|
|
222
|
+
* @returns The thread id to use
|
|
223
|
+
*/
|
|
224
|
+
getThreadId(appState: AppState, ctx: Context<Env>): string;
|
|
198
225
|
}
|
|
199
226
|
|
|
200
227
|
/**
|
|
@@ -253,6 +280,14 @@ export interface ThreadProvider {
|
|
|
253
280
|
*/
|
|
254
281
|
initialize(appState: AppState): Promise<void>;
|
|
255
282
|
|
|
283
|
+
/**
|
|
284
|
+
* Set the provider to use for generating / restoring the thread id
|
|
285
|
+
* on new requests. Overrides the built-in provider when set.
|
|
286
|
+
*
|
|
287
|
+
* @param provider - the provider implementation
|
|
288
|
+
*/
|
|
289
|
+
setThreadIDProvider(provider: ThreadIDProvider): void;
|
|
290
|
+
|
|
256
291
|
/**
|
|
257
292
|
* Restore or create a thread from the HTTP request context.
|
|
258
293
|
* Should check cookies for existing thread ID or create a new one.
|
|
@@ -394,17 +429,37 @@ export function generateId(prefix?: string): string {
|
|
|
394
429
|
return `${prefix}${prefix ? '_' : ''}${arr.toHex()}`;
|
|
395
430
|
}
|
|
396
431
|
|
|
432
|
+
/**
|
|
433
|
+
* DefaultThreadIDProvider will look for a cookie named `atid` and use that as
|
|
434
|
+
* the thread id or if not found, generate a new one.
|
|
435
|
+
*/
|
|
436
|
+
export class DefaultThreadIDProvider implements ThreadIDProvider {
|
|
437
|
+
getThreadId(_appState: AppState, ctx: Context<Env>): string {
|
|
438
|
+
const cookie = getCookie(ctx);
|
|
439
|
+
let threadId: string | undefined;
|
|
440
|
+
|
|
441
|
+
if (cookie.atid?.startsWith('thrd_')) {
|
|
442
|
+
threadId = cookie.atid;
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
threadId = threadId || generateId('thrd');
|
|
446
|
+
|
|
447
|
+
setCookie(ctx, 'atid', threadId);
|
|
448
|
+
return threadId;
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
|
|
397
452
|
export class DefaultThread implements Thread {
|
|
398
|
-
#
|
|
453
|
+
#initialStateJson: string | undefined;
|
|
399
454
|
readonly id: string;
|
|
400
455
|
readonly state: Map<string, unknown>;
|
|
401
456
|
private provider: ThreadProvider;
|
|
402
457
|
|
|
403
|
-
constructor(provider: ThreadProvider, id: string) {
|
|
458
|
+
constructor(provider: ThreadProvider, id: string, initialStateJson?: string) {
|
|
404
459
|
this.provider = provider;
|
|
405
460
|
this.id = id;
|
|
406
461
|
this.state = new Map();
|
|
407
|
-
this.#
|
|
462
|
+
this.#initialStateJson = initialStateJson;
|
|
408
463
|
}
|
|
409
464
|
|
|
410
465
|
addEventListener(eventName: ThreadEventName, callback: ThreadEventCallback<any>): void {
|
|
@@ -434,15 +489,29 @@ export class DefaultThread implements Thread {
|
|
|
434
489
|
}
|
|
435
490
|
|
|
436
491
|
async destroy(): Promise<void> {
|
|
437
|
-
this.provider.destroy(this);
|
|
492
|
+
await this.provider.destroy(this);
|
|
438
493
|
}
|
|
439
494
|
|
|
440
|
-
|
|
441
|
-
|
|
495
|
+
/**
|
|
496
|
+
* Check if thread state has been modified since restore
|
|
497
|
+
* @internal
|
|
498
|
+
*/
|
|
499
|
+
isDirty(): boolean {
|
|
500
|
+
if (this.state.size === 0 && !this.#initialStateJson) {
|
|
501
|
+
return false;
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
const currentJson = JSON.stringify(Object.fromEntries(this.state));
|
|
505
|
+
|
|
506
|
+
return currentJson !== this.#initialStateJson;
|
|
442
507
|
}
|
|
443
508
|
|
|
444
|
-
|
|
445
|
-
|
|
509
|
+
/**
|
|
510
|
+
* Get serialized state for saving
|
|
511
|
+
* @internal
|
|
512
|
+
*/
|
|
513
|
+
getSerializedState(): string {
|
|
514
|
+
return JSON.stringify(Object.fromEntries(this.state));
|
|
446
515
|
}
|
|
447
516
|
}
|
|
448
517
|
|
|
@@ -482,64 +551,420 @@ export class DefaultSession implements Session {
|
|
|
482
551
|
async fireEvent(eventName: SessionEventName): Promise<void> {
|
|
483
552
|
await fireSessionEvent(this, eventName);
|
|
484
553
|
}
|
|
554
|
+
|
|
555
|
+
/**
|
|
556
|
+
* Serialize session state to JSON string for persistence.
|
|
557
|
+
* Returns undefined if state is empty or exceeds 1MB limit.
|
|
558
|
+
* @internal
|
|
559
|
+
*/
|
|
560
|
+
serializeUserData(): string | undefined {
|
|
561
|
+
if (this.state.size === 0) {
|
|
562
|
+
return undefined;
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
try {
|
|
566
|
+
const obj = Object.fromEntries(this.state);
|
|
567
|
+
const json = JSON.stringify(obj);
|
|
568
|
+
|
|
569
|
+
// Check 1MB limit (1,048,576 bytes)
|
|
570
|
+
const sizeInBytes = new TextEncoder().encode(json).length;
|
|
571
|
+
if (sizeInBytes > 1048576) {
|
|
572
|
+
console.error(
|
|
573
|
+
`Session ${this.id} user_data exceeds 1MB limit (${sizeInBytes} bytes), data will not be persisted`
|
|
574
|
+
);
|
|
575
|
+
return undefined;
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
return json;
|
|
579
|
+
} catch (err) {
|
|
580
|
+
console.error(`Failed to serialize session ${this.id} user_data:`, err);
|
|
581
|
+
return undefined;
|
|
582
|
+
}
|
|
583
|
+
}
|
|
485
584
|
}
|
|
486
585
|
|
|
487
|
-
|
|
488
|
-
|
|
586
|
+
/**
|
|
587
|
+
* WebSocket client for thread state persistence
|
|
588
|
+
* @internal
|
|
589
|
+
*/
|
|
590
|
+
class ThreadWebSocketClient {
|
|
591
|
+
private ws: WebSocket | null = null;
|
|
592
|
+
private authenticated = false;
|
|
593
|
+
private pendingRequests = new Map<
|
|
594
|
+
string,
|
|
595
|
+
{ resolve: (data?: string) => void; reject: (err: Error) => void }
|
|
596
|
+
>();
|
|
597
|
+
private reconnectAttempts = 0;
|
|
598
|
+
private maxReconnectAttempts = 5;
|
|
599
|
+
private apiKey: string;
|
|
600
|
+
private wsUrl: string;
|
|
601
|
+
private wsConnecting: Promise<void> | null = null;
|
|
602
|
+
|
|
603
|
+
constructor(apiKey: string, wsUrl: string) {
|
|
604
|
+
this.apiKey = apiKey;
|
|
605
|
+
this.wsUrl = wsUrl;
|
|
606
|
+
}
|
|
489
607
|
|
|
490
|
-
async
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
608
|
+
async connect(): Promise<void> {
|
|
609
|
+
return new Promise((resolve, reject) => {
|
|
610
|
+
// Set connection timeout
|
|
611
|
+
const connectionTimeout = setTimeout(() => {
|
|
612
|
+
this.cleanup();
|
|
613
|
+
reject(new Error('WebSocket connection timeout (10s)'));
|
|
614
|
+
}, 10_000);
|
|
615
|
+
|
|
616
|
+
try {
|
|
617
|
+
this.ws = new WebSocket(this.wsUrl);
|
|
618
|
+
|
|
619
|
+
this.ws.on('open', () => {
|
|
620
|
+
// Send authentication (do NOT clear timeout yet - wait for auth response)
|
|
621
|
+
this.ws?.send(JSON.stringify({ authorization: this.apiKey }));
|
|
622
|
+
});
|
|
623
|
+
|
|
624
|
+
this.ws.on('message', (data: any) => {
|
|
625
|
+
try {
|
|
626
|
+
const message = JSON.parse(data.toString());
|
|
627
|
+
|
|
628
|
+
// Handle auth response
|
|
629
|
+
if ('success' in message && !this.authenticated) {
|
|
630
|
+
clearTimeout(connectionTimeout);
|
|
631
|
+
if (message.success) {
|
|
632
|
+
this.authenticated = true;
|
|
633
|
+
this.reconnectAttempts = 0;
|
|
634
|
+
resolve();
|
|
635
|
+
} else {
|
|
636
|
+
const err = new Error(
|
|
637
|
+
`WebSocket authentication failed: ${message.error || 'Unknown error'}`
|
|
638
|
+
);
|
|
639
|
+
this.cleanup();
|
|
640
|
+
reject(err);
|
|
641
|
+
}
|
|
642
|
+
return;
|
|
499
643
|
}
|
|
500
|
-
|
|
501
|
-
|
|
644
|
+
|
|
645
|
+
// Handle action response
|
|
646
|
+
if ('id' in message && this.pendingRequests.has(message.id)) {
|
|
647
|
+
const pending = this.pendingRequests.get(message.id)!;
|
|
648
|
+
this.pendingRequests.delete(message.id);
|
|
649
|
+
|
|
650
|
+
if (message.success) {
|
|
651
|
+
pending.resolve(message.data);
|
|
652
|
+
} else {
|
|
653
|
+
pending.reject(new Error(message.error || 'Request failed'));
|
|
654
|
+
}
|
|
655
|
+
}
|
|
656
|
+
} catch {
|
|
657
|
+
// Ignore parse errors
|
|
658
|
+
}
|
|
659
|
+
});
|
|
660
|
+
|
|
661
|
+
this.ws.on('error', (err: Error) => {
|
|
662
|
+
clearTimeout(connectionTimeout);
|
|
663
|
+
if (!this.authenticated) {
|
|
664
|
+
reject(new Error(`WebSocket error: ${err.message}`));
|
|
665
|
+
}
|
|
666
|
+
});
|
|
667
|
+
|
|
668
|
+
this.ws.on('close', () => {
|
|
669
|
+
clearTimeout(connectionTimeout);
|
|
670
|
+
const wasAuthenticated = this.authenticated;
|
|
671
|
+
this.authenticated = false;
|
|
672
|
+
|
|
673
|
+
// Reject all pending requests
|
|
674
|
+
for (const [id, pending] of this.pendingRequests) {
|
|
675
|
+
pending.reject(new Error('WebSocket connection closed'));
|
|
676
|
+
this.pendingRequests.delete(id);
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
// Reject connecting promise if still pending
|
|
680
|
+
if (!wasAuthenticated) {
|
|
681
|
+
reject(new Error('WebSocket closed before authentication'));
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
// Attempt reconnection only if we were previously authenticated
|
|
685
|
+
if (wasAuthenticated && this.reconnectAttempts < this.maxReconnectAttempts) {
|
|
686
|
+
this.reconnectAttempts++;
|
|
687
|
+
const delay = Math.min(1000 * Math.pow(2, this.reconnectAttempts), 30_000);
|
|
688
|
+
|
|
689
|
+
// Schedule reconnection with backoff delay
|
|
690
|
+
setTimeout(() => {
|
|
691
|
+
// Create new connection promise for reconnection
|
|
692
|
+
this.wsConnecting = this.connect().catch(() => {
|
|
693
|
+
// Reconnection failed, reset
|
|
694
|
+
this.wsConnecting = null;
|
|
695
|
+
});
|
|
696
|
+
}, delay);
|
|
697
|
+
}
|
|
698
|
+
});
|
|
699
|
+
} catch (err) {
|
|
700
|
+
clearTimeout(connectionTimeout);
|
|
701
|
+
reject(err);
|
|
502
702
|
}
|
|
503
|
-
}
|
|
703
|
+
});
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
async restore(threadId: string): Promise<string | undefined> {
|
|
707
|
+
// Wait for connection/reconnection if in progress
|
|
708
|
+
if (this.wsConnecting) {
|
|
709
|
+
await this.wsConnecting;
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
if (!this.authenticated || !this.ws) {
|
|
713
|
+
throw new Error('WebSocket not connected or authenticated');
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
return new Promise((resolve, reject) => {
|
|
717
|
+
const requestId = crypto.randomUUID();
|
|
718
|
+
this.pendingRequests.set(requestId, { resolve, reject });
|
|
719
|
+
|
|
720
|
+
const message = {
|
|
721
|
+
id: requestId,
|
|
722
|
+
action: 'restore',
|
|
723
|
+
data: { thread_id: threadId },
|
|
724
|
+
};
|
|
725
|
+
|
|
726
|
+
this.ws!.send(JSON.stringify(message));
|
|
727
|
+
|
|
728
|
+
// Timeout after 10 seconds
|
|
729
|
+
setTimeout(() => {
|
|
730
|
+
if (this.pendingRequests.has(requestId)) {
|
|
731
|
+
this.pendingRequests.delete(requestId);
|
|
732
|
+
reject(new Error('Request timeout'));
|
|
733
|
+
}
|
|
734
|
+
}, 10000);
|
|
735
|
+
});
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
async save(threadId: string, userData: string): Promise<void> {
|
|
739
|
+
// Wait for connection/reconnection if in progress
|
|
740
|
+
if (this.wsConnecting) {
|
|
741
|
+
await this.wsConnecting;
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
if (!this.authenticated || !this.ws) {
|
|
745
|
+
throw new Error('WebSocket not connected or authenticated');
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
// Check 1MB limit
|
|
749
|
+
const sizeInBytes = new TextEncoder().encode(userData).length;
|
|
750
|
+
if (sizeInBytes > 1048576) {
|
|
751
|
+
console.error(
|
|
752
|
+
`Thread ${threadId} user_data exceeds 1MB limit (${sizeInBytes} bytes), data will not be persisted`
|
|
753
|
+
);
|
|
754
|
+
return;
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
return new Promise((resolve, reject) => {
|
|
758
|
+
const requestId = crypto.randomUUID();
|
|
759
|
+
this.pendingRequests.set(requestId, {
|
|
760
|
+
resolve: () => resolve(),
|
|
761
|
+
reject,
|
|
762
|
+
});
|
|
763
|
+
|
|
764
|
+
const message = {
|
|
765
|
+
id: requestId,
|
|
766
|
+
action: 'save',
|
|
767
|
+
data: { thread_id: threadId, user_data: userData },
|
|
768
|
+
};
|
|
769
|
+
|
|
770
|
+
this.ws!.send(JSON.stringify(message));
|
|
771
|
+
|
|
772
|
+
// Timeout after 10 seconds
|
|
773
|
+
setTimeout(() => {
|
|
774
|
+
if (this.pendingRequests.has(requestId)) {
|
|
775
|
+
this.pendingRequests.delete(requestId);
|
|
776
|
+
reject(new Error('Request timeout'));
|
|
777
|
+
}
|
|
778
|
+
}, 10_000);
|
|
779
|
+
});
|
|
780
|
+
}
|
|
781
|
+
|
|
782
|
+
async delete(threadId: string): Promise<void> {
|
|
783
|
+
// Wait for connection/reconnection if in progress
|
|
784
|
+
if (this.wsConnecting) {
|
|
785
|
+
await this.wsConnecting;
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
if (!this.authenticated || !this.ws) {
|
|
789
|
+
throw new Error('WebSocket not connected or authenticated');
|
|
790
|
+
}
|
|
791
|
+
|
|
792
|
+
return new Promise((resolve, reject) => {
|
|
793
|
+
const requestId = crypto.randomUUID();
|
|
794
|
+
this.pendingRequests.set(requestId, {
|
|
795
|
+
resolve: () => resolve(),
|
|
796
|
+
reject,
|
|
797
|
+
});
|
|
798
|
+
|
|
799
|
+
const message = {
|
|
800
|
+
id: requestId,
|
|
801
|
+
action: 'delete',
|
|
802
|
+
data: { thread_id: threadId },
|
|
803
|
+
};
|
|
804
|
+
|
|
805
|
+
this.ws!.send(JSON.stringify(message));
|
|
806
|
+
|
|
807
|
+
// Timeout after 10 seconds
|
|
808
|
+
setTimeout(() => {
|
|
809
|
+
if (this.pendingRequests.has(requestId)) {
|
|
810
|
+
this.pendingRequests.delete(requestId);
|
|
811
|
+
reject(new Error('Request timeout'));
|
|
812
|
+
}
|
|
813
|
+
}, 10_000);
|
|
814
|
+
});
|
|
815
|
+
}
|
|
816
|
+
|
|
817
|
+
cleanup(): void {
|
|
818
|
+
if (this.ws) {
|
|
819
|
+
this.ws.close();
|
|
820
|
+
this.ws = null;
|
|
821
|
+
}
|
|
822
|
+
this.authenticated = false;
|
|
823
|
+
this.pendingRequests.clear();
|
|
824
|
+
}
|
|
825
|
+
}
|
|
826
|
+
|
|
827
|
+
const validThreadIdCharacters = /^[a-zA-Z0-9-]+$/;
|
|
828
|
+
|
|
829
|
+
export class DefaultThreadProvider implements ThreadProvider {
|
|
830
|
+
private appState: AppState | null = null;
|
|
831
|
+
private wsClient: ThreadWebSocketClient | null = null;
|
|
832
|
+
private wsConnecting: Promise<void> | null = null;
|
|
833
|
+
private threadIDProvider: ThreadIDProvider | null = null;
|
|
834
|
+
|
|
835
|
+
async initialize(appState: AppState): Promise<void> {
|
|
836
|
+
this.appState = appState;
|
|
837
|
+
this.threadIDProvider = new DefaultThreadIDProvider();
|
|
838
|
+
|
|
839
|
+
// Initialize WebSocket connection for thread persistence (async, non-blocking)
|
|
840
|
+
const apiKey = process.env.AGENTUITY_SDK_KEY;
|
|
841
|
+
if (apiKey) {
|
|
842
|
+
const serviceUrls = getServiceUrls();
|
|
843
|
+
const catalystUrl = serviceUrls.catalyst;
|
|
844
|
+
const wsUrl = new URL('/thread/ws', catalystUrl.replace(/^http/, 'ws'));
|
|
845
|
+
|
|
846
|
+
this.wsClient = new ThreadWebSocketClient(apiKey, wsUrl.toString());
|
|
847
|
+
// Connect in background, don't block initialization
|
|
848
|
+
this.wsConnecting = this.wsClient
|
|
849
|
+
.connect()
|
|
850
|
+
.then(() => {
|
|
851
|
+
this.wsConnecting = null;
|
|
852
|
+
})
|
|
853
|
+
.catch((err) => {
|
|
854
|
+
console.error('Failed to connect to thread WebSocket:', err);
|
|
855
|
+
this.wsClient = null;
|
|
856
|
+
this.wsConnecting = null;
|
|
857
|
+
});
|
|
858
|
+
}
|
|
859
|
+
}
|
|
860
|
+
|
|
861
|
+
setThreadIDProvider(provider: ThreadIDProvider): void {
|
|
862
|
+
this.threadIDProvider = provider;
|
|
504
863
|
}
|
|
505
864
|
|
|
506
865
|
async restore(ctx: Context<Env>): Promise<Thread> {
|
|
507
|
-
const
|
|
508
|
-
let threadId: string | undefined;
|
|
866
|
+
const threadId = this.threadIDProvider!.getThreadId(this.appState!, ctx);
|
|
509
867
|
|
|
510
|
-
if (
|
|
511
|
-
|
|
868
|
+
if (!threadId) {
|
|
869
|
+
throw new Error(`the ThreadIDProvider returned an empty thread id for getThreadId`);
|
|
870
|
+
}
|
|
871
|
+
if (!threadId.startsWith('thrd_')) {
|
|
872
|
+
throw new Error(
|
|
873
|
+
`the ThreadIDProvider returned an invalid thread id (${threadId}) for getThreadId. The thread id must start with the prefix 'thrd_'.`
|
|
874
|
+
);
|
|
875
|
+
}
|
|
876
|
+
if (threadId.length > 64) {
|
|
877
|
+
throw new Error(
|
|
878
|
+
`the ThreadIDProvider returned an invalid thread id (${threadId}) for getThreadId. The thread id must be less than 64 characters long.`
|
|
879
|
+
);
|
|
880
|
+
}
|
|
881
|
+
if (threadId.length < 32) {
|
|
882
|
+
throw new Error(
|
|
883
|
+
`the ThreadIDProvider returned an invalid thread id (${threadId}) for getThreadId. The thread id must be at least 32 characters long.`
|
|
884
|
+
);
|
|
885
|
+
}
|
|
886
|
+
if (!validThreadIdCharacters.test(threadId.substring(5))) {
|
|
887
|
+
throw new Error(
|
|
888
|
+
`the ThreadIDProvider returned an invalid thread id (${threadId}) for getThreadId. The thread id must contain only characters that match the regular expression [a-zA-Z0-9-].`
|
|
889
|
+
);
|
|
512
890
|
}
|
|
513
891
|
|
|
514
|
-
|
|
892
|
+
// Wait for WebSocket connection if still connecting
|
|
893
|
+
if (this.wsConnecting) {
|
|
894
|
+
await this.wsConnecting;
|
|
895
|
+
}
|
|
896
|
+
|
|
897
|
+
// Restore thread state from WebSocket if available
|
|
898
|
+
let initialStateJson: string | undefined;
|
|
899
|
+
if (this.wsClient) {
|
|
900
|
+
try {
|
|
901
|
+
const restoredData = await this.wsClient.restore(threadId);
|
|
902
|
+
if (restoredData) {
|
|
903
|
+
initialStateJson = restoredData;
|
|
904
|
+
}
|
|
905
|
+
} catch {
|
|
906
|
+
// Continue with empty state rather than failing
|
|
907
|
+
}
|
|
908
|
+
}
|
|
515
909
|
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
910
|
+
const thread = new DefaultThread(this, threadId, initialStateJson);
|
|
911
|
+
|
|
912
|
+
// Populate thread state from restored data
|
|
913
|
+
if (initialStateJson) {
|
|
914
|
+
try {
|
|
915
|
+
const data = JSON.parse(initialStateJson);
|
|
916
|
+
for (const [key, value] of Object.entries(data)) {
|
|
917
|
+
thread.state.set(key, value);
|
|
918
|
+
}
|
|
919
|
+
} catch {
|
|
920
|
+
// Continue with empty state if parsing fails
|
|
521
921
|
}
|
|
522
922
|
}
|
|
523
923
|
|
|
524
|
-
const thread = new DefaultThread(this, threadId);
|
|
525
|
-
this.threads.set(thread.id, thread);
|
|
526
924
|
await fireEvent('thread.created', thread);
|
|
527
925
|
return thread;
|
|
528
926
|
}
|
|
529
927
|
|
|
530
928
|
async save(thread: Thread): Promise<void> {
|
|
531
929
|
if (thread instanceof DefaultThread) {
|
|
532
|
-
|
|
930
|
+
// Wait for WebSocket connection if still connecting
|
|
931
|
+
if (this.wsConnecting) {
|
|
932
|
+
await this.wsConnecting;
|
|
933
|
+
}
|
|
934
|
+
|
|
935
|
+
// Only save to WebSocket if state has changed
|
|
936
|
+
if (this.wsClient && thread.isDirty()) {
|
|
937
|
+
try {
|
|
938
|
+
const serialized = thread.getSerializedState();
|
|
939
|
+
await this.wsClient.save(thread.id, serialized);
|
|
940
|
+
} catch {
|
|
941
|
+
// Don't throw - allow request to complete even if save fails
|
|
942
|
+
}
|
|
943
|
+
}
|
|
533
944
|
}
|
|
534
945
|
}
|
|
535
946
|
|
|
536
947
|
async destroy(thread: Thread): Promise<void> {
|
|
537
948
|
if (thread instanceof DefaultThread) {
|
|
538
949
|
try {
|
|
950
|
+
// Wait for WebSocket connection if still connecting
|
|
951
|
+
if (this.wsConnecting) {
|
|
952
|
+
await this.wsConnecting;
|
|
953
|
+
}
|
|
954
|
+
|
|
955
|
+
// Delete thread from remote storage
|
|
956
|
+
if (this.wsClient) {
|
|
957
|
+
try {
|
|
958
|
+
await this.wsClient.delete(thread.id);
|
|
959
|
+
} catch (err) {
|
|
960
|
+
console.error(`Failed to delete thread ${thread.id} from remote storage:`, err);
|
|
961
|
+
// Continue with local cleanup even if remote delete fails
|
|
962
|
+
}
|
|
963
|
+
}
|
|
964
|
+
|
|
539
965
|
await thread.fireEvent('destroyed');
|
|
540
966
|
await fireEvent('thread.destroyed', thread);
|
|
541
967
|
} finally {
|
|
542
|
-
this.threads.delete(thread.id);
|
|
543
968
|
threadEventListeners.delete(thread);
|
|
544
969
|
}
|
|
545
970
|
}
|