@letta-ai/letta-agent-sdk 0.6.2 → 0.7.0

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 (44) hide show
  1. package/AGENTS.md +42 -0
  2. package/README.md +101 -1
  3. package/dist/app-server-session.d.ts.map +1 -1
  4. package/dist/client-base.d.ts +7 -0
  5. package/dist/client-base.d.ts.map +1 -1
  6. package/dist/client-entry.d.ts +3 -0
  7. package/dist/client-entry.d.ts.map +1 -1
  8. package/dist/client-entry.js +816 -138
  9. package/dist/client-entry.js.map +16 -14
  10. package/dist/cloud-session.d.ts +8 -1
  11. package/dist/cloud-session.d.ts.map +1 -1
  12. package/dist/computers.d.ts +67 -0
  13. package/dist/computers.d.ts.map +1 -0
  14. package/dist/index.d.ts +4 -1
  15. package/dist/index.d.ts.map +1 -1
  16. package/dist/index.js +817 -139
  17. package/dist/index.js.map +17 -15
  18. package/dist/remote-client-session-core.d.ts +13 -16
  19. package/dist/remote-client-session-core.d.ts.map +1 -1
  20. package/dist/remote-session-protocol.d.ts +11 -0
  21. package/dist/remote-session-protocol.d.ts.map +1 -1
  22. package/dist/remote-turn-coordinator.d.ts +6 -1
  23. package/dist/remote-turn-coordinator.d.ts.map +1 -1
  24. package/dist/remote.d.ts +6 -1
  25. package/dist/remote.d.ts.map +1 -1
  26. package/dist/transcript-accumulator.d.ts +133 -0
  27. package/dist/transcript-accumulator.d.ts.map +1 -0
  28. package/dist/types.d.ts +41 -21
  29. package/dist/types.d.ts.map +1 -1
  30. package/dist/validation.d.ts.map +1 -1
  31. package/package.json +2 -2
  32. package/src/app-server-session.ts +9 -0
  33. package/src/client-base.ts +66 -19
  34. package/src/client-entry.ts +23 -0
  35. package/src/cloud-session.ts +112 -45
  36. package/src/computers.ts +132 -0
  37. package/src/index.ts +24 -0
  38. package/src/remote-client-session-core.ts +103 -27
  39. package/src/remote-session-protocol.ts +24 -0
  40. package/src/remote-turn-coordinator.ts +11 -2
  41. package/src/remote.ts +41 -12
  42. package/src/transcript-accumulator.ts +823 -0
  43. package/src/types.ts +43 -18
  44. package/src/validation.ts +16 -0
@@ -20,6 +20,7 @@ import type {
20
20
  SDKResultMessage,
21
21
  SendCommandOptions,
22
22
  SendMessage,
23
+ SendOptions,
23
24
  SessionDeviceStatus,
24
25
  UpdateModelOptions,
25
26
  UpdateModelResult,
@@ -43,6 +44,7 @@ import {
43
44
  resolveDreamingSettings,
44
45
  sameContextCandidates,
45
46
  toBaseModelHandle,
47
+ turnSendOptions,
46
48
  type NormalizedUpdateModelInput,
47
49
  type RemoteClientSessionCoreConfig,
48
50
  type RemoteClientRuntimeController,
@@ -78,6 +80,9 @@ export abstract class RemoteClientSessionCore implements LettaCodeSession {
78
80
  private initializePromise: Promise<SDKInitMessage> | null = null;
79
81
  private removeMessageHandler: (() => void) | null = null;
80
82
  private detachTransportDisconnect: (() => void) | null = null;
83
+ private idleTransportDisconnected = false;
84
+ private transportRecoveryPromise: Promise<void> | null = null;
85
+ private transportDisconnectGeneration = 0;
81
86
  private readonly turns: RemoteTurnCoordinator;
82
87
  private toolNames: string[] | undefined;
83
88
  private deviceStatusListeners = new Set<(status: SessionDeviceStatus) => void>();
@@ -122,8 +127,7 @@ export abstract class RemoteClientSessionCore implements LettaCodeSession {
122
127
  const attempt = this.performInitialize();
123
128
  const memo = attempt
124
129
  .catch((error: unknown) => {
125
- // Tear down only this attempt's partial state. Never close() the
126
- // session here: a failed remote attempt is retryable.
130
+ // Tear down only this attempt's partial state. A failed remote attempt is retryable.
127
131
  this.cleanupFailedInitialize();
128
132
  throw error;
129
133
  })
@@ -164,8 +168,7 @@ export abstract class RemoteClientSessionCore implements LettaCodeSession {
164
168
  throw new Error("Session is closed");
165
169
  }
166
170
 
167
- // This is the lifecycle commit point. Lazy entry points must continue to
168
- // await initializePromise until every post-initialize option is applied.
171
+ // Lazy entry points must await this lifecycle commit after all post-initialize options.
169
172
  this.initialized = true;
170
173
 
171
174
  const initMessage: SDKInitMessage = {
@@ -206,21 +209,26 @@ export abstract class RemoteClientSessionCore implements LettaCodeSession {
206
209
  this.initialized = false;
207
210
  }
208
211
 
209
- async send(message: SendMessage): Promise<void> {
212
+ async send(message: SendMessage, options?: SendOptions): Promise<void> {
213
+ if (this.closed) throw new Error("Session is closed");
210
214
  if (!this.initialized) {
211
215
  await this.initialize();
212
216
  }
217
+ await this.recoverIdleTransportIfNeeded();
213
218
  if (!this.controller || !this.runtime) {
214
219
  throw new Error("Session is not initialized");
215
220
  }
216
221
 
217
222
  await this.beforeTurn();
218
223
 
219
- const turn = this.turns.trackSentTurn(this.runtime);
224
+ const controller = this.controller;
225
+ const runtime = this.runtime;
226
+ if (!controller || !runtime) {
227
+ throw new Error("Session transport disconnected before the turn was sent");
228
+ }
229
+ const turn = this.turns.trackSentTurn(runtime, options?.otid);
220
230
  try {
221
- this.controller.sendTurnMessage(this.runtime, message, {
222
- clientMessageId: turn.clientMessageId,
223
- });
231
+ controller.sendTurnMessage(runtime, message, turnSendOptions(turn));
224
232
  } catch (error) {
225
233
  this.turns.removeTrackedTurn(turn);
226
234
  throw error;
@@ -317,6 +325,11 @@ export abstract class RemoteClientSessionCore implements LettaCodeSession {
317
325
  }
318
326
 
319
327
  async updateModel(update: string | UpdateModelOptions): Promise<UpdateModelResult> {
328
+ if (this.mode.kind === "session" && this.mode.options.stateless === true) {
329
+ throw new Error(
330
+ "updateModel() is unavailable in a stateless session because it changes persisted agent configuration.",
331
+ );
332
+ }
320
333
  if (!this.initialized) {
321
334
  await this.initialize();
322
335
  }
@@ -556,6 +569,7 @@ export abstract class RemoteClientSessionCore implements LettaCodeSession {
556
569
  this.deviceStatusListeners.clear();
557
570
  this.controller?.close();
558
571
  this.controller = null;
572
+ this.idleTransportDisconnected = false;
559
573
  this.onCoreClose();
560
574
  }
561
575
 
@@ -625,27 +639,31 @@ export abstract class RemoteClientSessionCore implements LettaCodeSession {
625
639
  protected abstract initializeRuntimeController(): Promise<RuntimeSessionInit>;
626
640
 
627
641
  /**
628
- * Fail the session when its transport drops unexpectedly.
629
- *
630
- * Without this an in-flight turn parks forever: nextMessage() only settles
631
- * through the coordinator, and the socket's own pending-request rejection
632
- * covers request/response commands, not streaming turns.
633
- *
634
- * The session is not revived — the runtime lived on the dead socket. The
635
- * caller observes the error and rebuilds via resumeSession().
636
- *
637
- * Subclasses call this only once their runtime is live: a drop before that
638
- * already surfaces as a rejected connect or runtime-start, and failing the
639
- * session there would defeat initialize()'s retry path. Explicit closes do
640
- * not notify, so this fires for faults only. Detaching stays here so every
641
- * teardown path — clean close and failed initialize alike — covers it.
642
+ * Active turns fail because the input may have reached the listener. Cloud
643
+ * sessions may recover an idle connection before the next turn is tracked.
642
644
  */
643
- protected watchTransportDisconnect(client: {
644
- onDisconnect(handler: () => void): () => void;
645
- }): void {
645
+ protected watchTransportDisconnect(
646
+ client: { onDisconnect(handler: () => void): () => void },
647
+ options: { recoverWhenIdle?: boolean } = {},
648
+ ): void {
646
649
  this.detachTransportDisconnect?.();
647
650
  this.detachTransportDisconnect = client.onDisconnect(() => {
648
651
  if (this.closed) return;
652
+ this.transportDisconnectGeneration += 1;
653
+ if (options.recoverWhenIdle && !this.turns.hasInFlightTurn()) {
654
+ this.detachTransportDisconnect?.();
655
+ this.detachTransportDisconnect = null;
656
+ this.removeMessageHandler?.();
657
+ this.removeMessageHandler = null;
658
+ this.controller?.close();
659
+ this.controller = null;
660
+ const error = new Error(`${this.label} connection closed before send`);
661
+ for (const cancel of [...this.deviceStatusRefreshCancels]) cancel(error);
662
+ this.deviceStatusRefreshCancels.clear();
663
+ this.onIdleTransportDisconnect();
664
+ this.idleTransportDisconnected = true;
665
+ return;
666
+ }
649
667
  this.turns.closeWithError(
650
668
  `The ${this.label} connection closed unexpectedly; resume the conversation to continue.`,
651
669
  );
@@ -653,6 +671,62 @@ export abstract class RemoteClientSessionCore implements LettaCodeSession {
653
671
  });
654
672
  }
655
673
 
674
+ private async recoverIdleTransportIfNeeded(): Promise<void> {
675
+ if (!this.idleTransportDisconnected) return;
676
+ if (this.transportRecoveryPromise) {
677
+ await this.transportRecoveryPromise;
678
+ return;
679
+ }
680
+ const runtime = this.runtime;
681
+ if (!runtime) throw new Error("Session transport disconnected without a runtime");
682
+ const generation = this.transportDisconnectGeneration;
683
+ const recovery = this.recoverIdleTransport(runtime)
684
+ .then(async (init) => {
685
+ if (this.closed || generation !== this.transportDisconnectGeneration) {
686
+ init.controller.close();
687
+ this.onRecoveredTransportDiscarded();
688
+ throw new Error(`${this.label} connection closed during recovery`);
689
+ }
690
+ if (
691
+ init.runtime.agent_id !== runtime.agent_id ||
692
+ init.runtime.conversation_id !== runtime.conversation_id
693
+ ) {
694
+ init.controller.close();
695
+ this.onRecoveredTransportDiscarded();
696
+ throw new Error(`${this.label} transport recovered a different runtime`);
697
+ }
698
+ this.controller = init.controller;
699
+ this.runtime = init.runtime;
700
+ this._modelSettings = init.modelSettings ?? this._modelSettings;
701
+ if (typeof init.model === "string" && init.model) this._model = init.model;
702
+ if (init.tools !== undefined) this.toolNames = init.tools;
703
+ this.removeMessageHandler = this.controller.onMessage((message) => {
704
+ if (this.runtime) this.turns.handleProtocolMessage(message, this.runtime);
705
+ });
706
+ this.idleTransportDisconnected = false;
707
+ await this.afterRuntimeInitialized();
708
+ })
709
+ .finally(() => {
710
+ if (this.transportRecoveryPromise === recovery) this.transportRecoveryPromise = null;
711
+ });
712
+ this.transportRecoveryPromise = recovery;
713
+ await recovery;
714
+ }
715
+
716
+ protected async recoverIdleTransport(
717
+ _runtime: RuntimeScope,
718
+ ): Promise<RuntimeSessionInit> {
719
+ throw new Error(`${this.label} sessions do not support idle transport recovery`);
720
+ }
721
+
722
+ protected onIdleTransportDisconnect(): void {
723
+ // Optional hook for transport-specific handler cleanup before recovery.
724
+ }
725
+
726
+ protected onRecoveredTransportDiscarded(): void {
727
+ // Optional hook when a recovery finishes after the session closed or dropped again.
728
+ }
729
+
656
730
  protected async afterRuntimeInitialized(): Promise<void> {
657
731
  // Optional hook for subclasses to send transport-specific startup frames.
658
732
  }
@@ -779,7 +853,9 @@ export abstract class RemoteClientSessionCore implements LettaCodeSession {
779
853
  }
780
854
 
781
855
  const dreamingSettings = resolveDreamingSettings(options.dreaming);
782
- if (dreamingSettings) {
856
+ const isStatelessSession =
857
+ this.mode.kind === "session" && this.mode.options.stateless === true;
858
+ if (dreamingSettings && !isStatelessSession) {
783
859
  const response = await this.controller.request(
784
860
  "set_reflection_settings",
785
861
  {
@@ -55,6 +55,8 @@ export type RuntimeTurnResult = {
55
55
 
56
56
  export type RuntimeSendTurnOptions = {
57
57
  clientMessageId: string;
58
+ /** Caller-supplied OTID for the user message, when one was provided. */
59
+ otid?: string;
58
60
  };
59
61
 
60
62
  export type RuntimeRequestOptions = {
@@ -128,6 +130,8 @@ export type TurnTracker = {
128
130
  id: number;
129
131
  runtime: RuntimeScope;
130
132
  clientMessageId: string;
133
+ /** Caller-supplied OTID for this turn's user message, when one was provided. */
134
+ otid?: string;
131
135
  queuedAt: number;
132
136
  startedAt: number;
133
137
  assistantText: string;
@@ -677,3 +681,23 @@ export function toSessionDeviceStatus(
677
681
  export function normalizeSendMessage(message: SendMessage): string | MessageContentItem[] {
678
682
  return message;
679
683
  }
684
+
685
+ /**
686
+ * Validate a caller-supplied OTID. Empty/whitespace-only values would silently
687
+ * defeat correlation, so they are rejected instead of being dropped.
688
+ */
689
+ export function normalizeCallerOtid(otid: string | undefined): string | undefined {
690
+ if (otid === undefined) return undefined;
691
+ if (typeof otid !== "string" || otid.trim() === "") {
692
+ throw new Error("send() otid must be a non-empty string");
693
+ }
694
+ return otid;
695
+ }
696
+
697
+ /** Wire correlation options for a tracked turn. */
698
+ export function turnSendOptions(turn: TurnTracker): RuntimeSendTurnOptions {
699
+ return {
700
+ clientMessageId: turn.clientMessageId,
701
+ ...(turn.otid !== undefined ? { otid: turn.otid } : {}),
702
+ };
703
+ }
@@ -15,6 +15,7 @@ import {
15
15
  isApprovalConflictSignal,
16
16
  loopStatusRunIds,
17
17
  loopStatusValue,
18
+ normalizeCallerOtid,
18
19
  queueItems,
19
20
  sameRuntime,
20
21
  streamDeltaMessageType,
@@ -76,11 +77,19 @@ export class RemoteTurnCoordinator {
76
77
  return this.activeTurn !== null || this.pendingTurns.length > 0;
77
78
  }
78
79
 
79
- trackSentTurn(runtime: RuntimeScope): TurnTracker {
80
+ /**
81
+ * Start tracking a turn. A caller-supplied OTID doubles as the turn's
82
+ * `clientMessageId` so queue updates carry the same correlation id the
83
+ * persisted message will; otherwise the SDK mints one.
84
+ */
85
+ trackSentTurn(runtime: RuntimeScope, callerOtid?: string): TurnTracker {
86
+ const otid = normalizeCallerOtid(callerOtid);
80
87
  const turn: TurnTracker = {
81
88
  id: ++this.nextTurnId,
82
89
  runtime,
83
- clientMessageId: `sdk-message-${Date.now()}-${++this.clientMessageCounter}`,
90
+ ...(otid !== undefined ? { otid } : {}),
91
+ clientMessageId:
92
+ otid ?? `sdk-message-${Date.now()}-${++this.clientMessageCounter}`,
84
93
  queuedAt: Date.now(),
85
94
  startedAt: 0,
86
95
  assistantText: "",
package/src/remote.ts CHANGED
@@ -35,6 +35,12 @@ export interface RemoteEnvironmentConnection {
35
35
  metadata?: Record<string, unknown>;
36
36
  }
37
37
 
38
+ export interface RemoteEnvironmentListOptions {
39
+ limit?: number;
40
+ after?: string;
41
+ onlineOnly?: boolean;
42
+ }
43
+
38
44
  export interface RemoteEnvironmentListResult {
39
45
  connections: RemoteEnvironmentConnection[];
40
46
  hasNextPage: boolean;
@@ -59,7 +65,7 @@ function ensureOnline(
59
65
  : "connectionName" in target
60
66
  ? target.connectionName
61
67
  : environment.deviceId;
62
- throw new Error(`Remote environment is offline: ${label}`);
68
+ throw new Error(`Computer is offline: ${label}`);
63
69
  }
64
70
 
65
71
  return {
@@ -84,8 +90,16 @@ export class RemoteEnvironmentClient {
84
90
  }),
85
91
  ) {}
86
92
 
87
- async listEnvironments(): Promise<RemoteEnvironmentListResult> {
88
- return await this.client.environments.list() as RemoteEnvironmentListResult;
93
+ async listEnvironments(
94
+ options: RemoteEnvironmentListOptions = {},
95
+ ): Promise<RemoteEnvironmentListResult> {
96
+ return await this.client.environments.list({
97
+ after: options.after,
98
+ limit: options.limit === undefined ? undefined : String(options.limit),
99
+ onlineOnly: options.onlineOnly === undefined
100
+ ? undefined
101
+ : String(options.onlineOnly),
102
+ }) as RemoteEnvironmentListResult;
89
103
  }
90
104
 
91
105
  async getEnvironmentByDeviceId(deviceId: string): Promise<RemoteEnvironmentConnection> {
@@ -103,7 +117,15 @@ export class RemoteEnvironmentClient {
103
117
  return ensureOnline(await this.getEnvironmentByDeviceId(target.deviceId), target);
104
118
  }
105
119
 
106
- const { connections } = await this.listEnvironments();
120
+ const connections: RemoteEnvironmentConnection[] = [];
121
+ let after: string | undefined;
122
+ do {
123
+ const page = await this.listEnvironments({ limit: 100, after });
124
+ connections.push(...page.connections);
125
+ if (!page.hasNextPage || page.connections.length === 0) break;
126
+ after = page.connections.at(-1)?.id;
127
+ } while (after !== undefined);
128
+
107
129
  if ("environmentId" in target) {
108
130
  const match = connections.find((env) => env.id === target.environmentId);
109
131
  if (!match) {
@@ -116,17 +138,24 @@ export class RemoteEnvironmentClient {
116
138
  (env) => env.connectionName === target.connectionName,
117
139
  );
118
140
  if (matches.length === 0) {
119
- throw new Error(`Remote environment not found: ${target.connectionName}`);
141
+ throw new Error(`Computer not found: ${target.connectionName}`);
120
142
  }
121
- if (matches.length > 1) {
143
+ const onlineMatches = matches.filter(
144
+ (environment) => environment.connectionId !== null,
145
+ );
146
+ if (onlineMatches.length === 0) {
147
+ throw new Error(`Computer is offline: ${target.connectionName}`);
148
+ }
149
+ if (onlineMatches.length > 1) {
150
+ const candidates = onlineMatches
151
+ .map((environment) =>
152
+ `${environment.connectionName} (${environment.deviceId}, online)`
153
+ )
154
+ .join(", ");
122
155
  throw new Error(
123
- `Remote environment name is ambiguous: ${target.connectionName}`,
156
+ `Computer name is ambiguous: ${target.connectionName}. Matches: ${candidates}. Select one by deviceId.`,
124
157
  );
125
158
  }
126
- const match = matches[0];
127
- if (!match) {
128
- throw new Error(`Remote environment not found: ${target.connectionName}`);
129
- }
130
- return ensureOnline(match, target);
159
+ return ensureOnline(onlineMatches[0]!, target);
131
160
  }
132
161
  }