@letta-ai/letta-agent-sdk 0.3.3 → 0.5.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 (55) hide show
  1. package/AGENTS.md +0 -3
  2. package/README.md +12 -0
  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 +369 -468
  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.map +1 -1
  14. package/dist/cloud-status-transport.d.ts +53 -0
  15. package/dist/cloud-status-transport.d.ts.map +1 -0
  16. package/dist/index.d.ts +7 -16
  17. package/dist/index.d.ts.map +1 -1
  18. package/dist/index.js +896 -2019
  19. package/dist/index.js.map +18 -18
  20. package/dist/local-app-server-session.d.ts +1 -1
  21. package/dist/local-app-server-session.d.ts.map +1 -1
  22. package/dist/local-app-server.d.ts +11 -0
  23. package/dist/local-app-server.d.ts.map +1 -1
  24. package/dist/management.d.ts +0 -7
  25. package/dist/management.d.ts.map +1 -1
  26. package/dist/remote-session-protocol.d.ts +2 -0
  27. package/dist/remote-session-protocol.d.ts.map +1 -1
  28. package/dist/remote-turn-coordinator.d.ts.map +1 -1
  29. package/dist/types.d.ts +57 -66
  30. package/dist/types.d.ts.map +1 -1
  31. package/dist/validation.d.ts.map +1 -1
  32. package/package.json +2 -2
  33. package/src/app-server-management.ts +20 -256
  34. package/src/app-server-session.ts +0 -32
  35. package/src/client-base.ts +47 -73
  36. package/src/client.ts +31 -70
  37. package/src/cloud-session.ts +9 -267
  38. package/src/cloud-status-transport.ts +305 -0
  39. package/src/index.ts +11 -29
  40. package/src/local-app-server-session.ts +13 -3
  41. package/src/local-app-server.ts +97 -3
  42. package/src/management.ts +0 -7
  43. package/src/remote-session-protocol.ts +14 -0
  44. package/src/remote-turn-coordinator.ts +20 -2
  45. package/src/types.ts +64 -111
  46. package/src/validation.ts +21 -6
  47. package/dist/protocol.d.ts +0 -205
  48. package/dist/protocol.d.ts.map +0 -1
  49. package/dist/session.d.ts +0 -155
  50. package/dist/session.d.ts.map +0 -1
  51. package/dist/transport.d.ts +0 -53
  52. package/dist/transport.d.ts.map +0 -1
  53. package/src/protocol.ts +0 -249
  54. package/src/session.ts +0 -1638
  55. package/src/transport.ts +0 -484
@@ -3,7 +3,6 @@ import {
3
3
  type AppServerClient,
4
4
  type AppServerRequestCommandWithId,
5
5
  type AppServerSocketConstructor,
6
- type AppServerSocketLike,
7
6
  } from "@letta-ai/letta-code/app-server-client";
8
7
  import type {
9
8
  ManagementQuery,
@@ -59,92 +58,14 @@ export type AppServerManagementOptions =
59
58
  Partial<LettaCodeRemoteClientOptions> & {
60
59
  url?: string;
61
60
  connect?: () => Promise<OwnedConnection>;
62
- /**
63
- * How long (in milliseconds) an idle control connection lingers before it
64
- * is released. Defaults to {@link DEFAULT_IDLE_LINGER_MS}.
65
- */
66
- idleLingerMs?: number;
67
61
  };
68
62
 
69
- /**
70
- * How long an idle control connection lingers before it is released.
71
- *
72
- * Long enough to batch a burst of management calls (for example a screen
73
- * fetching a list plus a retrieve together) over one connection, short enough
74
- * that the app-server's single control-client slot frees up quickly for
75
- * sessions.
76
- */
77
- const DEFAULT_IDLE_LINGER_MS = 250;
78
-
79
63
  type ActiveConnection = {
80
64
  client: AppServerClient;
81
65
  ownedConnection: OwnedConnection | null;
82
66
  detachDisconnect: () => void;
83
- unregister: () => void;
84
- };
85
-
86
- type RegisteredManagementTransport = {
87
- releaseIdleConnection(): Promise<void>;
88
- };
89
-
90
- type ManagementTransportRegistration = {
91
- transport: RegisteredManagementTransport;
92
67
  };
93
68
 
94
- const registeredTransportsByUrl =
95
- new Map<string, Set<ManagementTransportRegistration>>();
96
-
97
- function appServerUrlKey(value: string): string {
98
- try {
99
- const url = new URL(value);
100
- if (url.protocol === "http:") url.protocol = "ws:";
101
- if (url.protocol === "https:") url.protocol = "wss:";
102
- url.hash = "";
103
- url.searchParams.delete("channel");
104
- return url.toString();
105
- } catch {
106
- return value;
107
- }
108
- }
109
-
110
- function registerManagementTransport(
111
- url: string,
112
- transport: RegisteredManagementTransport,
113
- ): () => void {
114
- const key = appServerUrlKey(url);
115
- const registration = { transport };
116
- const transports =
117
- registeredTransportsByUrl.get(key) ??
118
- new Set<ManagementTransportRegistration>();
119
- transports.add(registration);
120
- registeredTransportsByUrl.set(key, transports);
121
- return () => {
122
- transports.delete(registration);
123
- if (transports.size === 0) {
124
- registeredTransportsByUrl.delete(key);
125
- }
126
- };
127
- }
128
-
129
- /**
130
- * Wait for every management transport targeting this app-server to release
131
- * its control connection. The registry is process-wide so a session created
132
- * by a different `LettaAgentClient` instance can still take the server's
133
- * single control slot safely.
134
- */
135
- export async function releaseAppServerManagementConnections(
136
- url: string,
137
- ): Promise<void> {
138
- const key = appServerUrlKey(url);
139
- while (true) {
140
- const transports = [...(registeredTransportsByUrl.get(key) ?? [])];
141
- if (transports.length === 0) return;
142
- await Promise.all(
143
- transports.map(({ transport }) => transport.releaseIdleConnection()),
144
- );
145
- }
146
- }
147
-
148
69
  function ensureResponse<T>(
149
70
  response: { success: boolean; error?: string },
150
71
  value: T | null | undefined,
@@ -192,40 +113,17 @@ function stringRecord(value: unknown): Record<string, string> | undefined {
192
113
  /**
193
114
  * Management transport that speaks the app-server control protocol.
194
115
  *
195
- * Connection lifecycle: the Letta Code app-server currently accepts a single
196
- * control client at a time — while one control socket is attached, additional
197
- * control sockets are rejected with close code 1008
198
- * `"control channel already connected"` (see letta-ai/letta-code
199
- * `src/websocket/app-server.ts`). Sessions (`AppServerSession`) need that same
200
- * control slot, so a management transport that holds an idle connection
201
- * starves any later `resumeSession()`/`createSession()` from the same process
202
- * (live-reproduced in the letta-mobile reference app, SDK-FEEDBACK.md #00).
203
- *
204
- * To stay out of the way, this transport pools a single lazily-connected
205
- * client while requests are in flight (bursts share one connection and one
206
- * request-id counter, so responses correlate correctly), then releases the
207
- * connection shortly after it goes idle ({@link DEFAULT_IDLE_LINGER_MS}) and
208
- * reconnects lazily on the next request. Before a session connects, all
209
- * management transports registered for the same app-server URL relinquish
210
- * their connections; the handoff waits for in-flight work and for the control
211
- * socket's close event. The inverse contention — a management request issued
212
- * while a session holds the control slot — cannot be solved client-side and
213
- * still fails until the app-server allows multiple control clients.
116
+ * The app-server assigns every client an independent connection, so management
117
+ * keeps one lazy pooled client while sessions connect alongside it. Unexpected
118
+ * disconnects discard the pool and the next management request reconnects.
214
119
  */
215
120
  export class AppServerManagementTransport
216
121
  implements ManagementTransport
217
122
  {
218
123
  private connectionPromise: Promise<ActiveConnection> | null = null;
219
- private releasePromise: Promise<void> | null = null;
220
124
  private closingConnections = new Set<Promise<void>>();
221
- private inFlightRequests = 0;
222
- private idleWaiters = new Set<() => void>();
223
- private idleTimer: ReturnType<typeof setTimeout> | null = null;
224
- private readonly idleLingerMs: number;
225
125
 
226
- constructor(private readonly options: AppServerManagementOptions) {
227
- this.idleLingerMs = options.idleLingerMs ?? DEFAULT_IDLE_LINGER_MS;
228
- }
126
+ constructor(private readonly options: AppServerManagementOptions) {}
229
127
 
230
128
  async listAgents(query: ManagementQuery): Promise<LettaAgent[]> {
231
129
  const response = await this.request<AgentListResponse>(
@@ -384,97 +282,28 @@ export class AppServerManagementTransport
384
282
  return { messages: response.messages };
385
283
  }
386
284
 
387
- /**
388
- * Release the pooled control connection instead of waiting out the idle
389
- * linger. If requests are in flight, wait for them to settle first.
390
- *
391
- * The app-server accepts a single control client (see the class docs), so
392
- * This is called before opening a session to hand the control slot over
393
- * without a linger-sized or socket-close race window.
394
- */
395
- releaseIdleConnection(): Promise<void> {
396
- this.clearIdleTimer();
397
- if (this.releasePromise) return this.releasePromise;
398
-
399
- const release = this.releaseConnectionWhenIdle();
400
- this.releasePromise = release;
401
- void release.then(
402
- () => {
403
- if (this.releasePromise === release) {
404
- this.releasePromise = null;
405
- }
406
- },
407
- () => {
408
- if (this.releasePromise === release) {
409
- this.releasePromise = null;
410
- }
411
- },
412
- );
413
- return release;
414
- }
415
-
416
285
  private async request<TResponse extends { type: string }>(
417
286
  type: string,
418
287
  body: Record<string, unknown>,
419
288
  responseType: string,
420
289
  ): Promise<TResponse> {
421
- if (this.releasePromise) {
422
- await this.releasePromise;
423
- }
424
290
  if (this.closingConnections.size > 0) {
425
291
  await Promise.all([...this.closingConnections]);
426
292
  }
427
- this.clearIdleTimer();
428
- this.inFlightRequests += 1;
429
- try {
430
- // Concurrent requests share the pooled connection (queueing behind the
431
- // same connect promise) and its request-id counter, so correlation is
432
- // stable across a burst and across reconnects: a fresh connection gets a
433
- // fresh client whose pending map starts empty.
434
- const { client } = await this.acquireConnection();
435
- const command = {
436
- type,
437
- request_id: client.nextRequestId(type),
438
- ...body,
439
- } as AppServerRequestCommandWithId;
440
- const response = await client.request(command, {
441
- predicate: (message): message is typeof message =>
442
- message.type === responseType,
443
- });
444
- return response as unknown as TResponse;
445
- } finally {
446
- this.inFlightRequests -= 1;
447
- if (this.inFlightRequests === 0) {
448
- const waiters = [...this.idleWaiters];
449
- this.idleWaiters.clear();
450
- for (const resolve of waiters) resolve();
451
- if (!this.releasePromise) {
452
- this.scheduleIdleRelease();
453
- }
454
- }
455
- }
456
- }
457
-
458
- private async releaseConnectionWhenIdle(): Promise<void> {
459
- if (this.inFlightRequests > 0) {
460
- await new Promise<void>((resolve) => {
461
- this.idleWaiters.add(resolve);
462
- });
463
- }
464
-
465
- this.clearIdleTimer();
466
- const promise = this.connectionPromise;
467
- try {
468
- if (promise) {
469
- this.connectionPromise = null;
470
- await this.trackClosingConnection(await promise);
471
- }
472
- if (this.closingConnections.size > 0) {
473
- await Promise.all([...this.closingConnections]);
474
- }
475
- } catch {
476
- // A failed connect already closes its partially-created resources.
477
- }
293
+ // Concurrent requests share the same connect promise and request-id
294
+ // counter, while connection identity keeps this pool independent from
295
+ // session clients using the same app-server.
296
+ const { client } = await this.acquireConnection();
297
+ const command = {
298
+ type,
299
+ request_id: client.nextRequestId(type),
300
+ ...body,
301
+ } as AppServerRequestCommandWithId;
302
+ const response = await client.request(command, {
303
+ predicate: (message): message is typeof message =>
304
+ message.type === responseType,
305
+ });
306
+ return response as unknown as TResponse;
478
307
  }
479
308
 
480
309
  private acquireConnection(): Promise<ActiveConnection> {
@@ -507,7 +336,6 @@ export class AppServerManagementTransport
507
336
  if (!url) {
508
337
  throw new Error("App-server management requires a url or connect hook.");
509
338
  }
510
- const unregister = registerManagementTransport(url, this);
511
339
 
512
340
  let client: AppServerClient | null = null;
513
341
  try {
@@ -530,17 +358,13 @@ export class AppServerManagementTransport
530
358
  } catch (error) {
531
359
  try {
532
360
  if (client) {
533
- const controlClosed = waitForSocketClose(client.control);
534
361
  client.close();
535
362
  ownedConnection?.close();
536
- await controlClosed;
537
363
  } else {
538
364
  ownedConnection?.close();
539
365
  }
540
366
  } catch {
541
367
  // Preserve the original connect error after best-effort cleanup.
542
- } finally {
543
- unregister();
544
368
  }
545
369
  throw error;
546
370
  }
@@ -548,7 +372,6 @@ export class AppServerManagementTransport
548
372
  client,
549
373
  ownedConnection,
550
374
  detachDisconnect: () => {},
551
- unregister,
552
375
  };
553
376
  }
554
377
 
@@ -558,7 +381,6 @@ export class AppServerManagementTransport
558
381
  ): void {
559
382
  if (this.connectionPromise === promise) {
560
383
  this.connectionPromise = null;
561
- this.clearIdleTimer();
562
384
  }
563
385
  void this.trackClosingConnection(connection);
564
386
  }
@@ -574,68 +396,10 @@ export class AppServerManagementTransport
574
396
  );
575
397
  return closing;
576
398
  }
577
-
578
- private scheduleIdleRelease(): void {
579
- if (!this.connectionPromise) return;
580
- this.clearIdleTimer();
581
- const timer = setTimeout(() => {
582
- this.idleTimer = null;
583
- this.releaseIdleConnection();
584
- }, this.idleLingerMs);
585
- this.idleTimer = timer;
586
- // Do not keep a Node event loop alive just for the linger.
587
- (timer as unknown as { unref?: () => void }).unref?.();
588
- }
589
-
590
- private clearIdleTimer(): void {
591
- if (this.idleTimer === null) return;
592
- clearTimeout(this.idleTimer);
593
- this.idleTimer = null;
594
- }
595
399
  }
596
400
 
597
401
  async function closeConnection(connection: ActiveConnection): Promise<void> {
598
402
  connection.detachDisconnect();
599
- const controlClosed = waitForSocketClose(connection.client.control);
600
- try {
601
- connection.client.close();
602
- connection.ownedConnection?.close();
603
- await controlClosed;
604
- } finally {
605
- connection.unregister();
606
- }
607
- }
608
-
609
- function waitForSocketClose(socket: AppServerSocketLike): Promise<void> {
610
- if (socket.readyState === 3) return Promise.resolve();
611
-
612
- return new Promise((resolve) => {
613
- let settled = false;
614
- let detach = () => {};
615
- const finish = () => {
616
- if (settled) return;
617
- settled = true;
618
- clearTimeout(timeout);
619
- detach();
620
- resolve();
621
- };
622
-
623
- if (socket.addEventListener) {
624
- socket.addEventListener("close", finish);
625
- detach = () => socket.removeEventListener?.("close", finish);
626
- } else if (socket.once) {
627
- socket.once("close", finish);
628
- detach = () => socket.off?.("close", finish);
629
- } else if (socket.on) {
630
- socket.on("close", finish);
631
- detach = () => socket.off?.("close", finish);
632
- } else {
633
- resolve();
634
- return;
635
- }
636
-
637
- const timeout = setTimeout(finish, 1_000);
638
- (timeout as unknown as { unref?: () => void }).unref?.();
639
- if (socket.readyState === 3) finish();
640
- });
403
+ connection.client.close();
404
+ connection.ownedConnection?.close();
641
405
  }
@@ -10,7 +10,6 @@ import {
10
10
  GIT_MEMORY_ENABLED_TAG,
11
11
  LETTA_CODE_ORIGIN_TAG,
12
12
  } from "@letta-ai/letta-code/agent-presets";
13
- import { releaseAppServerManagementConnections } from "./app-server-management.js";
14
13
  import {
15
14
  buildCanUseToolContext,
16
15
  isHeadlessAutoAllowTool,
@@ -127,11 +126,6 @@ export type AppServerSessionOptions = Partial<LettaCodeRemoteClientOptions> & {
127
126
  connect?: (
128
127
  sessionEnv?: Record<string, string>,
129
128
  ) => Promise<{ url: string; close(): void }>;
130
- /**
131
- * Internal handoff hook used to release a management connection owned by
132
- * the same SDK client before this session opens its control socket.
133
- */
134
- beforeConnect?: () => Promise<void>;
135
129
  /** Whether SDK create-agent payloads should add the origin tag automatically. */
136
130
  includeSdkOriginTag?: boolean;
137
131
  };
@@ -165,30 +159,6 @@ function assertRemoteCreateAgentOptionsSupported(options: CreateAgentOptions): v
165
159
  }
166
160
  }
167
161
 
168
- export function assertRemoteSessionOptionsSupported(
169
- action: string,
170
- options: LettaCodeClientSessionOptions,
171
- ): void {
172
- if (options.systemPrompt !== undefined) {
173
- throw new Error(`App-server ${action}() does not yet support systemPrompt overrides for existing agents.`);
174
- }
175
- if (options.disallowedTools !== undefined) {
176
- throw new Error(`App-server ${action}() does not yet support disallowedTools.`);
177
- }
178
- if (options.systemInfoReminder !== undefined) {
179
- throw new Error(`App-server ${action}() does not yet support systemInfoReminder overrides.`);
180
- }
181
- if (options.dreaming?.behavior !== undefined) {
182
- throw new Error(`App-server ${action}() does not yet support dreaming.behavior overrides.`);
183
- }
184
- if ((options as { memfsStartup?: unknown }).memfsStartup !== undefined) {
185
- throw new Error(`App-server ${action}() does not support memfsStartup.`);
186
- }
187
- if (options.includePartialMessages !== undefined) {
188
- throw new Error(`App-server ${action}() streams app-server deltas directly and does not support includePartialMessages.`);
189
- }
190
- }
191
-
192
162
  function normalizeMemoryBlock(block: Record<string, unknown>): Record<string, unknown> {
193
163
  const normalized = { ...block };
194
164
  if (normalized.value === undefined && typeof normalized.content === "string") {
@@ -771,9 +741,7 @@ export class AppServerSession extends RemoteClientSessionCore {
771
741
  }
772
742
 
773
743
  protected override async initializeRuntimeController(): Promise<RuntimeSessionInit> {
774
- await this.remoteOptions.beforeConnect?.();
775
744
  const url = await this.resolveAppServerUrl();
776
- await releaseAppServerManagementConnections(url);
777
745
  const client = applyUniqueRequestIds(createAppServerClient({
778
746
  url,
779
747
  ...(this.remoteOptions.authToken !== undefined