@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/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
- * console.log('Thread:', ctx.thread.id);
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
- * console.log('Thread destroyed:', thread.id);
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
- * console.log('Cleaning up thread:', thread.id);
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
- * // End conversation
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
- * console.log('Session:', ctx.session.id);
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
- * console.log('Thread:', ctx.session.thread.id);
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
- * console.log(`Session completed in ${duration}ms`);
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
- * console.log('Session finished:', session.id);
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
- #lastUsed: number;
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.#lastUsed = Date.now();
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
- touch() {
441
- this.#lastUsed = Date.now();
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
- expired() {
445
- return Date.now() - this.#lastUsed >= 3.6e6; // 1 hour
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
- export class DefaultThreadProvider implements ThreadProvider {
488
- private threads = new Map<string, DefaultThread>();
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 initialize(_appState: AppState): Promise<void> {
491
- setInterval(() => {
492
- for (const [, thread] of this.threads) {
493
- if (thread.expired()) {
494
- void (async () => {
495
- try {
496
- await this.destroy(thread);
497
- } catch (err) {
498
- console.error('Failed to destroy expired thread', err);
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
- }, 60_000).unref();
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 cookie = getCookie(ctx);
508
- let threadId: string | undefined;
866
+ const threadId = this.threadIDProvider!.getThreadId(this.appState!, ctx);
509
867
 
510
- if (cookie.atid?.startsWith('thrd_')) {
511
- threadId = cookie.atid;
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
- threadId = threadId || generateId('thrd');
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
- if (threadId) {
517
- setCookie(ctx, 'atid', threadId);
518
- const existing = this.threads.get(threadId);
519
- if (existing) {
520
- return existing;
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
- thread.touch();
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
  }