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

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 (49) hide show
  1. package/AGENTS.md +47 -0
  2. package/README.md +17 -0
  3. package/dist/client-entry.js +800 -732
  4. package/dist/client-entry.js.map +8 -5
  5. package/dist/cloud-sandbox.d.ts +33 -0
  6. package/dist/cloud-sandbox.d.ts.map +1 -0
  7. package/dist/cloud-session.d.ts.map +1 -1
  8. package/dist/index.d.ts +1 -1
  9. package/dist/index.d.ts.map +1 -1
  10. package/dist/index.js +799 -732
  11. package/dist/index.js.map +9 -6
  12. package/dist/remote-client-session-core.d.ts +6 -93
  13. package/dist/remote-client-session-core.d.ts.map +1 -1
  14. package/dist/remote-session-protocol.d.ts +130 -0
  15. package/dist/remote-session-protocol.d.ts.map +1 -0
  16. package/dist/remote-turn-coordinator.d.ts +49 -0
  17. package/dist/remote-turn-coordinator.d.ts.map +1 -0
  18. package/dist/types.d.ts +2 -20
  19. package/dist/types.d.ts.map +1 -1
  20. package/package.json +5 -2
  21. package/src/app-server-management.ts +641 -0
  22. package/src/app-server-session.ts +948 -0
  23. package/src/cli-resolver.ts +46 -0
  24. package/src/client-base.ts +482 -0
  25. package/src/client-entry.ts +31 -0
  26. package/src/client.ts +138 -0
  27. package/src/cloud-management.ts +360 -0
  28. package/src/cloud-sandbox.ts +117 -0
  29. package/src/cloud-session.ts +1313 -0
  30. package/src/index.ts +440 -0
  31. package/src/interactiveToolPolicy.ts +62 -0
  32. package/src/local-app-server-session.ts +39 -0
  33. package/src/local-app-server.ts +137 -0
  34. package/src/management-types.ts +133 -0
  35. package/src/management.ts +206 -0
  36. package/src/protocol.ts +249 -0
  37. package/src/remote-client-session-core.ts +786 -0
  38. package/src/remote-session-protocol.ts +660 -0
  39. package/src/remote-turn-coordinator.ts +505 -0
  40. package/src/remote.ts +177 -0
  41. package/src/repositories.ts +340 -0
  42. package/src/request-ids.ts +33 -0
  43. package/src/session.ts +1638 -0
  44. package/src/stream-events.ts +88 -0
  45. package/src/tool-helpers.ts +147 -0
  46. package/src/transport.ts +484 -0
  47. package/src/types.ts +1328 -0
  48. package/src/validation.ts +223 -0
  49. package/src/websocket.ts +22 -0
@@ -0,0 +1,505 @@
1
+ import type {
2
+ SDKLoopStatusMessage,
3
+ SDKMessage,
4
+ SDKQueueUpdateMessage,
5
+ SDKResultMessage,
6
+ SDKStreamEventPayload,
7
+ SessionDeviceStatus,
8
+ } from "./types.js";
9
+ import {
10
+ FAILURE_STOP_REASONS,
11
+ deviceStatusRecord,
12
+ extractTextFromContent,
13
+ firstToolCall,
14
+ firstToolReturn,
15
+ isApprovalConflictSignal,
16
+ loopStatusRunIds,
17
+ loopStatusValue,
18
+ queueItems,
19
+ sameRuntime,
20
+ streamDeltaMessageType,
21
+ streamDeltaRecord,
22
+ streamDeltaRunId,
23
+ streamDeltaStopReason,
24
+ toSdkErrorCode,
25
+ toSessionDeviceStatus,
26
+ toolInputFromArguments,
27
+ type ProtocolMessage,
28
+ type RuntimeScope,
29
+ type RuntimeTurnResult,
30
+ type TurnTracker,
31
+ } from "./remote-session-protocol.js";
32
+
33
+ type RemoteTurnCoordinatorConfig = {
34
+ label: string;
35
+ requestTimeoutMs?: number;
36
+ onDeviceStatus(status: SessionDeviceStatus): void;
37
+ };
38
+
39
+ /**
40
+ * Owns the portable session's turn correlation and stream queue.
41
+ *
42
+ * Transport setup and public SDK methods stay in RemoteClientSessionCore.
43
+ * This class only translates protocol events into SDK messages and correlates
44
+ * those events with active or queued turns.
45
+ */
46
+ export class RemoteTurnCoordinator {
47
+ private readonly label: string;
48
+ private readonly requestTimeoutMs: number | undefined;
49
+ private readonly onDeviceStatus: (status: SessionDeviceStatus) => void;
50
+ private streamQueue: SDKMessage[] = [];
51
+ private streamResolvers: Array<(message: SDKMessage | null) => void> = [];
52
+ private activeTurn: TurnTracker | null = null;
53
+ private pendingTurns: TurnTracker[] = [];
54
+ private nextTurnId = 0;
55
+ private messageCounter = 0;
56
+ private clientMessageCounter = 0;
57
+ private closed = false;
58
+ private _activeTurnStartedAt = 0;
59
+
60
+ constructor(config: RemoteTurnCoordinatorConfig) {
61
+ this.label = config.label;
62
+ this.requestTimeoutMs = config.requestTimeoutMs;
63
+ this.onDeviceStatus = config.onDeviceStatus;
64
+ }
65
+
66
+ get activeTurnStartedAt(): number {
67
+ return this._activeTurnStartedAt;
68
+ }
69
+
70
+ hasInFlightTurn(): boolean {
71
+ return this.activeTurn !== null || this.pendingTurns.length > 0;
72
+ }
73
+
74
+ trackSentTurn(runtime: RuntimeScope): TurnTracker {
75
+ const turn: TurnTracker = {
76
+ id: ++this.nextTurnId,
77
+ runtime,
78
+ clientMessageId: `sdk-message-${Date.now()}-${++this.clientMessageCounter}`,
79
+ queuedAt: Date.now(),
80
+ startedAt: 0,
81
+ assistantText: "",
82
+ runIds: new Set<string>(),
83
+ observedTurnEvidence: false,
84
+ observedRequiresApprovalStop: false,
85
+ abortRequested: false,
86
+ timeout: null,
87
+ };
88
+
89
+ if (this.activeTurn) {
90
+ this.pendingTurns.push(turn);
91
+ } else {
92
+ this.activateTurn(turn);
93
+ }
94
+ return turn;
95
+ }
96
+
97
+ removeTrackedTurn(turn: TurnTracker): void {
98
+ if (turn.timeout) clearTimeout(turn.timeout);
99
+ if (this.activeTurn === turn) {
100
+ this.activeTurn = null;
101
+ return;
102
+ }
103
+ const index = this.pendingTurns.indexOf(turn);
104
+ if (index !== -1) this.pendingTurns.splice(index, 1);
105
+ }
106
+
107
+ markAbortRequested(): void {
108
+ if (this.activeTurn) this.activeTurn.abortRequested = true;
109
+ }
110
+
111
+ handleProtocolMessage(message: ProtocolMessage, runtime: RuntimeScope): void {
112
+ if (!sameRuntime(message, runtime)) return;
113
+
114
+ const statusRecord = deviceStatusRecord(message);
115
+ if (statusRecord) {
116
+ const status = toSessionDeviceStatus(statusRecord);
117
+ if (status) this.onDeviceStatus(status);
118
+ return;
119
+ }
120
+
121
+ if (message.type === "update_queue") {
122
+ const sdkMessage: SDKQueueUpdateMessage = {
123
+ type: "queue_update",
124
+ queue: queueItems(message),
125
+ };
126
+ this.enqueue(sdkMessage);
127
+ return;
128
+ }
129
+
130
+ if (message.type === "update_loop_status") {
131
+ this.handleLoopStatusMessage(message);
132
+ return;
133
+ }
134
+
135
+ const delta = streamDeltaRecord(message);
136
+ if (!delta) return;
137
+ const active = this.activateNextTurnFromProtocol();
138
+ if (active) {
139
+ active.observedTurnEvidence = true;
140
+ const runId = streamDeltaRunId(delta);
141
+ if (runId) active.runIds.add(runId);
142
+ }
143
+
144
+ const sdkMessage = this.transformStreamDelta(delta);
145
+ if (sdkMessage) this.enqueue(sdkMessage);
146
+ this.handleTurnTerminalDelta(delta, sdkMessage);
147
+ }
148
+
149
+ nextMessage(): Promise<SDKMessage | null> {
150
+ const next = this.streamQueue.shift();
151
+ if (next) return Promise.resolve(next);
152
+ if (this.closed) return Promise.resolve(null);
153
+ return new Promise((resolve) => {
154
+ this.streamResolvers.push(resolve);
155
+ });
156
+ }
157
+
158
+ close(): void {
159
+ if (this.closed) return;
160
+ this.closed = true;
161
+ if (this.activeTurn?.timeout) clearTimeout(this.activeTurn.timeout);
162
+ for (const turn of this.pendingTurns) {
163
+ if (turn.timeout) clearTimeout(turn.timeout);
164
+ }
165
+ this.activeTurn = null;
166
+ this.pendingTurns.length = 0;
167
+ this.resolveAll(null);
168
+ }
169
+
170
+ private activateTurn(turn: TurnTracker): void {
171
+ this.activeTurn = turn;
172
+ turn.startedAt = Date.now();
173
+ this._activeTurnStartedAt = turn.startedAt;
174
+ if (this.requestTimeoutMs !== undefined) {
175
+ turn.timeout = setTimeout(() => {
176
+ this.failTurn(turn, `Timed out waiting for ${this.label} turn`);
177
+ }, this.requestTimeoutMs);
178
+ (turn.timeout as { unref?: () => void }).unref?.();
179
+ }
180
+ }
181
+
182
+ private activateNextTurnFromProtocol(): TurnTracker | null {
183
+ if (this.activeTurn) return this.activeTurn;
184
+ const next = this.pendingTurns.shift();
185
+ if (!next) return null;
186
+ this.activateTurn(next);
187
+ return next;
188
+ }
189
+
190
+ private failTurn(turn: TurnTracker, detail: string): void {
191
+ if (this.activeTurn !== turn) return;
192
+ this.enqueue({
193
+ type: "error",
194
+ message: detail,
195
+ errorCode: "error",
196
+ stopReason: "error",
197
+ errorDetail: detail,
198
+ recoverable: false,
199
+ });
200
+ this.completeActiveTurn({
201
+ runtime: turn.runtime,
202
+ stopReason: "error",
203
+ runIds: [...turn.runIds],
204
+ success: false,
205
+ detail,
206
+ errorCode: "error",
207
+ });
208
+ }
209
+
210
+ private completeActiveTurn(turn: RuntimeTurnResult): void {
211
+ const active = this.activeTurn;
212
+ if (!active) return;
213
+ if (active.timeout) {
214
+ clearTimeout(active.timeout);
215
+ active.timeout = null;
216
+ }
217
+ this.enqueue(this.resultFromTurn(turn, active));
218
+ this.activeTurn = null;
219
+ }
220
+
221
+ private handleLoopStatusMessage(message: ProtocolMessage): void {
222
+ const status = loopStatusValue(message);
223
+ if (!status) return;
224
+ const activeRunIds = loopStatusRunIds(message);
225
+ const sdkMessage: SDKLoopStatusMessage = {
226
+ type: "loop_status",
227
+ status,
228
+ activeRunIds,
229
+ };
230
+ this.enqueue(sdkMessage);
231
+
232
+ const active = this.activeTurn;
233
+ if (!active) return;
234
+ for (const runId of activeRunIds) active.runIds.add(runId);
235
+
236
+ const hadTurnEvidence =
237
+ active.observedTurnEvidence || active.observedRequiresApprovalStop;
238
+ if (!hadTurnEvidence) return;
239
+ if (status === "WAITING_ON_APPROVAL") {
240
+ this.completeActiveTurn({
241
+ runtime: active.runtime,
242
+ stopReason: "requires_approval",
243
+ runIds: [...active.runIds],
244
+ });
245
+ return;
246
+ }
247
+ if (status === "WAITING_ON_INPUT" && active.abortRequested) {
248
+ this.completeActiveTurn({
249
+ runtime: active.runtime,
250
+ stopReason: "interrupted",
251
+ runIds: [...active.runIds],
252
+ success: false,
253
+ detail: "Interrupted",
254
+ errorCode: "interrupted",
255
+ });
256
+ return;
257
+ }
258
+ if (status === "WAITING_ON_INPUT" && active.observedTurnEvidence) {
259
+ this.completeActiveTurn({
260
+ runtime: active.runtime,
261
+ stopReason: null,
262
+ runIds: [...active.runIds],
263
+ });
264
+ }
265
+ }
266
+
267
+ private handleTurnTerminalDelta(
268
+ delta: Record<string, unknown>,
269
+ sdkMessage: SDKMessage | null,
270
+ ): void {
271
+ const active = this.activeTurn;
272
+ if (!active) return;
273
+ const messageType = streamDeltaMessageType(delta);
274
+ if (messageType === "stop_reason") {
275
+ const stopReason = streamDeltaStopReason(delta) ?? null;
276
+ if (stopReason === "requires_approval") {
277
+ active.observedRequiresApprovalStop = true;
278
+ return;
279
+ }
280
+ this.completeActiveTurn({
281
+ runtime: active.runtime,
282
+ stopReason,
283
+ runIds: [...active.runIds],
284
+ });
285
+ return;
286
+ }
287
+
288
+ if (sdkMessage?.type === "error") {
289
+ this.completeActiveTurn({
290
+ runtime: active.runtime,
291
+ stopReason: sdkMessage.stopReason,
292
+ runIds: [...active.runIds],
293
+ success: false,
294
+ detail: sdkMessage.errorDetail ?? sdkMessage.message,
295
+ errorCode: sdkMessage.errorCode,
296
+ });
297
+ }
298
+ }
299
+
300
+ private transformStreamDelta(
301
+ delta: Record<string, unknown>,
302
+ ): SDKMessage | null {
303
+ const messageType =
304
+ typeof delta.message_type === "string" ? delta.message_type : undefined;
305
+ const runId = typeof delta.run_id === "string" ? delta.run_id : undefined;
306
+ const uuid =
307
+ typeof delta.id === "string"
308
+ ? delta.id
309
+ : `${this.label}-${++this.messageCounter}`;
310
+
311
+ if (messageType === "assistant_message") {
312
+ const content = extractTextFromContent(delta.content);
313
+ if (!content) return null;
314
+ if (this.activeTurn) this.activeTurn.assistantText += content;
315
+ return { type: "assistant", content, uuid, runId };
316
+ }
317
+
318
+ if (messageType === "reasoning_message") {
319
+ const content =
320
+ typeof delta.reasoning === "string"
321
+ ? delta.reasoning
322
+ : extractTextFromContent(delta.content);
323
+ if (!content) return null;
324
+ return { type: "reasoning", content, uuid, runId };
325
+ }
326
+
327
+ if (
328
+ messageType === "tool_call_message" ||
329
+ messageType === "approval_request_message"
330
+ ) {
331
+ const toolCall = firstToolCall(delta);
332
+ if (!toolCall) return null;
333
+ const fn =
334
+ toolCall.function && typeof toolCall.function === "object"
335
+ ? (toolCall.function as Record<string, unknown>)
336
+ : undefined;
337
+ const toolCallId =
338
+ (typeof toolCall.tool_call_id === "string"
339
+ ? toolCall.tool_call_id
340
+ : undefined) ??
341
+ (typeof toolCall.id === "string" ? toolCall.id : undefined);
342
+ if (!toolCallId) {
343
+ const detail = `Missing tool_call_id in ${messageType} (uuid=${uuid})`;
344
+ return {
345
+ type: "error",
346
+ message: detail,
347
+ errorCode: "protocol_error",
348
+ stopReason: "protocol_error",
349
+ runId,
350
+ recoverable: false,
351
+ errorDetail: detail,
352
+ };
353
+ }
354
+ const toolName =
355
+ (typeof toolCall.name === "string" ? toolCall.name : undefined) ??
356
+ (typeof fn?.name === "string" ? fn.name : undefined) ??
357
+ "?";
358
+ const { input, raw } = toolInputFromArguments(
359
+ toolCall.arguments ?? fn?.arguments,
360
+ );
361
+ return {
362
+ type: "tool_call",
363
+ toolCallId,
364
+ toolName,
365
+ toolInput: input,
366
+ rawArguments: raw,
367
+ uuid,
368
+ runId,
369
+ };
370
+ }
371
+
372
+ if (messageType === "tool_return_message") {
373
+ const toolReturn = firstToolReturn(delta) ?? delta;
374
+ const toolCallId =
375
+ (typeof delta.tool_call_id === "string"
376
+ ? delta.tool_call_id
377
+ : undefined) ??
378
+ (typeof toolReturn.tool_call_id === "string"
379
+ ? toolReturn.tool_call_id
380
+ : undefined);
381
+ if (!toolCallId) return null;
382
+ const content =
383
+ extractTextFromContent(
384
+ delta.tool_return ?? toolReturn.tool_return ?? toolReturn.content,
385
+ ) ?? "";
386
+ const status =
387
+ typeof delta.status === "string" ? delta.status : toolReturn.status;
388
+ return {
389
+ type: "tool_result",
390
+ toolCallId,
391
+ content,
392
+ isError: status === "error",
393
+ uuid,
394
+ runId,
395
+ };
396
+ }
397
+
398
+ if (messageType === "error_message" || messageType === "loop_error") {
399
+ const detail =
400
+ (typeof delta.detail === "string" ? delta.detail : undefined) ??
401
+ (typeof delta.message === "string" ? delta.message : undefined) ??
402
+ `${this.label} turn failed`;
403
+ const stopReason =
404
+ (typeof delta.stop_reason === "string"
405
+ ? delta.stop_reason
406
+ : undefined) ??
407
+ (typeof delta.error_type === "string"
408
+ ? delta.error_type
409
+ : undefined) ??
410
+ "error";
411
+ const approvalConflict = isApprovalConflictSignal({
412
+ detail,
413
+ message:
414
+ typeof delta.message === "string" ? delta.message : undefined,
415
+ stopReason,
416
+ });
417
+ return {
418
+ type: "error",
419
+ message: detail,
420
+ errorCode: approvalConflict
421
+ ? "approval_conflict"
422
+ : toSdkErrorCode(stopReason),
423
+ approvalConflict: approvalConflict || undefined,
424
+ recoverable: approvalConflict ? true : false,
425
+ errorDetail: detail,
426
+ stopReason,
427
+ runId,
428
+ };
429
+ }
430
+
431
+ if (messageType === "retry") {
432
+ return {
433
+ type: "retry",
434
+ reason: typeof delta.reason === "string" ? delta.reason : "error",
435
+ attempt: typeof delta.attempt === "number" ? delta.attempt : 0,
436
+ maxAttempts:
437
+ typeof delta.max_attempts === "number" ? delta.max_attempts : 0,
438
+ delayMs: typeof delta.delay_ms === "number" ? delta.delay_ms : 0,
439
+ runId,
440
+ };
441
+ }
442
+
443
+ if (messageType === "stop_reason" || messageType === "ping") {
444
+ return null;
445
+ }
446
+
447
+ return {
448
+ type: "stream_event",
449
+ event: delta as SDKStreamEventPayload,
450
+ uuid,
451
+ };
452
+ }
453
+
454
+ private resultFromTurn(
455
+ turn: RuntimeTurnResult,
456
+ tracker?: TurnTracker,
457
+ ): SDKResultMessage {
458
+ const stopReason =
459
+ turn.stopReason ?? (turn.success === false ? "error" : undefined);
460
+ const approvalConflict = isApprovalConflictSignal({
461
+ detail: turn.detail,
462
+ stopReason,
463
+ });
464
+ const success =
465
+ turn.success !== undefined
466
+ ? turn.success &&
467
+ !approvalConflict &&
468
+ !FAILURE_STOP_REASONS.has(stopReason ?? "")
469
+ : !approvalConflict && !FAILURE_STOP_REASONS.has(stopReason ?? "");
470
+ const errorCode = approvalConflict
471
+ ? "approval_conflict"
472
+ : (turn.errorCode ?? toSdkErrorCode(stopReason));
473
+
474
+ return {
475
+ type: "result",
476
+ success,
477
+ result: success ? tracker?.assistantText || undefined : undefined,
478
+ error: success ? undefined : (errorCode ?? stopReason ?? "error"),
479
+ errorCode: success ? undefined : (errorCode ?? "error"),
480
+ approvalConflict: approvalConflict || undefined,
481
+ recoverable: approvalConflict ? true : success ? undefined : false,
482
+ errorDetail: success ? undefined : turn.detail,
483
+ stopReason,
484
+ durationMs:
485
+ Date.now() - (tracker?.startedAt || this._activeTurnStartedAt),
486
+ conversationId: turn.runtime.conversation_id,
487
+ runIds: turn.runIds.length > 0 ? turn.runIds : undefined,
488
+ };
489
+ }
490
+
491
+ private enqueue(message: SDKMessage): void {
492
+ const resolver = this.streamResolvers.shift();
493
+ if (resolver) {
494
+ resolver(message);
495
+ return;
496
+ }
497
+ this.streamQueue.push(message);
498
+ }
499
+
500
+ private resolveAll(value: SDKMessage | null): void {
501
+ for (const resolve of this.streamResolvers.splice(0)) {
502
+ resolve(value);
503
+ }
504
+ }
505
+ }
package/src/remote.ts ADDED
@@ -0,0 +1,177 @@
1
+ export type RemoteEnvironmentTarget =
2
+ | { connectionId: string }
3
+ | { environmentId: string }
4
+ | { deviceId: string }
5
+ | { connectionName: string };
6
+
7
+ export interface RemoteEnvironmentClientOptions {
8
+ /** Letta API base URL. Defaults to https://api.letta.com. */
9
+ baseUrl?: string;
10
+ /** Bearer token / API key. Defaults to process.env.LETTA_API_KEY when present. */
11
+ apiKey?: string;
12
+ /** Additional headers, e.g. x-project-id. */
13
+ headers?: Record<string, string>;
14
+ /** Custom fetch implementation for tests or non-standard runtimes. */
15
+ fetch?: typeof fetch;
16
+ }
17
+
18
+ export interface RemoteEnvironmentConnection {
19
+ id: string;
20
+ connectionId: string | null;
21
+ deviceId: string;
22
+ connectionName: string;
23
+ organizationId: string;
24
+ userId?: string;
25
+ apiKeyOwner?: string;
26
+ podId: string | null;
27
+ connectedAt: number | null;
28
+ lastHeartbeat: number | null;
29
+ lastSeenAt: number;
30
+ firstSeenAt: number;
31
+ currentMode?: string;
32
+ metadata?: Record<string, unknown>;
33
+ }
34
+
35
+ export interface RemoteEnvironmentListResult {
36
+ connections: RemoteEnvironmentConnection[];
37
+ hasNextPage: boolean;
38
+ }
39
+
40
+ export interface ResolvedRemoteEnvironment {
41
+ connectionId: string;
42
+ environment?: RemoteEnvironmentConnection;
43
+ target: RemoteEnvironmentTarget;
44
+ }
45
+
46
+ type RemoteFetch = typeof fetch;
47
+
48
+ function getDefaultApiKey(): string | undefined {
49
+ if (typeof process === "undefined") {
50
+ return undefined;
51
+ }
52
+ return process.env.LETTA_API_KEY;
53
+ }
54
+
55
+ function createHeaders(options: RemoteEnvironmentClientOptions): Record<string, string> {
56
+ const apiKey = options.apiKey ?? getDefaultApiKey();
57
+ return {
58
+ ...(apiKey ? { Authorization: `Bearer ${apiKey}` } : {}),
59
+ ...(options.headers ?? {}),
60
+ };
61
+ }
62
+
63
+ function getFetch(options: RemoteEnvironmentClientOptions): RemoteFetch {
64
+ const fetchImpl = options.fetch ?? globalThis.fetch;
65
+ if (!fetchImpl) {
66
+ throw new Error("Remote environments require a fetch implementation");
67
+ }
68
+ return fetchImpl.bind(globalThis) as RemoteFetch;
69
+ }
70
+
71
+ function normalizeBaseUrl(baseUrl?: string): string {
72
+ return (baseUrl ?? "https://api.letta.com").replace(/\/$/, "");
73
+ }
74
+
75
+ function ensureOnline(
76
+ environment: RemoteEnvironmentConnection,
77
+ target: RemoteEnvironmentTarget,
78
+ ): ResolvedRemoteEnvironment {
79
+ if (!environment.connectionId) {
80
+ const label =
81
+ "deviceId" in target
82
+ ? target.deviceId
83
+ : "environmentId" in target
84
+ ? target.environmentId
85
+ : "connectionName" in target
86
+ ? target.connectionName
87
+ : environment.deviceId;
88
+ throw new Error(`Remote environment is offline: ${label}`);
89
+ }
90
+
91
+ return {
92
+ connectionId: environment.connectionId,
93
+ environment,
94
+ target,
95
+ };
96
+ }
97
+
98
+ async function parseJsonResponse<T>(response: Response): Promise<T> {
99
+ const text = await response.text();
100
+ const body = text ? JSON.parse(text) : null;
101
+
102
+ if (!response.ok) {
103
+ const message =
104
+ body && typeof body === "object" && "message" in body
105
+ ? String((body as { message: unknown }).message)
106
+ : response.statusText;
107
+ throw new Error(`Letta API request failed (${response.status}): ${message}`);
108
+ }
109
+
110
+ return body as T;
111
+ }
112
+
113
+ /**
114
+ * Small Cloud API helper for resolving explicit Letta Code environments.
115
+ */
116
+ export class RemoteEnvironmentClient {
117
+ private readonly baseUrl: string;
118
+ private readonly fetchImpl: RemoteFetch;
119
+
120
+ constructor(private readonly options: RemoteEnvironmentClientOptions = {}) {
121
+ this.baseUrl = normalizeBaseUrl(options.baseUrl);
122
+ this.fetchImpl = getFetch(options);
123
+ }
124
+
125
+ async listEnvironments(): Promise<RemoteEnvironmentListResult> {
126
+ const url = new URL(`${this.baseUrl}/v1/environments`);
127
+ const response = await this.fetchImpl(url, {
128
+ headers: createHeaders(this.options),
129
+ });
130
+ return parseJsonResponse<RemoteEnvironmentListResult>(response);
131
+ }
132
+
133
+ async getEnvironmentByDeviceId(deviceId: string): Promise<RemoteEnvironmentConnection> {
134
+ const response = await this.fetchImpl(
135
+ `${this.baseUrl}/v1/environments/${encodeURIComponent(deviceId)}`,
136
+ { headers: createHeaders(this.options) },
137
+ );
138
+ return parseJsonResponse<RemoteEnvironmentConnection>(response);
139
+ }
140
+
141
+ async resolveEnvironment(target: RemoteEnvironmentTarget): Promise<ResolvedRemoteEnvironment> {
142
+ if ("connectionId" in target) {
143
+ return { connectionId: target.connectionId, target };
144
+ }
145
+
146
+ if ("deviceId" in target) {
147
+ return ensureOnline(await this.getEnvironmentByDeviceId(target.deviceId), target);
148
+ }
149
+
150
+ const { connections } = await this.listEnvironments();
151
+ if ("environmentId" in target) {
152
+ const match = connections.find((env) => env.id === target.environmentId);
153
+ if (!match) {
154
+ throw new Error(`Remote environment not found: ${target.environmentId}`);
155
+ }
156
+ return ensureOnline(match, target);
157
+ }
158
+
159
+ const matches = connections.filter(
160
+ (env) => env.connectionName === target.connectionName,
161
+ );
162
+ if (matches.length === 0) {
163
+ throw new Error(`Remote environment not found: ${target.connectionName}`);
164
+ }
165
+ if (matches.length > 1) {
166
+ throw new Error(
167
+ `Remote environment name is ambiguous: ${target.connectionName}`,
168
+ );
169
+ }
170
+ const match = matches[0];
171
+ if (!match) {
172
+ throw new Error(`Remote environment not found: ${target.connectionName}`);
173
+ }
174
+ return ensureOnline(match, target);
175
+ }
176
+
177
+ }