@letta-ai/letta-agent-sdk 0.3.3 → 0.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (56) hide show
  1. package/AGENTS.md +0 -3
  2. package/README.md +8 -1
  3. package/dist/app-server-management.d.ts +3 -48
  4. package/dist/app-server-management.d.ts.map +1 -1
  5. package/dist/app-server-session.d.ts +0 -6
  6. package/dist/app-server-session.d.ts.map +1 -1
  7. package/dist/client-base.d.ts +7 -15
  8. package/dist/client-base.d.ts.map +1 -1
  9. package/dist/client-entry.js +428 -486
  10. package/dist/client-entry.js.map +13 -12
  11. package/dist/client.d.ts +3 -3
  12. package/dist/client.d.ts.map +1 -1
  13. package/dist/cloud-session.d.ts +2 -0
  14. package/dist/cloud-session.d.ts.map +1 -1
  15. package/dist/cloud-status-transport.d.ts +53 -0
  16. package/dist/cloud-status-transport.d.ts.map +1 -0
  17. package/dist/index.d.ts +7 -16
  18. package/dist/index.d.ts.map +1 -1
  19. package/dist/index.js +911 -1993
  20. package/dist/index.js.map +18 -18
  21. package/dist/local-app-server-session.d.ts +1 -1
  22. package/dist/local-app-server-session.d.ts.map +1 -1
  23. package/dist/local-app-server.d.ts +11 -0
  24. package/dist/local-app-server.d.ts.map +1 -1
  25. package/dist/management.d.ts +0 -7
  26. package/dist/management.d.ts.map +1 -1
  27. package/dist/remote-session-protocol.d.ts +2 -0
  28. package/dist/remote-session-protocol.d.ts.map +1 -1
  29. package/dist/remote-turn-coordinator.d.ts.map +1 -1
  30. package/dist/types.d.ts +65 -66
  31. package/dist/types.d.ts.map +1 -1
  32. package/dist/validation.d.ts.map +1 -1
  33. package/package.json +2 -2
  34. package/src/app-server-management.ts +20 -256
  35. package/src/app-server-session.ts +0 -32
  36. package/src/client-base.ts +47 -73
  37. package/src/client.ts +31 -70
  38. package/src/cloud-session.ts +113 -287
  39. package/src/cloud-status-transport.ts +305 -0
  40. package/src/index.ts +11 -29
  41. package/src/local-app-server-session.ts +13 -3
  42. package/src/local-app-server.ts +97 -3
  43. package/src/management.ts +0 -7
  44. package/src/remote-session-protocol.ts +18 -0
  45. package/src/remote-turn-coordinator.ts +20 -2
  46. package/src/types.ts +72 -111
  47. package/src/validation.ts +21 -6
  48. package/dist/protocol.d.ts +0 -205
  49. package/dist/protocol.d.ts.map +0 -1
  50. package/dist/session.d.ts +0 -155
  51. package/dist/session.d.ts.map +0 -1
  52. package/dist/transport.d.ts +0 -53
  53. package/dist/transport.d.ts.map +0 -1
  54. package/src/protocol.ts +0 -249
  55. package/src/session.ts +0 -1638
  56. package/src/transport.ts +0 -484
@@ -1,9 +1,6 @@
1
1
  import {
2
2
  createAppServerClient,
3
3
  type AppServerClient,
4
- type AppServerSocketConstructor,
5
- type AppServerSocketLike,
6
- type AppServerSocketOptions,
7
4
  } from "@letta-ai/letta-code/app-server-client";
8
5
  import {
9
6
  AppServerRuntimeController,
@@ -13,6 +10,7 @@ import {
13
10
  externalToolGroups,
14
11
  registerAppServerControlRequestHandler,
15
12
  } from "./app-server-session.js";
13
+ import { createCloudStatusTransportConstructor } from "./cloud-status-transport.js";
16
14
  import {
17
15
  RemoteEnvironmentClient,
18
16
  type RemoteEnvironmentTarget,
@@ -33,7 +31,6 @@ import type {
33
31
  LettaCodeCloudClientOptions,
34
32
  LettaCodeEnvironment,
35
33
  LettaCodeSocketConstructor,
36
- LettaCodeSocketLike,
37
34
  RepositoryResource,
38
35
  } from "./types.js";
39
36
  import {
@@ -53,24 +50,6 @@ const SDK_AGENT_ORIGIN = "@letta-ai/letta-agent-sdk";
53
50
 
54
51
  type FetchLike = typeof fetch;
55
52
 
56
- type CloudStatusMessage = ProtocolMessage & {
57
- type: string;
58
- seq?: unknown;
59
- event_seq?: unknown;
60
- idempotency_key?: unknown;
61
- runId?: unknown;
62
- run_id?: unknown;
63
- stopReason?: unknown;
64
- stop_reason?: unknown;
65
- conversation_id?: unknown;
66
- conversationId?: unknown;
67
- agent_id?: unknown;
68
- agentId?: unknown;
69
- request?: unknown;
70
- loop_status?: unknown;
71
- delta?: unknown;
72
- };
73
-
74
53
  type CloudRuntimeStartResponse = ProtocolMessage & {
75
54
  type: "runtime_start_response";
76
55
  success: boolean;
@@ -328,231 +307,6 @@ function buildCloudStatusWebSocketUrl(params: {
328
307
  return base.toString();
329
308
  }
330
309
 
331
- function addSocketListener(
332
- socket: LettaCodeSocketLike,
333
- type: string,
334
- listener: (event: unknown) => void,
335
- ): () => void {
336
- if (socket.addEventListener) {
337
- socket.addEventListener(type, listener);
338
- return () => socket.removeEventListener?.(type, listener);
339
- }
340
- if (socket.on) {
341
- socket.on(type, listener);
342
- return () => socket.off?.(type, listener);
343
- }
344
- throw new Error("WebSocket implementation does not support event listeners.");
345
- }
346
-
347
- function messageEventData(event: unknown): string | null {
348
- if (typeof event === "string") return event;
349
- if (event && typeof event === "object") {
350
- const data = (event as { data?: unknown }).data;
351
- if (typeof data === "string") return data;
352
- if (data instanceof ArrayBuffer) {
353
- return new TextDecoder().decode(data);
354
- }
355
- if (data instanceof Uint8Array) {
356
- return new TextDecoder().decode(data);
357
- }
358
- }
359
- return null;
360
- }
361
-
362
- type CloudStatusSocketControl = AppServerSocketLike & {
363
- sendCloudCommand(command: Record<string, unknown>): void;
364
- };
365
-
366
- type CloudStatusSocketState = {
367
- runtime: RuntimeScope;
368
- seenIdempotencyKeys: Set<string>;
369
- seenIdempotencyOrder: string[];
370
- lastEventSeq: number | null;
371
- controlSocket: CloudStatusSocketControl | null;
372
- };
373
-
374
- function createCloudStatusWebSocketConstructor(params: {
375
- cloudOptions: LettaCodeCloudClientOptions;
376
- runtime: RuntimeScope;
377
- }): AppServerSocketConstructor {
378
- const WebSocketCtor = getWebSocketConstructor(params.cloudOptions.WebSocket);
379
- const authMode = params.cloudOptions.webSocketAuth ?? "header";
380
- const cloudHeaders = authMode === "header" ? cloudWebSocketHeaders(params.cloudOptions) : undefined;
381
- const pingIntervalMs = params.cloudOptions.pingIntervalMs ?? DEFAULT_PING_INTERVAL_MS;
382
- const state: CloudStatusSocketState = {
383
- runtime: params.runtime,
384
- seenIdempotencyKeys: new Set<string>(),
385
- seenIdempotencyOrder: [],
386
- lastEventSeq: null,
387
- controlSocket: null,
388
- };
389
-
390
- class CloudStatusSocketAdapter implements AppServerSocketLike {
391
- private readonly socket: LettaCodeSocketLike;
392
- private readonly channel: string | null;
393
- private readonly listenerRemovers = new Map<string, Map<(event: unknown) => void, () => void>>();
394
- private pingTimer: ReturnType<typeof setInterval> | null = null;
395
- private removeCloseListener: (() => void) | null = null;
396
-
397
- constructor(url: string, options?: AppServerSocketOptions) {
398
- this.channel = new URL(url).searchParams.get("channel");
399
- const mergedHeaders = authMode === "header"
400
- ? {
401
- ...(options?.headers ?? {}),
402
- ...(cloudHeaders ?? {}),
403
- }
404
- : undefined;
405
- const socketOptions = mergedHeaders && Object.keys(mergedHeaders).length > 0
406
- ? { headers: mergedHeaders }
407
- : undefined;
408
- this.socket = new WebSocketCtor(url, socketOptions);
409
- if (this.channel === "control") {
410
- state.controlSocket = this;
411
- }
412
- this.removeCloseListener = addSocketListener(this.socket, "close", () => {
413
- this.stopPing();
414
- if (state.controlSocket === this) state.controlSocket = null;
415
- });
416
- this.startPing(pingIntervalMs);
417
- }
418
-
419
- get readyState(): number {
420
- return this.socket.readyState;
421
- }
422
-
423
- send(data: string): void {
424
- this.socket.send(data);
425
- }
426
-
427
- close(): void {
428
- this.stopPing();
429
- this.removeCloseListener?.();
430
- this.removeCloseListener = null;
431
- if (state.controlSocket === this) state.controlSocket = null;
432
- this.socket.close();
433
- }
434
-
435
- addEventListener(type: string, listener: (event: unknown) => void): void {
436
- const wrapped = type === "message"
437
- ? (event: unknown) => {
438
- if (this.handleIncomingMessage(event)) {
439
- listener(event);
440
- }
441
- }
442
- : listener;
443
- const remove = addSocketListener(this.socket, type, wrapped);
444
- let typeRemovers = this.listenerRemovers.get(type);
445
- if (!typeRemovers) {
446
- typeRemovers = new Map();
447
- this.listenerRemovers.set(type, typeRemovers);
448
- }
449
- typeRemovers.set(listener, remove);
450
- }
451
-
452
- removeEventListener(type: string, listener: (event: unknown) => void): void {
453
- const typeRemovers = this.listenerRemovers.get(type);
454
- const remove = typeRemovers?.get(listener);
455
- if (!remove) return;
456
- remove();
457
- typeRemovers?.delete(listener);
458
- if (typeRemovers?.size === 0) {
459
- this.listenerRemovers.delete(type);
460
- }
461
- }
462
-
463
- private handleIncomingMessage(event: unknown): boolean {
464
- const data = messageEventData(event);
465
- if (!data) return true;
466
-
467
- let message: CloudStatusMessage;
468
- try {
469
- message = JSON.parse(data) as CloudStatusMessage;
470
- } catch {
471
- return true;
472
- }
473
-
474
- this.ackIfSequenced(message);
475
-
476
- // The status gateway can mirror device stream frames to the control
477
- // subscriber. Drop them at the Cloud transport boundary instead of making
478
- // shared app-server session code understand Cloud fanout quirks. Do this
479
- // before idempotency/event tracking so the canonical stream-channel frame
480
- // is still delivered and sequenced.
481
- if (this.channel === "control" && message.type === "stream_delta") {
482
- return false;
483
- }
484
-
485
- if (this.isDuplicate(message)) return false;
486
- this.trackEventSeq(message);
487
- return true;
488
- }
489
-
490
- private ackIfSequenced(message: CloudStatusMessage): void {
491
- if (typeof message.seq !== "number") return;
492
- this.sendCloudCommand({ type: "ack", seq: message.seq });
493
- }
494
-
495
- private isDuplicate(message: CloudStatusMessage): boolean {
496
- const idempotencyKey =
497
- typeof message.idempotency_key === "string" ? message.idempotency_key : undefined;
498
- if (!idempotencyKey) return false;
499
- if (state.seenIdempotencyKeys.has(idempotencyKey)) return true;
500
- state.seenIdempotencyKeys.add(idempotencyKey);
501
- state.seenIdempotencyOrder.push(idempotencyKey);
502
- while (state.seenIdempotencyOrder.length > 1_000) {
503
- const oldest = state.seenIdempotencyOrder.shift();
504
- if (oldest) state.seenIdempotencyKeys.delete(oldest);
505
- }
506
- return false;
507
- }
508
-
509
- private trackEventSeq(message: CloudStatusMessage): void {
510
- if (typeof message.event_seq !== "number") return;
511
- if (state.lastEventSeq !== null && message.event_seq > state.lastEventSeq + 1) {
512
- this.sendSync(true);
513
- }
514
- if (state.lastEventSeq === null || message.event_seq > state.lastEventSeq) {
515
- state.lastEventSeq = message.event_seq;
516
- }
517
- }
518
-
519
- private sendSync(recoverApprovals: boolean): void {
520
- const target = state.controlSocket?.readyState === 1 ? state.controlSocket : this;
521
- target.sendCloudCommand({
522
- type: "sync",
523
- runtime: state.runtime,
524
- recover_approvals: recoverApprovals,
525
- force_device_status: true,
526
- });
527
- }
528
-
529
- sendCloudCommand(command: Record<string, unknown>): void {
530
- if (this.readyState !== 1) return;
531
- try {
532
- this.socket.send(JSON.stringify(command));
533
- } catch {
534
- // Best-effort Cloud status reliability command.
535
- }
536
- }
537
-
538
- private startPing(intervalMs: number): void {
539
- if (this.pingTimer) return;
540
- this.pingTimer = setInterval(() => {
541
- this.sendCloudCommand({ type: "ping" });
542
- }, intervalMs);
543
- (this.pingTimer as { unref?: () => void }).unref?.();
544
- }
545
-
546
- private stopPing(): void {
547
- if (!this.pingTimer) return;
548
- clearInterval(this.pingTimer);
549
- this.pingTimer = null;
550
- }
551
- }
552
-
553
- return CloudStatusSocketAdapter as AppServerSocketConstructor;
554
- }
555
-
556
310
  function isCloudConversation(value: unknown): value is CloudConversation & { id: string } {
557
311
  return Boolean(value && typeof value === "object" && typeof (value as CloudConversation).id === "string");
558
312
  }
@@ -638,24 +392,6 @@ export function assertCloudSessionOptionsSupported(
638
392
  if (options.environment !== undefined && options.sandbox !== undefined) {
639
393
  throw new Error(`Letta Cloud ${action}() cannot specify both environment and sandbox options.`);
640
394
  }
641
- if (options.systemPrompt !== undefined) {
642
- throw new Error(`Letta Cloud ${action}() cannot rewrite an existing agent's systemPrompt from the SDK adapter yet.`);
643
- }
644
- if (options.disallowedTools !== undefined) {
645
- throw new Error(`Letta Cloud ${action}() has not wired disallowedTools to the remote device protocol yet.`);
646
- }
647
- if (options.systemInfoReminder !== undefined) {
648
- throw new Error(`Letta Cloud ${action}() has not wired systemInfoReminder to the remote device protocol yet.`);
649
- }
650
- if (options.dreaming?.behavior !== undefined) {
651
- throw new Error(`Letta Cloud ${action}() does not yet support dreaming.behavior overrides.`);
652
- }
653
- if ((options as { memfsStartup?: unknown }).memfsStartup !== undefined) {
654
- throw new Error(`Letta Cloud ${action}() does not support memfsStartup.`);
655
- }
656
- if (options.includePartialMessages !== undefined) {
657
- throw new Error(`Letta Cloud ${action}() streams Remote Client deltas directly; includePartialMessages is not a separate toggle.`);
658
- }
659
395
  }
660
396
 
661
397
  export class CloudEnvironmentSession extends RemoteClientSessionCore {
@@ -668,6 +404,7 @@ export class CloudEnvironmentSession extends RemoteClientSessionCore {
668
404
  private sandboxRefreshInFlight: Promise<void> | null = null;
669
405
  private sandboxLifecycleClosing = false;
670
406
  private attachedRepositoryIds = new Set<string>();
407
+ private repositoryIdsRequiringCleanupRecompile = new Set<string>();
671
408
  private readonly cloudMode: CloudSessionMode;
672
409
 
673
410
  constructor(
@@ -685,7 +422,15 @@ export class CloudEnvironmentSession extends RemoteClientSessionCore {
685
422
 
686
423
  protected override async initializeRuntimeController(): Promise<RuntimeSessionInit> {
687
424
  const resolved = await this.resolveRuntime();
688
- const connection = await this.resolveConnectionForRuntime(resolved.runtime);
425
+ const connection = await this.resolveConnectionForRuntime(resolved.runtime).catch(
426
+ async (error: unknown) => {
427
+ await this.cleanupSessionRepositories(
428
+ resolved.runtime.agent_id,
429
+ resolved.runtime.conversation_id,
430
+ );
431
+ throw error;
432
+ },
433
+ );
689
434
  this.connectionId = connection.connectionId;
690
435
 
691
436
  const apiKey = getCloudApiKey(this.cloudOptions);
@@ -699,8 +444,14 @@ export class CloudEnvironmentSession extends RemoteClientSessionCore {
699
444
  });
700
445
  const client = applyUniqueRequestIds(createAppServerClient({
701
446
  url,
702
- WebSocket: createCloudStatusWebSocketConstructor({
703
- cloudOptions: this.cloudOptions,
447
+ WebSocket: createCloudStatusTransportConstructor({
448
+ url,
449
+ WebSocket: getWebSocketConstructor(this.cloudOptions.WebSocket),
450
+ ...((this.cloudOptions.webSocketAuth ?? "header") === "header"
451
+ ? { headers: cloudWebSocketHeaders(this.cloudOptions) }
452
+ : {}),
453
+ pingIntervalMs:
454
+ this.cloudOptions.pingIntervalMs ?? DEFAULT_PING_INTERVAL_MS,
704
455
  runtime: resolved.runtime,
705
456
  }),
706
457
  requestTimeoutMs: this.cloudOptions.requestTimeoutMs ?? DEFAULT_TURN_TIMEOUT_MS,
@@ -746,7 +497,10 @@ export class CloudEnvironmentSession extends RemoteClientSessionCore {
746
497
  this.removeControlRequestHandler = null;
747
498
  client.close();
748
499
  await this.cleanupManagedSandbox();
749
- await this.cleanupSessionRepositories(resolved.runtime.agent_id);
500
+ await this.cleanupSessionRepositories(
501
+ resolved.runtime.agent_id,
502
+ resolved.runtime.conversation_id,
503
+ );
750
504
  throw error;
751
505
  }
752
506
  }
@@ -803,7 +557,12 @@ export class CloudEnvironmentSession extends RemoteClientSessionCore {
803
557
  this.removeControlRequestHandler?.();
804
558
  this.removeControlRequestHandler = null;
805
559
  void this.cleanupManagedSandbox();
806
- if (this.runtime?.agent_id) void this.cleanupSessionRepositories(this.runtime.agent_id);
560
+ if (this.runtime?.agent_id) {
561
+ void this.cleanupSessionRepositories(
562
+ this.runtime.agent_id,
563
+ this.runtime.conversation_id,
564
+ );
565
+ }
807
566
  }
808
567
 
809
568
  private async resolveRuntime(): Promise<{ runtime: RuntimeScope }> {
@@ -824,27 +583,37 @@ export class CloudEnvironmentSession extends RemoteClientSessionCore {
824
583
  );
825
584
  }
826
585
 
827
- await this.attachSessionRepositories(agentId);
586
+ const shouldRecompileRepositories =
587
+ await this.attachSessionRepositories(agentId);
828
588
 
829
- if (this.cloudMode.newConversation) {
830
- const conversation = await this.createConversation(agentId);
831
- conversationId = conversation.id;
832
- } else if (this.cloudMode.defaultConversation) {
833
- conversationId = "default";
834
- }
589
+ try {
590
+ if (this.cloudMode.newConversation) {
591
+ const conversation = await this.createConversation(agentId);
592
+ conversationId = conversation.id;
593
+ } else if (this.cloudMode.defaultConversation) {
594
+ conversationId = "default";
595
+ }
835
596
 
836
- if (!conversationId) {
837
- throw new Error(
838
- "Letta Cloud createSession()/resumeSession() requires an agent id or conversation id.",
839
- );
840
- }
597
+ if (!conversationId) {
598
+ throw new Error(
599
+ "Letta Cloud createSession()/resumeSession() requires an agent id or conversation id.",
600
+ );
601
+ }
602
+
603
+ if (shouldRecompileRepositories) {
604
+ await this.recompileSystemPrompt(agentId, conversationId);
605
+ }
841
606
 
842
- return { runtime: { agent_id: agentId, conversation_id: conversationId } };
607
+ return { runtime: { agent_id: agentId, conversation_id: conversationId } };
608
+ } catch (error) {
609
+ await this.cleanupSessionRepositories(agentId, conversationId);
610
+ throw error;
611
+ }
843
612
  }
844
613
 
845
- private async attachSessionRepositories(agentId: string): Promise<void> {
614
+ private async attachSessionRepositories(agentId: string): Promise<boolean> {
846
615
  const resources = this.repositoryResources();
847
- if (resources.length === 0) return;
616
+ if (resources.length === 0) return false;
848
617
 
849
618
  const existing = await this.listAgentRepositories(agentId);
850
619
  const existingIds = new Set(existing.map((repository) => repository.id));
@@ -862,18 +631,75 @@ export class CloudEnvironmentSession extends RemoteClientSessionCore {
862
631
  await this.cleanupSessionRepositories(agentId);
863
632
  throw error;
864
633
  }
634
+
635
+ // Recompile even when every requested repository was already linked: the
636
+ // target conversation may still have been compiled before those links
637
+ // existed. One recompile after the full batch produces the desired state.
638
+ const shouldRecompile = resources.some(
639
+ (resource) => resource.recompile !== false,
640
+ );
641
+ this.repositoryIdsRequiringCleanupRecompile = new Set(
642
+ resources
643
+ .filter(
644
+ (resource) =>
645
+ resource.recompile !== false &&
646
+ this.attachedRepositoryIds.has(resource.repositoryId),
647
+ )
648
+ .map((resource) => resource.repositoryId),
649
+ );
650
+ return shouldRecompile;
865
651
  }
866
652
 
867
- private async cleanupSessionRepositories(agentId: string): Promise<void> {
653
+ private async cleanupSessionRepositories(
654
+ agentId: string,
655
+ conversationId?: string,
656
+ ): Promise<void> {
868
657
  const repositoryIds = [...this.attachedRepositoryIds];
658
+ const repositoryIdsRequiringRecompile =
659
+ this.repositoryIdsRequiringCleanupRecompile;
869
660
  this.attachedRepositoryIds.clear();
870
- await Promise.all(repositoryIds.map(async (repositoryId) => {
661
+ this.repositoryIdsRequiringCleanupRecompile = new Set<string>();
662
+ const recompileAfterDetach = await Promise.all(repositoryIds.map(async (repositoryId) => {
871
663
  try {
872
664
  await this.unlinkAgentRepository(agentId, repositoryId);
665
+ return repositoryIdsRequiringRecompile.has(repositoryId);
873
666
  } catch {
874
667
  // Best-effort cleanup: only repositories this SDK session attached are removed.
668
+ return false;
875
669
  }
876
670
  }));
671
+ // Best-effort recompile after detach so remaining conversations drop stale
672
+ // repository projections. Errors are swallowed because this runs on close.
673
+ if (recompileAfterDetach.some(Boolean)) {
674
+ try {
675
+ await this.recompileSystemPrompt(agentId, conversationId);
676
+ } catch {
677
+ // Best-effort: cleanup must not throw.
678
+ }
679
+ }
680
+ }
681
+
682
+ private async recompileSystemPrompt(
683
+ agentId: string,
684
+ conversationId?: string,
685
+ ): Promise<void> {
686
+ const fetchImpl = getFetch(this.cloudOptions.fetch);
687
+ const baseUrl = normalizeCloudApiBaseUrl(this.cloudOptions.apiBaseUrl);
688
+ const usesAgentPrompt = !conversationId || conversationId === "default";
689
+ const path = usesAgentPrompt
690
+ ? `/v1/agents/${encodeURIComponent(agentId)}/recompile`
691
+ : `/v1/conversations/${encodeURIComponent(conversationId)}/recompile`;
692
+ const url = `${baseUrl}${path}`;
693
+ const response = await fetchImpl(
694
+ url,
695
+ {
696
+ method: "POST",
697
+ headers: cloudHeaders(this.cloudOptions),
698
+ body: JSON.stringify(usesAgentPrompt ? {} : { agent_id: agentId }),
699
+ },
700
+ );
701
+ const body = await parseJsonResponse(response);
702
+ assertOkResponse(response, body, "Cloud recompile system prompt", url);
877
703
  }
878
704
 
879
705
  private repositoryResources(): RepositoryResource[] {