@agentuity/runtime 0.0.106 → 0.0.108

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/session.js CHANGED
@@ -233,18 +233,251 @@ export class DefaultThreadIDProvider {
233
233
  return threadId;
234
234
  }
235
235
  }
236
- export class DefaultThread {
236
+ export class LazyThreadState {
237
+ #status = 'idle';
238
+ #state = new Map();
239
+ #pendingOperations = [];
237
240
  #initialStateJson;
241
+ #restoreFn;
242
+ #loadingPromise = null;
243
+ constructor(restoreFn) {
244
+ this.#restoreFn = restoreFn;
245
+ }
246
+ get loaded() {
247
+ return this.#status === 'loaded';
248
+ }
249
+ get dirty() {
250
+ if (this.#status === 'pending-writes') {
251
+ return this.#pendingOperations.length > 0;
252
+ }
253
+ if (this.#status === 'loaded') {
254
+ const currentJson = JSON.stringify(Object.fromEntries(this.#state));
255
+ return currentJson !== this.#initialStateJson;
256
+ }
257
+ return false;
258
+ }
259
+ async ensureLoaded() {
260
+ if (this.#status === 'loaded') {
261
+ return;
262
+ }
263
+ if (this.#loadingPromise) {
264
+ await this.#loadingPromise;
265
+ return;
266
+ }
267
+ this.#loadingPromise = (async () => {
268
+ try {
269
+ await this.doLoad();
270
+ }
271
+ finally {
272
+ this.#loadingPromise = null;
273
+ }
274
+ })();
275
+ await this.#loadingPromise;
276
+ }
277
+ async doLoad() {
278
+ const { state } = await this.#restoreFn();
279
+ // Initialize state from restored data
280
+ this.#state = new Map(state);
281
+ this.#initialStateJson = JSON.stringify(Object.fromEntries(this.#state));
282
+ // Apply any pending operations
283
+ for (const op of this.#pendingOperations) {
284
+ switch (op.op) {
285
+ case 'clear':
286
+ this.#state.clear();
287
+ break;
288
+ case 'set':
289
+ if (op.key !== undefined) {
290
+ this.#state.set(op.key, op.value);
291
+ }
292
+ break;
293
+ case 'delete':
294
+ if (op.key !== undefined) {
295
+ this.#state.delete(op.key);
296
+ }
297
+ break;
298
+ case 'push':
299
+ if (op.key !== undefined) {
300
+ const existing = this.#state.get(op.key);
301
+ if (Array.isArray(existing)) {
302
+ existing.push(op.value);
303
+ // Apply maxRecords limit
304
+ if (op.maxRecords !== undefined && existing.length > op.maxRecords) {
305
+ existing.splice(0, existing.length - op.maxRecords);
306
+ }
307
+ }
308
+ else if (existing === undefined) {
309
+ this.#state.set(op.key, [op.value]);
310
+ }
311
+ // If existing is non-array, silently skip (error would have been thrown if loaded)
312
+ }
313
+ break;
314
+ }
315
+ }
316
+ this.#pendingOperations = [];
317
+ this.#status = 'loaded';
318
+ }
319
+ async get(key) {
320
+ await this.ensureLoaded();
321
+ return this.#state.get(key);
322
+ }
323
+ async set(key, value) {
324
+ if (this.#status === 'loaded') {
325
+ this.#state.set(key, value);
326
+ }
327
+ else {
328
+ this.#pendingOperations.push({ op: 'set', key, value });
329
+ if (this.#status === 'idle') {
330
+ this.#status = 'pending-writes';
331
+ }
332
+ }
333
+ }
334
+ async has(key) {
335
+ await this.ensureLoaded();
336
+ return this.#state.has(key);
337
+ }
338
+ async delete(key) {
339
+ if (this.#status === 'loaded') {
340
+ this.#state.delete(key);
341
+ }
342
+ else {
343
+ this.#pendingOperations.push({ op: 'delete', key });
344
+ if (this.#status === 'idle') {
345
+ this.#status = 'pending-writes';
346
+ }
347
+ }
348
+ }
349
+ async clear() {
350
+ if (this.#status === 'loaded') {
351
+ this.#state.clear();
352
+ }
353
+ else {
354
+ // Clear replaces all previous pending operations
355
+ this.#pendingOperations = [{ op: 'clear' }];
356
+ if (this.#status === 'idle') {
357
+ this.#status = 'pending-writes';
358
+ }
359
+ }
360
+ }
361
+ async entries() {
362
+ await this.ensureLoaded();
363
+ return Array.from(this.#state.entries());
364
+ }
365
+ async keys() {
366
+ await this.ensureLoaded();
367
+ return Array.from(this.#state.keys());
368
+ }
369
+ async values() {
370
+ await this.ensureLoaded();
371
+ return Array.from(this.#state.values());
372
+ }
373
+ async size() {
374
+ await this.ensureLoaded();
375
+ return this.#state.size;
376
+ }
377
+ async push(key, value, maxRecords) {
378
+ if (this.#status === 'loaded') {
379
+ // When loaded, push to local array
380
+ const existing = this.#state.get(key);
381
+ if (Array.isArray(existing)) {
382
+ existing.push(value);
383
+ // Apply maxRecords limit
384
+ if (maxRecords !== undefined && existing.length > maxRecords) {
385
+ existing.splice(0, existing.length - maxRecords);
386
+ }
387
+ }
388
+ else if (existing === undefined) {
389
+ this.#state.set(key, [value]);
390
+ }
391
+ else {
392
+ throw new Error(`Cannot push to non-array value at key "${key}"`);
393
+ }
394
+ }
395
+ else {
396
+ // Queue push operation for merge
397
+ const op = { op: 'push', key, value };
398
+ if (maxRecords !== undefined) {
399
+ op.maxRecords = maxRecords;
400
+ }
401
+ this.#pendingOperations.push(op);
402
+ if (this.#status === 'idle') {
403
+ this.#status = 'pending-writes';
404
+ }
405
+ }
406
+ }
407
+ /**
408
+ * Get the current status for save logic
409
+ * @internal
410
+ */
411
+ getStatus() {
412
+ return this.#status;
413
+ }
414
+ /**
415
+ * Get pending operations for merge command
416
+ * @internal
417
+ */
418
+ getPendingOperations() {
419
+ return [...this.#pendingOperations];
420
+ }
421
+ /**
422
+ * Get serialized state for full save.
423
+ * Ensures state is loaded before serializing.
424
+ * @internal
425
+ */
426
+ async getSerializedState() {
427
+ await this.ensureLoaded();
428
+ return Object.fromEntries(this.#state);
429
+ }
430
+ }
431
+ export class DefaultThread {
238
432
  id;
239
433
  state;
240
- metadata;
434
+ #metadata = null;
435
+ #metadataDirty = false;
436
+ #metadataLoadPromise = null;
241
437
  provider;
242
- constructor(provider, id, initialStateJson, metadata) {
438
+ #restoreFn;
439
+ #restoredMetadata;
440
+ constructor(provider, id, restoreFn, initialMetadata) {
243
441
  this.provider = provider;
244
442
  this.id = id;
245
- this.state = new Map();
246
- this.#initialStateJson = initialStateJson;
247
- this.metadata = metadata || {};
443
+ this.#restoreFn = restoreFn;
444
+ this.#restoredMetadata = initialMetadata;
445
+ this.state = new LazyThreadState(restoreFn);
446
+ }
447
+ async ensureMetadataLoaded() {
448
+ if (this.#metadata !== null) {
449
+ return;
450
+ }
451
+ // If we have initial metadata from thread creation, use it
452
+ if (this.#restoredMetadata !== undefined) {
453
+ this.#metadata = this.#restoredMetadata;
454
+ return;
455
+ }
456
+ if (this.#metadataLoadPromise) {
457
+ await this.#metadataLoadPromise;
458
+ return;
459
+ }
460
+ this.#metadataLoadPromise = (async () => {
461
+ try {
462
+ await this.doLoadMetadata();
463
+ }
464
+ finally {
465
+ this.#metadataLoadPromise = null;
466
+ }
467
+ })();
468
+ await this.#metadataLoadPromise;
469
+ }
470
+ async doLoadMetadata() {
471
+ const { metadata } = await this.#restoreFn();
472
+ this.#metadata = metadata;
473
+ }
474
+ async getMetadata() {
475
+ await this.ensureMetadataLoaded();
476
+ return { ...this.#metadata };
477
+ }
478
+ async setMetadata(metadata) {
479
+ this.#metadata = metadata;
480
+ this.#metadataDirty = true;
248
481
  }
249
482
  addEventListener(eventName, callback) {
250
483
  let listeners = threadEventListeners.get(this);
@@ -275,38 +508,79 @@ export class DefaultThread {
275
508
  await this.provider.destroy(this);
276
509
  }
277
510
  /**
278
- * Check if thread state has been modified since restore
511
+ * Check if thread has any data (state or metadata)
512
+ */
513
+ async empty() {
514
+ const stateSize = await this.state.size();
515
+ // Check both loaded metadata and initial metadata from constructor
516
+ const meta = this.#metadata ?? this.#restoredMetadata ?? {};
517
+ return stateSize === 0 && Object.keys(meta).length === 0;
518
+ }
519
+ /**
520
+ * Check if thread needs saving
279
521
  * @internal
280
522
  */
281
- isDirty() {
282
- if (this.state.size === 0 && !this.#initialStateJson) {
283
- return false;
523
+ needsSave() {
524
+ return this.state.dirty || this.#metadataDirty;
525
+ }
526
+ /**
527
+ * Get the save mode for this thread
528
+ * @internal
529
+ */
530
+ getSaveMode() {
531
+ const stateStatus = this.state.getStatus();
532
+ if (stateStatus === 'idle' && !this.#metadataDirty) {
533
+ return 'none';
534
+ }
535
+ if (stateStatus === 'pending-writes') {
536
+ return 'merge';
537
+ }
538
+ if (stateStatus === 'loaded' && (this.state.dirty || this.#metadataDirty)) {
539
+ return 'full';
284
540
  }
285
- const currentJson = JSON.stringify(Object.fromEntries(this.state));
286
- return currentJson !== this.#initialStateJson;
541
+ // Only metadata was changed without loading state
542
+ if (this.#metadataDirty) {
543
+ return 'merge';
544
+ }
545
+ return 'none';
287
546
  }
288
547
  /**
289
- * Check if thread has any data (state or metadata)
548
+ * Get pending operations for merge command
549
+ * @internal
550
+ */
551
+ getPendingOperations() {
552
+ return this.state.getPendingOperations();
553
+ }
554
+ /**
555
+ * Get metadata for saving (returns null if not loaded/modified)
556
+ * @internal
290
557
  */
291
- empty() {
292
- return this.state.size === 0 && Object.keys(this.metadata).length === 0;
558
+ getMetadataForSave() {
559
+ if (this.#metadataDirty && this.#metadata) {
560
+ return this.#metadata;
561
+ }
562
+ return undefined;
293
563
  }
294
564
  /**
295
- * Get serialized state for saving
565
+ * Get serialized state for full save.
566
+ * Ensures state is loaded before serializing.
296
567
  * @internal
297
568
  */
298
- getSerializedState() {
299
- const hasState = this.state.size > 0;
300
- const hasMetadata = Object.keys(this.metadata).length > 0;
569
+ async getSerializedState() {
570
+ const state = await this.state.getSerializedState();
571
+ // Also ensure metadata is loaded
572
+ const meta = this.#metadata ?? this.#restoredMetadata ?? {};
573
+ const hasState = Object.keys(state).length > 0;
574
+ const hasMetadata = Object.keys(meta).length > 0;
301
575
  if (!hasState && !hasMetadata) {
302
576
  return '';
303
577
  }
304
578
  const data = {};
305
579
  if (hasState) {
306
- data.state = Object.fromEntries(this.state);
580
+ data.state = state;
307
581
  }
308
582
  if (hasMetadata) {
309
- data.metadata = this.metadata;
583
+ data.metadata = meta;
310
584
  }
311
585
  return JSON.stringify(data);
312
586
  }
@@ -625,6 +899,42 @@ export class ThreadWebSocketClient {
625
899
  }, this.requestTimeoutMs);
626
900
  });
627
901
  }
902
+ async merge(threadId, operations, metadata) {
903
+ // Wait for connection/reconnection if in progress
904
+ if (this.wsConnecting) {
905
+ await this.wsConnecting;
906
+ }
907
+ if (!this.authenticated || !this.ws) {
908
+ throw new Error('WebSocket not connected or authenticated');
909
+ }
910
+ return new Promise((resolve, reject) => {
911
+ const requestId = crypto.randomUUID();
912
+ this.pendingRequests.set(requestId, {
913
+ resolve: () => resolve(),
914
+ reject,
915
+ });
916
+ const data = {
917
+ thread_id: threadId,
918
+ operations,
919
+ };
920
+ if (metadata && Object.keys(metadata).length > 0) {
921
+ data.metadata = metadata;
922
+ }
923
+ const message = {
924
+ id: requestId,
925
+ action: 'merge',
926
+ data,
927
+ };
928
+ this.ws.send(JSON.stringify(message));
929
+ // Timeout after configured duration
930
+ setTimeout(() => {
931
+ if (this.pendingRequests.has(requestId)) {
932
+ this.pendingRequests.delete(requestId);
933
+ reject(new Error('Request timeout'));
934
+ }
935
+ }, this.requestTimeoutMs);
936
+ });
937
+ }
628
938
  cleanup() {
629
939
  // Mark as disposed to prevent new reconnection attempts
630
940
  this.isDisposed = true;
@@ -680,79 +990,86 @@ export class DefaultThreadProvider {
680
990
  async restore(ctx) {
681
991
  const threadId = await this.threadIDProvider.getThreadId(this.appState, ctx);
682
992
  validateThreadIdOrThrow(threadId);
683
- internal.info('[thread] restoring thread %s', threadId);
684
- // Wait for WebSocket connection if still connecting
685
- if (this.wsConnecting) {
686
- internal.info('[thread] waiting for WebSocket connection');
687
- await this.wsConnecting;
688
- }
689
- // Restore thread state and metadata from WebSocket if available
690
- let initialStateJson;
691
- let restoredMetadata;
692
- if (this.wsClient) {
993
+ internal.info('[thread] creating lazy thread %s (no eager restore)', threadId);
994
+ // Create a restore function that will be called lazily when state/metadata is accessed
995
+ const restoreFn = async () => {
996
+ internal.info('[thread] lazy loading state for thread %s', threadId);
997
+ // Wait for WebSocket connection if still connecting
998
+ if (this.wsConnecting) {
999
+ internal.info('[thread] waiting for WebSocket connection');
1000
+ await this.wsConnecting;
1001
+ }
1002
+ if (!this.wsClient) {
1003
+ internal.info('[thread] no WebSocket client available, returning empty state');
1004
+ return { state: new Map(), metadata: {} };
1005
+ }
693
1006
  try {
694
- internal.info('[thread] restoring state from WebSocket');
695
1007
  const restoredData = await this.wsClient.restore(threadId);
696
1008
  if (restoredData) {
697
1009
  internal.info('[thread] restored state: %d bytes', restoredData.length);
698
1010
  const { flatStateJson, metadata } = parseThreadData(restoredData);
699
- initialStateJson = flatStateJson;
700
- restoredMetadata = metadata;
701
- }
702
- else {
703
- internal.info('[thread] no existing state found');
1011
+ const state = new Map();
1012
+ if (flatStateJson) {
1013
+ try {
1014
+ const data = JSON.parse(flatStateJson);
1015
+ for (const [key, value] of Object.entries(data)) {
1016
+ state.set(key, value);
1017
+ }
1018
+ }
1019
+ catch {
1020
+ internal.info('[thread] failed to parse state JSON');
1021
+ }
1022
+ }
1023
+ return { state, metadata: metadata || {} };
704
1024
  }
1025
+ internal.info('[thread] no existing state found');
1026
+ return { state: new Map(), metadata: {} };
705
1027
  }
706
1028
  catch (err) {
707
1029
  internal.info('[thread] WebSocket restore failed: %s', err);
708
- // Continue with empty state rather than failing
709
- }
710
- }
711
- else {
712
- internal.info('[thread] no WebSocket client available');
713
- }
714
- const thread = new DefaultThread(this, threadId, initialStateJson, restoredMetadata);
715
- // Populate thread state from restored data
716
- if (initialStateJson) {
717
- try {
718
- const data = JSON.parse(initialStateJson);
719
- for (const [key, value] of Object.entries(data)) {
720
- thread.state.set(key, value);
721
- }
722
- internal.info('[thread] populated state with %d keys', thread.state.size);
1030
+ return { state: new Map(), metadata: {} };
723
1031
  }
724
- catch (err) {
725
- internal.info('[thread] failed to parse state JSON: %s', err);
726
- // Continue with empty state if parsing fails
727
- }
728
- }
1032
+ };
1033
+ const thread = new DefaultThread(this, threadId, restoreFn);
729
1034
  await fireEvent('thread.created', thread);
730
1035
  return thread;
731
1036
  }
732
1037
  async save(thread) {
733
1038
  if (thread instanceof DefaultThread) {
734
- internal.info('[thread] DefaultThreadProvider.save() - thread %s, isDirty: %s, hasWsClient: %s', thread.id, thread.isDirty(), !!this.wsClient);
1039
+ const saveMode = thread.getSaveMode();
1040
+ internal.info('[thread] DefaultThreadProvider.save() - thread %s, saveMode: %s, hasWsClient: %s', thread.id, saveMode, !!this.wsClient);
1041
+ if (saveMode === 'none') {
1042
+ internal.info('[thread] skipping save - no changes');
1043
+ return;
1044
+ }
735
1045
  // Wait for WebSocket connection if still connecting
736
1046
  if (this.wsConnecting) {
737
1047
  internal.info('[thread] waiting for WebSocket connection');
738
1048
  await this.wsConnecting;
739
1049
  }
740
- // Only save to WebSocket if state has changed
741
- if (this.wsClient && thread.isDirty()) {
742
- try {
743
- const serialized = thread.getSerializedState();
1050
+ if (!this.wsClient) {
1051
+ internal.info('[thread] no WebSocket client available, skipping save');
1052
+ return;
1053
+ }
1054
+ try {
1055
+ if (saveMode === 'merge') {
1056
+ const operations = thread.getPendingOperations();
1057
+ const metadata = thread.getMetadataForSave();
1058
+ internal.info('[thread] sending merge command with %d operations', operations.length);
1059
+ await this.wsClient.merge(thread.id, operations, metadata);
1060
+ internal.info('[thread] WebSocket merge completed');
1061
+ }
1062
+ else if (saveMode === 'full') {
1063
+ const serialized = await thread.getSerializedState();
744
1064
  internal.info('[thread] saving to WebSocket, serialized length: %d', serialized.length);
745
- const metadata = Object.keys(thread.metadata).length > 0 ? thread.metadata : undefined;
1065
+ const metadata = thread.getMetadataForSave();
746
1066
  await this.wsClient.save(thread.id, serialized, metadata);
747
1067
  internal.info('[thread] WebSocket save completed');
748
1068
  }
749
- catch (err) {
750
- internal.info('[thread] WebSocket save failed: %s', err);
751
- // Don't throw - allow request to complete even if save fails
752
- }
753
1069
  }
754
- else {
755
- internal.info('[thread] skipping save - no wsClient or thread not dirty');
1070
+ catch (err) {
1071
+ internal.info('[thread] WebSocket save/merge failed: %s', err);
1072
+ // Don't throw - allow request to complete even if save fails
756
1073
  }
757
1074
  }
758
1075
  }