@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.
- package/AGENTS.md +47 -0
- package/README.md +17 -0
- package/dist/client-entry.js +800 -732
- package/dist/client-entry.js.map +8 -5
- package/dist/cloud-sandbox.d.ts +33 -0
- package/dist/cloud-sandbox.d.ts.map +1 -0
- package/dist/cloud-session.d.ts.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +799 -732
- package/dist/index.js.map +9 -6
- package/dist/remote-client-session-core.d.ts +6 -93
- package/dist/remote-client-session-core.d.ts.map +1 -1
- package/dist/remote-session-protocol.d.ts +130 -0
- package/dist/remote-session-protocol.d.ts.map +1 -0
- package/dist/remote-turn-coordinator.d.ts +49 -0
- package/dist/remote-turn-coordinator.d.ts.map +1 -0
- package/dist/types.d.ts +2 -20
- package/dist/types.d.ts.map +1 -1
- package/package.json +5 -2
- package/src/app-server-management.ts +641 -0
- package/src/app-server-session.ts +948 -0
- package/src/cli-resolver.ts +46 -0
- package/src/client-base.ts +482 -0
- package/src/client-entry.ts +31 -0
- package/src/client.ts +138 -0
- package/src/cloud-management.ts +360 -0
- package/src/cloud-sandbox.ts +117 -0
- package/src/cloud-session.ts +1313 -0
- package/src/index.ts +440 -0
- package/src/interactiveToolPolicy.ts +62 -0
- package/src/local-app-server-session.ts +39 -0
- package/src/local-app-server.ts +137 -0
- package/src/management-types.ts +133 -0
- package/src/management.ts +206 -0
- package/src/protocol.ts +249 -0
- package/src/remote-client-session-core.ts +786 -0
- package/src/remote-session-protocol.ts +660 -0
- package/src/remote-turn-coordinator.ts +505 -0
- package/src/remote.ts +177 -0
- package/src/repositories.ts +340 -0
- package/src/request-ids.ts +33 -0
- package/src/session.ts +1638 -0
- package/src/stream-events.ts +88 -0
- package/src/tool-helpers.ts +147 -0
- package/src/transport.ts +484 -0
- package/src/types.ts +1328 -0
- package/src/validation.ts +223 -0
- package/src/websocket.ts +22 -0
|
@@ -0,0 +1,1313 @@
|
|
|
1
|
+
import {
|
|
2
|
+
createAppServerClient,
|
|
3
|
+
type AppServerClient,
|
|
4
|
+
type AppServerSocketConstructor,
|
|
5
|
+
type AppServerSocketLike,
|
|
6
|
+
type AppServerSocketOptions,
|
|
7
|
+
} from "@letta-ai/letta-code/app-server-client";
|
|
8
|
+
import {
|
|
9
|
+
AppServerRuntimeController,
|
|
10
|
+
agentToolNames,
|
|
11
|
+
createAgentBody,
|
|
12
|
+
createExternalToolCallHandler,
|
|
13
|
+
externalToolGroups,
|
|
14
|
+
registerAppServerControlRequestHandler,
|
|
15
|
+
} from "./app-server-session.js";
|
|
16
|
+
import {
|
|
17
|
+
RemoteEnvironmentClient,
|
|
18
|
+
type RemoteEnvironmentTarget,
|
|
19
|
+
} from "./remote.js";
|
|
20
|
+
import { applyUniqueRequestIds } from "./request-ids.js";
|
|
21
|
+
import {
|
|
22
|
+
RemoteClientSessionCore,
|
|
23
|
+
mapPermissionMode,
|
|
24
|
+
type ProtocolMessage,
|
|
25
|
+
type RuntimeScope,
|
|
26
|
+
type RuntimeSessionInit,
|
|
27
|
+
type RuntimeSessionMode,
|
|
28
|
+
} from "./remote-client-session-core.js";
|
|
29
|
+
import type {
|
|
30
|
+
AnyAgentTool,
|
|
31
|
+
CreateAgentOptions,
|
|
32
|
+
LettaCodeClientSessionOptions,
|
|
33
|
+
LettaCodeCloudClientOptions,
|
|
34
|
+
LettaCodeEnvironment,
|
|
35
|
+
LettaCodeSocketConstructor,
|
|
36
|
+
LettaCodeSocketLike,
|
|
37
|
+
RepositoryResource,
|
|
38
|
+
} from "./types.js";
|
|
39
|
+
import {
|
|
40
|
+
type LettaCodeCloudSandboxOptions,
|
|
41
|
+
validateCloudSandboxOptions,
|
|
42
|
+
} from "./cloud-sandbox.js";
|
|
43
|
+
|
|
44
|
+
const DEFAULT_CLOUD_API_BASE_URL = "https://api.letta.com";
|
|
45
|
+
const DEFAULT_TURN_TIMEOUT_MS = 120_000;
|
|
46
|
+
const DEFAULT_PING_INTERVAL_MS = 30_000;
|
|
47
|
+
const DEFAULT_SANDBOX_TTL_MINUTES = 5;
|
|
48
|
+
const DEFAULT_SANDBOX_READY_TIMEOUT_MS = 120_000;
|
|
49
|
+
const DEFAULT_SANDBOX_READY_POLL_INTERVAL_MS = 1_000;
|
|
50
|
+
const DEFAULT_REPOSITORY_ATTACH_TIMEOUT_MS = 10_000;
|
|
51
|
+
const DEFAULT_REPOSITORY_ATTACH_POLL_INTERVAL_MS = 250;
|
|
52
|
+
const SDK_AGENT_ORIGIN = "@letta-ai/letta-agent-sdk";
|
|
53
|
+
|
|
54
|
+
type FetchLike = typeof fetch;
|
|
55
|
+
|
|
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
|
+
type CloudRuntimeStartResponse = ProtocolMessage & {
|
|
75
|
+
type: "runtime_start_response";
|
|
76
|
+
success: boolean;
|
|
77
|
+
runtime: RuntimeScope | null;
|
|
78
|
+
agent: (Record<string, unknown> & {
|
|
79
|
+
id?: string;
|
|
80
|
+
model?: string | null;
|
|
81
|
+
model_settings?: Record<string, unknown> | null;
|
|
82
|
+
}) | null;
|
|
83
|
+
conversation: (Record<string, unknown> & { id?: string; agent_id?: string }) | null;
|
|
84
|
+
error?: string;
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
type CloudConversation = Record<string, unknown> & {
|
|
88
|
+
id?: string;
|
|
89
|
+
agent_id?: string;
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
type CloudAgentSandbox = Record<string, unknown> & {
|
|
93
|
+
sandboxId?: string;
|
|
94
|
+
deviceId?: string;
|
|
95
|
+
connectionName?: string;
|
|
96
|
+
conversationId?: string;
|
|
97
|
+
resumed?: boolean;
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
type CloudAgentSandboxRefresh = Record<string, unknown> & {
|
|
101
|
+
success?: boolean;
|
|
102
|
+
sandboxId?: string;
|
|
103
|
+
ttlMinutes?: number;
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
type ManagedCloudSandbox = {
|
|
107
|
+
agentId: string;
|
|
108
|
+
/** Non-null when the server confirmed conversation scoping by echoing the
|
|
109
|
+
* conversationId (legacy servers strip unknown body keys and answer
|
|
110
|
+
* without it, so the sandbox falls back to the agent-scoped lifecycle). */
|
|
111
|
+
conversationId: string | null;
|
|
112
|
+
sandboxId: string;
|
|
113
|
+
deviceId: string;
|
|
114
|
+
connectionName: string;
|
|
115
|
+
ttlMinutes: number;
|
|
116
|
+
readyTimeoutMs: number;
|
|
117
|
+
readyPollIntervalMs: number;
|
|
118
|
+
refreshIntervalMs: number;
|
|
119
|
+
terminateOnClose: boolean;
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
type ResolvedCloudConnection = {
|
|
123
|
+
connectionId: string;
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
class CloudManagedSandboxOwnershipError extends Error {}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* The Cloud API reaped a conversation-scoped managed sandbox before the SDK
|
|
130
|
+
* could refresh it. The failed refresh happens before the next turn is sent,
|
|
131
|
+
* so callers can safely create a new SDK session for {@link conversationId}
|
|
132
|
+
* and retry that turn.
|
|
133
|
+
*/
|
|
134
|
+
export class CloudManagedSandboxExpiredError extends Error {
|
|
135
|
+
readonly code = "managed_sandbox_expired" as const;
|
|
136
|
+
|
|
137
|
+
constructor(
|
|
138
|
+
readonly sandboxId: string,
|
|
139
|
+
readonly conversationId: string,
|
|
140
|
+
) {
|
|
141
|
+
super(
|
|
142
|
+
`Cloud managed sandbox ${sandboxId} expired. Resume conversation ${conversationId} with a new SDK session and retry the turn.`,
|
|
143
|
+
);
|
|
144
|
+
this.name = "CloudManagedSandboxExpiredError";
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
type CloudSessionMode = Extract<RuntimeSessionMode, { kind: "session" }>;
|
|
149
|
+
|
|
150
|
+
type CloudAgentRepository = {
|
|
151
|
+
id: string;
|
|
152
|
+
name?: string;
|
|
153
|
+
is_primary?: boolean;
|
|
154
|
+
};
|
|
155
|
+
|
|
156
|
+
function getDefaultApiKey(): string | undefined {
|
|
157
|
+
const env = (globalThis as { process?: { env?: Record<string, string | undefined> } })
|
|
158
|
+
.process?.env;
|
|
159
|
+
return env?.LETTA_API_KEY ?? env?.LETTA_CLOUD_API_KEY;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function bearerTokenFromHeaders(headers: Record<string, string> | undefined): string | undefined {
|
|
163
|
+
const authorization = headers?.Authorization ?? headers?.authorization;
|
|
164
|
+
if (!authorization) return undefined;
|
|
165
|
+
const match = /^Bearer\s+(.+)$/i.exec(authorization);
|
|
166
|
+
return match?.[1];
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function getCloudApiKey(options: LettaCodeCloudClientOptions): string | undefined {
|
|
170
|
+
return options.apiKey ?? bearerTokenFromHeaders(options.headers) ?? getDefaultApiKey();
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function getFetch(fetchOverride?: FetchLike): FetchLike {
|
|
174
|
+
const resolved = fetchOverride ?? globalThis.fetch;
|
|
175
|
+
if (!resolved) {
|
|
176
|
+
throw new Error("No fetch implementation available for cloud backend.");
|
|
177
|
+
}
|
|
178
|
+
return resolved.bind(globalThis) as FetchLike;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function getWebSocketConstructor(
|
|
182
|
+
websocketOverride?: LettaCodeSocketConstructor,
|
|
183
|
+
): LettaCodeSocketConstructor {
|
|
184
|
+
const resolved =
|
|
185
|
+
websocketOverride ??
|
|
186
|
+
(globalThis as { WebSocket?: LettaCodeSocketConstructor }).WebSocket;
|
|
187
|
+
if (!resolved) {
|
|
188
|
+
throw new Error("No WebSocket implementation available for cloud backend.");
|
|
189
|
+
}
|
|
190
|
+
return resolved;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function normalizeCloudApiBaseUrl(url: string | undefined): string {
|
|
194
|
+
const parsed = new URL(url ?? DEFAULT_CLOUD_API_BASE_URL);
|
|
195
|
+
parsed.pathname = parsed.pathname.replace(/\/+$/, "");
|
|
196
|
+
parsed.search = "";
|
|
197
|
+
parsed.hash = "";
|
|
198
|
+
return parsed.toString().replace(/\/$/, "");
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function cloudHeaders(options: LettaCodeCloudClientOptions): Record<string, string> {
|
|
202
|
+
const headers: Record<string, string> = {
|
|
203
|
+
"Content-Type": "application/json",
|
|
204
|
+
...(options.headers ?? {}),
|
|
205
|
+
};
|
|
206
|
+
const apiKey = getCloudApiKey(options);
|
|
207
|
+
if (apiKey && !headers.Authorization && !headers.authorization) {
|
|
208
|
+
headers.Authorization = `Bearer ${apiKey}`;
|
|
209
|
+
}
|
|
210
|
+
return headers;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
function cloudWebSocketHeaders(options: LettaCodeCloudClientOptions): Record<string, string> | undefined {
|
|
214
|
+
const headers = { ...(options.headers ?? {}) };
|
|
215
|
+
delete headers.authorization;
|
|
216
|
+
delete headers.Authorization;
|
|
217
|
+
const apiKey = getCloudApiKey(options);
|
|
218
|
+
if (apiKey) headers.Authorization = `Bearer ${apiKey}`;
|
|
219
|
+
return Object.keys(headers).length > 0 ? headers : undefined;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
async function parseJsonResponse(response: Response): Promise<unknown> {
|
|
223
|
+
const text = await response.text();
|
|
224
|
+
if (!text) return null;
|
|
225
|
+
try {
|
|
226
|
+
return JSON.parse(text) as unknown;
|
|
227
|
+
} catch {
|
|
228
|
+
return text;
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function responseErrorMessage(body: unknown, fallback: string): string {
|
|
233
|
+
if (body && typeof body === "object") {
|
|
234
|
+
const record = body as Record<string, unknown>;
|
|
235
|
+
const message = record.message ?? record.error ?? record.detail;
|
|
236
|
+
const reasonText = record.reason_text;
|
|
237
|
+
const pieces = [message, reasonText]
|
|
238
|
+
.filter((value): value is string => typeof value === "string" && value.length > 0);
|
|
239
|
+
if (pieces.length > 0) return pieces.join(": ");
|
|
240
|
+
}
|
|
241
|
+
return fallback;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
function assertOkResponse(response: Response, body: unknown, action: string, url?: string): void {
|
|
245
|
+
if (!response.ok) {
|
|
246
|
+
const detail = responseErrorMessage(body, `HTTP ${response.status}`);
|
|
247
|
+
const parts = [`${action} failed`, detail];
|
|
248
|
+
if (url) parts.push(`URL: ${url}`);
|
|
249
|
+
if (response.status === 401 || response.status === 403) {
|
|
250
|
+
const env = (globalThis as { process?: { env?: Record<string, string | undefined> } })
|
|
251
|
+
.process?.env;
|
|
252
|
+
const hasKey = !!(env?.LETTA_API_KEY ?? env?.LETTA_CLOUD_API_KEY);
|
|
253
|
+
parts.push(
|
|
254
|
+
hasKey
|
|
255
|
+
? "Authentication failed — the API key may be invalid or lack permissions for this resource."
|
|
256
|
+
: "No API key found. Set LETTA_API_KEY (or pass apiKey in client options).",
|
|
257
|
+
);
|
|
258
|
+
}
|
|
259
|
+
throw new Error(parts.join(" — "));
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
function validatePositiveInteger(value: number | undefined, name: string): void {
|
|
264
|
+
if (value !== undefined && (!Number.isInteger(value) || value <= 0)) {
|
|
265
|
+
throw new Error(`Invalid ${name}. Expected a positive integer.`);
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
export function validateCloudClientOptions(options: LettaCodeCloudClientOptions): void {
|
|
270
|
+
validatePositiveInteger(options.requestTimeoutMs, "requestTimeoutMs");
|
|
271
|
+
validateCloudSandboxOptions(options.sandbox, "sandbox");
|
|
272
|
+
if (options.environment !== undefined && options.sandbox !== undefined) {
|
|
273
|
+
throw new Error("Letta Cloud sessions cannot specify both environment and sandbox options.");
|
|
274
|
+
}
|
|
275
|
+
if (
|
|
276
|
+
options.webSocketAuth !== undefined &&
|
|
277
|
+
options.webSocketAuth !== "header" &&
|
|
278
|
+
options.webSocketAuth !== "query"
|
|
279
|
+
) {
|
|
280
|
+
throw new Error("Invalid webSocketAuth. Valid values: header, query.");
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
function environmentToRemoteTarget(
|
|
285
|
+
environment: LettaCodeEnvironment,
|
|
286
|
+
): RemoteEnvironmentTarget {
|
|
287
|
+
if (typeof environment === "string") {
|
|
288
|
+
return { connectionName: environment };
|
|
289
|
+
}
|
|
290
|
+
if ("name" in environment) {
|
|
291
|
+
return { connectionName: environment.name };
|
|
292
|
+
}
|
|
293
|
+
if ("id" in environment) {
|
|
294
|
+
return { environmentId: environment.id };
|
|
295
|
+
}
|
|
296
|
+
if ("connectionId" in environment) {
|
|
297
|
+
return { connectionId: environment.connectionId };
|
|
298
|
+
}
|
|
299
|
+
if ("deviceId" in environment) {
|
|
300
|
+
return { deviceId: environment.deviceId };
|
|
301
|
+
}
|
|
302
|
+
throw new Error("Unknown cloud environment selector.");
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
function buildCloudStatusWebSocketUrl(params: {
|
|
306
|
+
apiBaseUrl?: string;
|
|
307
|
+
connectionId: string;
|
|
308
|
+
agentId: string;
|
|
309
|
+
conversationId: string;
|
|
310
|
+
apiKey?: string;
|
|
311
|
+
authMode: "header" | "query";
|
|
312
|
+
}): string {
|
|
313
|
+
const base = new URL(normalizeCloudApiBaseUrl(params.apiBaseUrl));
|
|
314
|
+
if (base.protocol === "http:") {
|
|
315
|
+
base.protocol = "ws:";
|
|
316
|
+
} else if (base.protocol === "https:") {
|
|
317
|
+
base.protocol = "wss:";
|
|
318
|
+
} else if (base.protocol !== "ws:" && base.protocol !== "wss:") {
|
|
319
|
+
throw new Error(`Unsupported cloud apiBaseUrl protocol: ${base.protocol}`);
|
|
320
|
+
}
|
|
321
|
+
base.pathname = `/v1/environments/${encodeURIComponent(params.connectionId)}/status/ws`;
|
|
322
|
+
base.searchParams.set("agentId", params.agentId);
|
|
323
|
+
base.searchParams.set("conversationId", params.conversationId);
|
|
324
|
+
base.searchParams.set("channel", "stream");
|
|
325
|
+
if (params.authMode === "query" && params.apiKey) {
|
|
326
|
+
base.searchParams.set("token", params.apiKey);
|
|
327
|
+
}
|
|
328
|
+
return base.toString();
|
|
329
|
+
}
|
|
330
|
+
|
|
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
|
+
function isCloudConversation(value: unknown): value is CloudConversation & { id: string } {
|
|
557
|
+
return Boolean(value && typeof value === "object" && typeof (value as CloudConversation).id === "string");
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
function isCloudAgentSandbox(
|
|
561
|
+
value: unknown,
|
|
562
|
+
): value is CloudAgentSandbox & { sandboxId: string; deviceId: string; connectionName: string } {
|
|
563
|
+
return Boolean(
|
|
564
|
+
value &&
|
|
565
|
+
typeof value === "object" &&
|
|
566
|
+
typeof (value as CloudAgentSandbox).sandboxId === "string" &&
|
|
567
|
+
typeof (value as CloudAgentSandbox).deviceId === "string" &&
|
|
568
|
+
typeof (value as CloudAgentSandbox).connectionName === "string",
|
|
569
|
+
);
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
function isCloudAgentSandboxRefresh(
|
|
573
|
+
value: unknown,
|
|
574
|
+
): value is CloudAgentSandboxRefresh & { success: boolean; sandboxId: string; ttlMinutes: number } {
|
|
575
|
+
return Boolean(
|
|
576
|
+
value &&
|
|
577
|
+
typeof value === "object" &&
|
|
578
|
+
typeof (value as CloudAgentSandboxRefresh).success === "boolean" &&
|
|
579
|
+
typeof (value as CloudAgentSandboxRefresh).sandboxId === "string" &&
|
|
580
|
+
typeof (value as CloudAgentSandboxRefresh).ttlMinutes === "number",
|
|
581
|
+
);
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
function isRetryableManagedSandboxResolveError(error: unknown): boolean {
|
|
585
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
586
|
+
return (
|
|
587
|
+
message.includes("Remote environment is offline") ||
|
|
588
|
+
message.toLowerCase().includes("not found") ||
|
|
589
|
+
message.includes("(404)")
|
|
590
|
+
);
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
function sleep(ms: number): Promise<void> {
|
|
594
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
function externalToolsByName(tools: AnyAgentTool[] | undefined): Map<string, AnyAgentTool> {
|
|
598
|
+
const result = new Map<string, AnyAgentTool>();
|
|
599
|
+
for (const tool of tools ?? []) {
|
|
600
|
+
result.set(tool.name, tool);
|
|
601
|
+
}
|
|
602
|
+
return result;
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
export async function createCloudAgent(
|
|
606
|
+
clientOptions: LettaCodeCloudClientOptions,
|
|
607
|
+
agentOptions: CreateAgentOptions,
|
|
608
|
+
): Promise<string> {
|
|
609
|
+
const body = await createAgentBody(agentOptions);
|
|
610
|
+
|
|
611
|
+
// No trailing slash: production api.letta.com 404s `POST /v1/agents/` at
|
|
612
|
+
// the router (GET tolerates the slash; POST does not).
|
|
613
|
+
const response = await getFetch(clientOptions.fetch)(
|
|
614
|
+
`${normalizeCloudApiBaseUrl(clientOptions.apiBaseUrl)}/v1/agents`,
|
|
615
|
+
{
|
|
616
|
+
method: "POST",
|
|
617
|
+
headers: cloudHeaders(clientOptions),
|
|
618
|
+
body: JSON.stringify(body),
|
|
619
|
+
},
|
|
620
|
+
);
|
|
621
|
+
const responseBody = await parseJsonResponse(response);
|
|
622
|
+
assertOkResponse(response, responseBody, "Cloud create agent");
|
|
623
|
+
const agentId =
|
|
624
|
+
responseBody && typeof responseBody === "object"
|
|
625
|
+
? (responseBody as { id?: unknown }).id
|
|
626
|
+
: undefined;
|
|
627
|
+
if (typeof agentId !== "string" || agentId.length === 0) {
|
|
628
|
+
throw new Error("Cloud create agent response did not include an agent id.");
|
|
629
|
+
}
|
|
630
|
+
return agentId;
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
export function assertCloudSessionOptionsSupported(
|
|
634
|
+
action: string,
|
|
635
|
+
options: LettaCodeClientSessionOptions,
|
|
636
|
+
): void {
|
|
637
|
+
validateCloudSandboxOptions(options.sandbox, "sandbox");
|
|
638
|
+
if (options.environment !== undefined && options.sandbox !== undefined) {
|
|
639
|
+
throw new Error(`Letta Cloud ${action}() cannot specify both environment and sandbox options.`);
|
|
640
|
+
}
|
|
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
|
+
}
|
|
660
|
+
|
|
661
|
+
export class CloudEnvironmentSession extends RemoteClientSessionCore {
|
|
662
|
+
private connectionId: string | null = null;
|
|
663
|
+
private removeExternalToolHandler: (() => void) | null = null;
|
|
664
|
+
private removeControlRequestHandler: (() => void) | null = null;
|
|
665
|
+
private externalTools = new Map<string, AnyAgentTool>();
|
|
666
|
+
private managedSandbox: ManagedCloudSandbox | null = null;
|
|
667
|
+
private sandboxRefreshTimer: ReturnType<typeof setInterval> | null = null;
|
|
668
|
+
private sandboxRefreshInFlight: Promise<void> | null = null;
|
|
669
|
+
private sandboxLifecycleClosing = false;
|
|
670
|
+
private attachedRepositoryIds = new Set<string>();
|
|
671
|
+
private readonly cloudMode: CloudSessionMode;
|
|
672
|
+
|
|
673
|
+
constructor(
|
|
674
|
+
private readonly cloudOptions: LettaCodeCloudClientOptions,
|
|
675
|
+
mode: CloudSessionMode,
|
|
676
|
+
) {
|
|
677
|
+
super(mode, {
|
|
678
|
+
label: "cloud",
|
|
679
|
+
requestTimeoutMs: cloudOptions.requestTimeoutMs ?? DEFAULT_TURN_TIMEOUT_MS,
|
|
680
|
+
});
|
|
681
|
+
this.cloudMode = mode;
|
|
682
|
+
const tools = mode.options.tools;
|
|
683
|
+
this.externalTools = externalToolsByName(tools);
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
protected override async initializeRuntimeController(): Promise<RuntimeSessionInit> {
|
|
687
|
+
const resolved = await this.resolveRuntime();
|
|
688
|
+
const connection = await this.resolveConnectionForRuntime(resolved.runtime);
|
|
689
|
+
this.connectionId = connection.connectionId;
|
|
690
|
+
|
|
691
|
+
const apiKey = getCloudApiKey(this.cloudOptions);
|
|
692
|
+
const url = buildCloudStatusWebSocketUrl({
|
|
693
|
+
apiBaseUrl: this.cloudOptions.apiBaseUrl,
|
|
694
|
+
connectionId: connection.connectionId,
|
|
695
|
+
agentId: resolved.runtime.agent_id,
|
|
696
|
+
conversationId: resolved.runtime.conversation_id,
|
|
697
|
+
apiKey,
|
|
698
|
+
authMode: this.cloudOptions.webSocketAuth ?? "header",
|
|
699
|
+
});
|
|
700
|
+
const client = applyUniqueRequestIds(createAppServerClient({
|
|
701
|
+
url,
|
|
702
|
+
WebSocket: createCloudStatusWebSocketConstructor({
|
|
703
|
+
cloudOptions: this.cloudOptions,
|
|
704
|
+
runtime: resolved.runtime,
|
|
705
|
+
}),
|
|
706
|
+
requestTimeoutMs: this.cloudOptions.requestTimeoutMs ?? DEFAULT_TURN_TIMEOUT_MS,
|
|
707
|
+
}));
|
|
708
|
+
this.removeControlRequestHandler = registerAppServerControlRequestHandler({
|
|
709
|
+
client,
|
|
710
|
+
getRuntime: () => this.runtime,
|
|
711
|
+
getOptions: () => this.currentOptions(),
|
|
712
|
+
});
|
|
713
|
+
if (this.externalTools.size > 0) {
|
|
714
|
+
this.removeExternalToolHandler = client.onExternalToolCall(
|
|
715
|
+
createExternalToolCallHandler(this.externalTools),
|
|
716
|
+
);
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
try {
|
|
720
|
+
await client.connect();
|
|
721
|
+
const response = await this.startCloudRuntime(client, resolved.runtime);
|
|
722
|
+
if (!response.success || !response.runtime) {
|
|
723
|
+
throw new Error(response.error ?? "Failed to start Cloud status runtime");
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
const tools = agentToolNames(response.agent);
|
|
727
|
+
const skillSources = this.currentOptions().skillSources;
|
|
728
|
+
return {
|
|
729
|
+
controller: new AppServerRuntimeController(
|
|
730
|
+
client,
|
|
731
|
+
{
|
|
732
|
+
requestTimeoutMs: this.cloudOptions.requestTimeoutMs ?? DEFAULT_TURN_TIMEOUT_MS,
|
|
733
|
+
},
|
|
734
|
+
this.currentOptions().allowedTools,
|
|
735
|
+
),
|
|
736
|
+
runtime: response.runtime,
|
|
737
|
+
model: typeof response.agent?.model === "string" ? response.agent.model : "",
|
|
738
|
+
modelSettings: response.agent?.model_settings ?? null,
|
|
739
|
+
...(tools !== undefined ? { tools } : {}),
|
|
740
|
+
...(skillSources !== undefined ? { skillSources: [...skillSources] } : {}),
|
|
741
|
+
};
|
|
742
|
+
} catch (error) {
|
|
743
|
+
this.removeExternalToolHandler?.();
|
|
744
|
+
this.removeExternalToolHandler = null;
|
|
745
|
+
this.removeControlRequestHandler?.();
|
|
746
|
+
this.removeControlRequestHandler = null;
|
|
747
|
+
client.close();
|
|
748
|
+
await this.cleanupManagedSandbox();
|
|
749
|
+
await this.cleanupSessionRepositories(resolved.runtime.agent_id);
|
|
750
|
+
throw error;
|
|
751
|
+
}
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
private async startCloudRuntime(
|
|
755
|
+
client: AppServerClient,
|
|
756
|
+
runtime: RuntimeScope,
|
|
757
|
+
): Promise<CloudRuntimeStartResponse> {
|
|
758
|
+
const options = this.currentOptions();
|
|
759
|
+
const command: Record<string, unknown> = {
|
|
760
|
+
client_info: {
|
|
761
|
+
name: SDK_AGENT_ORIGIN,
|
|
762
|
+
title: "Letta Agent SDK",
|
|
763
|
+
},
|
|
764
|
+
agent_id: runtime.agent_id,
|
|
765
|
+
conversation_id: runtime.conversation_id,
|
|
766
|
+
recover_approvals: false,
|
|
767
|
+
force_device_status: true,
|
|
768
|
+
};
|
|
769
|
+
|
|
770
|
+
const mode = mapPermissionMode(options.permissionMode);
|
|
771
|
+
if (mode) command.mode = mode;
|
|
772
|
+
if (options.cwd !== undefined) command.cwd = options.cwd;
|
|
773
|
+
if (options.skillSources !== undefined) {
|
|
774
|
+
command.skill_sources = [...new Set(options.skillSources)];
|
|
775
|
+
}
|
|
776
|
+
const groups = externalToolGroups(options.tools);
|
|
777
|
+
if (groups) command.external_tools = groups;
|
|
778
|
+
|
|
779
|
+
return (await client.runtimeStart(
|
|
780
|
+
command as Parameters<AppServerClient["runtimeStart"]>[0],
|
|
781
|
+
)) as unknown as CloudRuntimeStartResponse;
|
|
782
|
+
}
|
|
783
|
+
|
|
784
|
+
protected override async afterRuntimeInitialized(): Promise<void> {
|
|
785
|
+
if (!this.controller || !this.runtime) return;
|
|
786
|
+
this.controller.send({
|
|
787
|
+
type: "sync",
|
|
788
|
+
runtime: this.runtime,
|
|
789
|
+
recover_approvals: true,
|
|
790
|
+
force_device_status: true,
|
|
791
|
+
});
|
|
792
|
+
}
|
|
793
|
+
|
|
794
|
+
protected override async beforeTurn(): Promise<void> {
|
|
795
|
+
const sandbox = this.managedSandbox;
|
|
796
|
+
if (!sandbox) return;
|
|
797
|
+
await this.refreshManagedSandbox(sandbox);
|
|
798
|
+
}
|
|
799
|
+
|
|
800
|
+
protected override onCoreClose(): void {
|
|
801
|
+
this.removeExternalToolHandler?.();
|
|
802
|
+
this.removeExternalToolHandler = null;
|
|
803
|
+
this.removeControlRequestHandler?.();
|
|
804
|
+
this.removeControlRequestHandler = null;
|
|
805
|
+
void this.cleanupManagedSandbox();
|
|
806
|
+
if (this.runtime?.agent_id) void this.cleanupSessionRepositories(this.runtime.agent_id);
|
|
807
|
+
}
|
|
808
|
+
|
|
809
|
+
private async resolveRuntime(): Promise<{ runtime: RuntimeScope }> {
|
|
810
|
+
let agentId = this.cloudMode.agentId;
|
|
811
|
+
let conversationId = this.cloudMode.conversationId;
|
|
812
|
+
|
|
813
|
+
if (!agentId && conversationId) {
|
|
814
|
+
const conversation = await this.retrieveConversation(conversationId);
|
|
815
|
+
if (!conversation.agent_id) {
|
|
816
|
+
throw new Error(`Cloud conversation ${conversationId} did not include an agent id.`);
|
|
817
|
+
}
|
|
818
|
+
agentId = conversation.agent_id;
|
|
819
|
+
}
|
|
820
|
+
|
|
821
|
+
if (!agentId) {
|
|
822
|
+
throw new Error(
|
|
823
|
+
"Letta Cloud createSession()/resumeSession() requires an agent id or conversation id.",
|
|
824
|
+
);
|
|
825
|
+
}
|
|
826
|
+
|
|
827
|
+
await this.attachSessionRepositories(agentId);
|
|
828
|
+
|
|
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
|
+
}
|
|
835
|
+
|
|
836
|
+
if (!conversationId) {
|
|
837
|
+
throw new Error(
|
|
838
|
+
"Letta Cloud createSession()/resumeSession() requires an agent id or conversation id.",
|
|
839
|
+
);
|
|
840
|
+
}
|
|
841
|
+
|
|
842
|
+
return { runtime: { agent_id: agentId, conversation_id: conversationId } };
|
|
843
|
+
}
|
|
844
|
+
|
|
845
|
+
private async attachSessionRepositories(agentId: string): Promise<void> {
|
|
846
|
+
const resources = this.repositoryResources();
|
|
847
|
+
if (resources.length === 0) return;
|
|
848
|
+
|
|
849
|
+
const existing = await this.listAgentRepositories(agentId);
|
|
850
|
+
const existingIds = new Set(existing.map((repository) => repository.id));
|
|
851
|
+
|
|
852
|
+
try {
|
|
853
|
+
for (const resource of resources) {
|
|
854
|
+
if (existingIds.has(resource.repositoryId) || this.attachedRepositoryIds.has(resource.repositoryId)) {
|
|
855
|
+
continue;
|
|
856
|
+
}
|
|
857
|
+
await this.linkAgentRepository(agentId, resource.repositoryId);
|
|
858
|
+
await this.waitForAgentRepository(agentId, resource.repositoryId);
|
|
859
|
+
this.attachedRepositoryIds.add(resource.repositoryId);
|
|
860
|
+
}
|
|
861
|
+
} catch (error) {
|
|
862
|
+
await this.cleanupSessionRepositories(agentId);
|
|
863
|
+
throw error;
|
|
864
|
+
}
|
|
865
|
+
}
|
|
866
|
+
|
|
867
|
+
private async cleanupSessionRepositories(agentId: string): Promise<void> {
|
|
868
|
+
const repositoryIds = [...this.attachedRepositoryIds];
|
|
869
|
+
this.attachedRepositoryIds.clear();
|
|
870
|
+
await Promise.all(repositoryIds.map(async (repositoryId) => {
|
|
871
|
+
try {
|
|
872
|
+
await this.unlinkAgentRepository(agentId, repositoryId);
|
|
873
|
+
} catch {
|
|
874
|
+
// Best-effort cleanup: only repositories this SDK session attached are removed.
|
|
875
|
+
}
|
|
876
|
+
}));
|
|
877
|
+
}
|
|
878
|
+
|
|
879
|
+
private repositoryResources(): RepositoryResource[] {
|
|
880
|
+
const resources = this.cloudMode.options.resources ?? [];
|
|
881
|
+
const seen = new Set<string>();
|
|
882
|
+
const result: RepositoryResource[] = [];
|
|
883
|
+
for (const resource of resources) {
|
|
884
|
+
if (resource.type !== "repository") {
|
|
885
|
+
throw new Error(`Unsupported Cloud session resource type: ${String(resource.type)}`);
|
|
886
|
+
}
|
|
887
|
+
if (typeof resource.repositoryId !== "string" || resource.repositoryId.length === 0) {
|
|
888
|
+
throw new Error("Cloud session repository resources require repositoryId.");
|
|
889
|
+
}
|
|
890
|
+
if (seen.has(resource.repositoryId)) continue;
|
|
891
|
+
seen.add(resource.repositoryId);
|
|
892
|
+
result.push(resource);
|
|
893
|
+
}
|
|
894
|
+
return result;
|
|
895
|
+
}
|
|
896
|
+
|
|
897
|
+
private async listAgentRepositories(agentId: string): Promise<CloudAgentRepository[]> {
|
|
898
|
+
const fetchImpl = getFetch(this.cloudOptions.fetch);
|
|
899
|
+
const baseUrl = normalizeCloudApiBaseUrl(this.cloudOptions.apiBaseUrl);
|
|
900
|
+
const response = await fetchImpl(
|
|
901
|
+
`${baseUrl}/v1/agents/${encodeURIComponent(agentId)}/repositories`,
|
|
902
|
+
{ headers: cloudHeaders(this.cloudOptions) },
|
|
903
|
+
);
|
|
904
|
+
const body = await parseJsonResponse(response);
|
|
905
|
+
assertOkResponse(response, body, "Cloud list agent repositories");
|
|
906
|
+
if (!body || typeof body !== "object") return [];
|
|
907
|
+
const repositories = (body as Record<string, unknown>).repositories;
|
|
908
|
+
return Array.isArray(repositories)
|
|
909
|
+
? repositories.filter((repository): repository is CloudAgentRepository => (
|
|
910
|
+
repository !== null &&
|
|
911
|
+
typeof repository === "object" &&
|
|
912
|
+
typeof (repository as Record<string, unknown>).id === "string"
|
|
913
|
+
))
|
|
914
|
+
: [];
|
|
915
|
+
}
|
|
916
|
+
|
|
917
|
+
private async waitForAgentRepository(agentId: string, repositoryId: string): Promise<void> {
|
|
918
|
+
const deadline = Date.now() + DEFAULT_REPOSITORY_ATTACH_TIMEOUT_MS;
|
|
919
|
+
while (true) {
|
|
920
|
+
const repositories = await this.listAgentRepositories(agentId);
|
|
921
|
+
if (repositories.some((repository) => repository.id === repositoryId)) return;
|
|
922
|
+
if (Date.now() >= deadline) {
|
|
923
|
+
throw new Error(`Cloud attach agent repository did not become visible for ${agentId}: ${repositoryId}`);
|
|
924
|
+
}
|
|
925
|
+
await sleep(DEFAULT_REPOSITORY_ATTACH_POLL_INTERVAL_MS);
|
|
926
|
+
}
|
|
927
|
+
}
|
|
928
|
+
|
|
929
|
+
private async linkAgentRepository(agentId: string, repositoryId: string): Promise<void> {
|
|
930
|
+
const fetchImpl = getFetch(this.cloudOptions.fetch);
|
|
931
|
+
const baseUrl = normalizeCloudApiBaseUrl(this.cloudOptions.apiBaseUrl);
|
|
932
|
+
const response = await fetchImpl(
|
|
933
|
+
`${baseUrl}/v1/agents/${encodeURIComponent(agentId)}/repositories`,
|
|
934
|
+
{
|
|
935
|
+
method: "POST",
|
|
936
|
+
headers: cloudHeaders(this.cloudOptions),
|
|
937
|
+
body: JSON.stringify({ repository_id: repositoryId }),
|
|
938
|
+
},
|
|
939
|
+
);
|
|
940
|
+
const body = await parseJsonResponse(response);
|
|
941
|
+
assertOkResponse(response, body, "Cloud attach agent repository");
|
|
942
|
+
}
|
|
943
|
+
|
|
944
|
+
private async unlinkAgentRepository(agentId: string, repositoryId: string): Promise<void> {
|
|
945
|
+
const fetchImpl = getFetch(this.cloudOptions.fetch);
|
|
946
|
+
const baseUrl = normalizeCloudApiBaseUrl(this.cloudOptions.apiBaseUrl);
|
|
947
|
+
const response = await fetchImpl(
|
|
948
|
+
`${baseUrl}/v1/agents/${encodeURIComponent(agentId)}/repositories/${encodeURIComponent(repositoryId)}`,
|
|
949
|
+
{ method: "DELETE", headers: cloudHeaders(this.cloudOptions) },
|
|
950
|
+
);
|
|
951
|
+
const body = await parseJsonResponse(response);
|
|
952
|
+
if (response.status === 404) return;
|
|
953
|
+
assertOkResponse(response, body, "Cloud detach agent repository");
|
|
954
|
+
}
|
|
955
|
+
|
|
956
|
+
private async createConversation(agentId: string): Promise<{ id: string; agent_id?: string }> {
|
|
957
|
+
const fetchImpl = getFetch(this.cloudOptions.fetch);
|
|
958
|
+
const baseUrl = normalizeCloudApiBaseUrl(this.cloudOptions.apiBaseUrl);
|
|
959
|
+
const url = new URL(`${baseUrl}/v1/conversations/`);
|
|
960
|
+
url.searchParams.set("agent_id", agentId);
|
|
961
|
+
const response = await fetchImpl(url, {
|
|
962
|
+
method: "POST",
|
|
963
|
+
headers: cloudHeaders(this.cloudOptions),
|
|
964
|
+
body: JSON.stringify({}),
|
|
965
|
+
});
|
|
966
|
+
const body = await parseJsonResponse(response);
|
|
967
|
+
assertOkResponse(response, body, "Cloud createSession()", url.toString());
|
|
968
|
+
if (!isCloudConversation(body)) {
|
|
969
|
+
throw new Error("Cloud createSession() response did not include a conversation id.");
|
|
970
|
+
}
|
|
971
|
+
return { id: body.id, agent_id: body.agent_id };
|
|
972
|
+
}
|
|
973
|
+
|
|
974
|
+
private async retrieveConversation(conversationId: string): Promise<{ id: string; agent_id?: string }> {
|
|
975
|
+
const fetchImpl = getFetch(this.cloudOptions.fetch);
|
|
976
|
+
const baseUrl = normalizeCloudApiBaseUrl(this.cloudOptions.apiBaseUrl);
|
|
977
|
+
const response = await fetchImpl(
|
|
978
|
+
`${baseUrl}/v1/conversations/${encodeURIComponent(conversationId)}`,
|
|
979
|
+
{ headers: cloudHeaders(this.cloudOptions) },
|
|
980
|
+
);
|
|
981
|
+
const body = await parseJsonResponse(response);
|
|
982
|
+
assertOkResponse(response, body, "Cloud resumeSession()", `${baseUrl}/v1/conversations/${encodeURIComponent(conversationId)}`);
|
|
983
|
+
if (!isCloudConversation(body)) {
|
|
984
|
+
throw new Error(`Cloud resumeSession() could not retrieve conversation ${conversationId}.`);
|
|
985
|
+
}
|
|
986
|
+
return { id: body.id, agent_id: body.agent_id };
|
|
987
|
+
}
|
|
988
|
+
|
|
989
|
+
private async resolveConnectionForRuntime(
|
|
990
|
+
runtime: RuntimeScope,
|
|
991
|
+
): Promise<ResolvedCloudConnection> {
|
|
992
|
+
const environment = this.effectiveEnvironment();
|
|
993
|
+
const sandboxOptions = this.effectiveSandboxOptions();
|
|
994
|
+
if (environment !== undefined) {
|
|
995
|
+
if (sandboxOptions !== undefined) {
|
|
996
|
+
throw new Error("Letta Cloud sessions cannot specify both environment and sandbox options.");
|
|
997
|
+
}
|
|
998
|
+
return this.resolveExplicitConnection(environment);
|
|
999
|
+
}
|
|
1000
|
+
return this.createManagedSandboxConnection(runtime);
|
|
1001
|
+
}
|
|
1002
|
+
|
|
1003
|
+
private async resolveExplicitConnection(
|
|
1004
|
+
environment: LettaCodeEnvironment,
|
|
1005
|
+
): Promise<{ connectionId: string }> {
|
|
1006
|
+
const target = environmentToRemoteTarget(environment);
|
|
1007
|
+
const resolved = await this.remoteEnvironmentClient().resolveEnvironment(target);
|
|
1008
|
+
return { connectionId: resolved.connectionId };
|
|
1009
|
+
}
|
|
1010
|
+
|
|
1011
|
+
private async createManagedSandboxConnection(
|
|
1012
|
+
runtime: RuntimeScope,
|
|
1013
|
+
): Promise<ResolvedCloudConnection> {
|
|
1014
|
+
// Scope the managed sandbox to the conversation when one exists: the
|
|
1015
|
+
// server treats create-with-conversationId as create-or-resume, so
|
|
1016
|
+
// sessions of the same conversation share one sandbox and sessions of
|
|
1017
|
+
// different conversations stop contending for a single per-agent one.
|
|
1018
|
+
const conversationId =
|
|
1019
|
+
runtime.conversation_id && runtime.conversation_id !== "default"
|
|
1020
|
+
? runtime.conversation_id
|
|
1021
|
+
: undefined;
|
|
1022
|
+
const sandbox = await this.createManagedSandbox(
|
|
1023
|
+
runtime.agent_id,
|
|
1024
|
+
conversationId,
|
|
1025
|
+
);
|
|
1026
|
+
this.managedSandbox = sandbox;
|
|
1027
|
+
|
|
1028
|
+
if (this.sandboxLifecycleClosing) {
|
|
1029
|
+
await this.cleanupManagedSandbox();
|
|
1030
|
+
throw new Error("Cloud managed sandbox session closed during initialization.");
|
|
1031
|
+
}
|
|
1032
|
+
|
|
1033
|
+
try {
|
|
1034
|
+
await this.refreshManagedSandbox(sandbox);
|
|
1035
|
+
const connection = await this.waitForManagedSandboxConnection(sandbox);
|
|
1036
|
+
this.startManagedSandboxRefresh(sandbox);
|
|
1037
|
+
return { connectionId: connection.connectionId };
|
|
1038
|
+
} catch (error) {
|
|
1039
|
+
await this.cleanupManagedSandbox();
|
|
1040
|
+
throw error;
|
|
1041
|
+
}
|
|
1042
|
+
}
|
|
1043
|
+
|
|
1044
|
+
private async createManagedSandbox(
|
|
1045
|
+
agentId: string,
|
|
1046
|
+
conversationId?: string,
|
|
1047
|
+
): Promise<ManagedCloudSandbox> {
|
|
1048
|
+
const fetchImpl = getFetch(this.cloudOptions.fetch);
|
|
1049
|
+
const baseUrl = normalizeCloudApiBaseUrl(this.cloudOptions.apiBaseUrl);
|
|
1050
|
+
const sandboxOptions = this.resolvedSandboxOptions();
|
|
1051
|
+
const githubRepositories = sandboxOptions.githubRepositories;
|
|
1052
|
+
const response = await fetchImpl(
|
|
1053
|
+
`${baseUrl}/v1/agents/${encodeURIComponent(agentId)}/sandboxes`,
|
|
1054
|
+
{
|
|
1055
|
+
method: "POST",
|
|
1056
|
+
headers: cloudHeaders(this.cloudOptions),
|
|
1057
|
+
body: JSON.stringify({
|
|
1058
|
+
...(conversationId ? { conversationId } : {}),
|
|
1059
|
+
...(githubRepositories && githubRepositories.length > 0
|
|
1060
|
+
? { githubRepositories }
|
|
1061
|
+
: {}),
|
|
1062
|
+
}),
|
|
1063
|
+
},
|
|
1064
|
+
);
|
|
1065
|
+
const body = await parseJsonResponse(response);
|
|
1066
|
+
assertOkResponse(response, body, "Cloud create managed sandbox", `${baseUrl}/v1/agents/${encodeURIComponent(agentId)}/sandboxes`);
|
|
1067
|
+
if (!isCloudAgentSandbox(body)) {
|
|
1068
|
+
throw new Error("Cloud create managed sandbox response did not include sandbox connection details.");
|
|
1069
|
+
}
|
|
1070
|
+
|
|
1071
|
+
const responseConversationId =
|
|
1072
|
+
typeof body.conversationId === "string" ? body.conversationId : null;
|
|
1073
|
+
if (
|
|
1074
|
+
responseConversationId !== null &&
|
|
1075
|
+
responseConversationId !== conversationId
|
|
1076
|
+
) {
|
|
1077
|
+
throw new Error(
|
|
1078
|
+
`Cloud managed sandbox response conversation mismatch: expected ${conversationId ?? "none"}, got ${responseConversationId}.`,
|
|
1079
|
+
);
|
|
1080
|
+
}
|
|
1081
|
+
|
|
1082
|
+
const ttlMinutes = sandboxOptions.ttlMinutes ?? DEFAULT_SANDBOX_TTL_MINUTES;
|
|
1083
|
+
const readyTimeoutMs = sandboxOptions.readyTimeoutMs
|
|
1084
|
+
?? DEFAULT_SANDBOX_READY_TIMEOUT_MS;
|
|
1085
|
+
const readyPollIntervalMs = sandboxOptions.readyPollIntervalMs
|
|
1086
|
+
?? DEFAULT_SANDBOX_READY_POLL_INTERVAL_MS;
|
|
1087
|
+
const defaultRefreshIntervalMs = Math.max(
|
|
1088
|
+
1_000,
|
|
1089
|
+
Math.floor(ttlMinutes * 60_000 * 0.8),
|
|
1090
|
+
);
|
|
1091
|
+
|
|
1092
|
+
return {
|
|
1093
|
+
agentId,
|
|
1094
|
+
conversationId: responseConversationId,
|
|
1095
|
+
sandboxId: body.sandboxId,
|
|
1096
|
+
deviceId: body.deviceId,
|
|
1097
|
+
connectionName: body.connectionName,
|
|
1098
|
+
ttlMinutes,
|
|
1099
|
+
readyTimeoutMs,
|
|
1100
|
+
readyPollIntervalMs,
|
|
1101
|
+
refreshIntervalMs: sandboxOptions.refreshIntervalMs ?? defaultRefreshIntervalMs,
|
|
1102
|
+
// Default to TTL cleanup: terminating on close kills a sandbox that
|
|
1103
|
+
// other sessions of the same conversation (or a reconnecting client)
|
|
1104
|
+
// may still be using; the server-side TTL bounds leaked sandboxes.
|
|
1105
|
+
terminateOnClose: sandboxOptions.terminateOnClose ?? false,
|
|
1106
|
+
};
|
|
1107
|
+
}
|
|
1108
|
+
|
|
1109
|
+
private async refreshManagedSandbox(sandbox: ManagedCloudSandbox): Promise<void> {
|
|
1110
|
+
if (this.sandboxRefreshInFlight) {
|
|
1111
|
+
await this.sandboxRefreshInFlight;
|
|
1112
|
+
return;
|
|
1113
|
+
}
|
|
1114
|
+
|
|
1115
|
+
this.sandboxRefreshInFlight = this.refreshManagedSandboxOnce(sandbox);
|
|
1116
|
+
try {
|
|
1117
|
+
await this.sandboxRefreshInFlight;
|
|
1118
|
+
} finally {
|
|
1119
|
+
this.sandboxRefreshInFlight = null;
|
|
1120
|
+
}
|
|
1121
|
+
}
|
|
1122
|
+
|
|
1123
|
+
private async refreshManagedSandboxOnce(sandbox: ManagedCloudSandbox): Promise<void> {
|
|
1124
|
+
const fetchImpl = getFetch(this.cloudOptions.fetch);
|
|
1125
|
+
const baseUrl = normalizeCloudApiBaseUrl(this.cloudOptions.apiBaseUrl);
|
|
1126
|
+
|
|
1127
|
+
if (sandbox.conversationId) {
|
|
1128
|
+
// Conversation-scoped sandboxes refresh by id — no "latest active"
|
|
1129
|
+
// indirection, so ownership changes are structurally impossible.
|
|
1130
|
+
const response = await fetchImpl(
|
|
1131
|
+
`${baseUrl}/v1/sandboxes/${encodeURIComponent(sandbox.sandboxId)}/refresh`,
|
|
1132
|
+
{
|
|
1133
|
+
method: "POST",
|
|
1134
|
+
headers: cloudHeaders(this.cloudOptions),
|
|
1135
|
+
body: JSON.stringify({ ttlMinutes: sandbox.ttlMinutes }),
|
|
1136
|
+
},
|
|
1137
|
+
);
|
|
1138
|
+
if (response.status === 404) {
|
|
1139
|
+
await parseJsonResponse(response);
|
|
1140
|
+
// The current control and stream WebSockets are bound to this
|
|
1141
|
+
// sandbox's ephemeral connection id. Recreating only the sandbox would
|
|
1142
|
+
// leave both sockets targeting the dead connection, so surface a
|
|
1143
|
+
// pre-turn error and let the caller resume the same conversation with
|
|
1144
|
+
// a new SDK session.
|
|
1145
|
+
throw new CloudManagedSandboxExpiredError(
|
|
1146
|
+
sandbox.sandboxId,
|
|
1147
|
+
sandbox.conversationId,
|
|
1148
|
+
);
|
|
1149
|
+
}
|
|
1150
|
+
const body = await parseJsonResponse(response);
|
|
1151
|
+
assertOkResponse(response, body, "Cloud refresh managed sandbox");
|
|
1152
|
+
if (!isCloudAgentSandboxRefresh(body) || !body.success) {
|
|
1153
|
+
throw new Error("Cloud refresh managed sandbox response did not confirm refresh.");
|
|
1154
|
+
}
|
|
1155
|
+
return;
|
|
1156
|
+
}
|
|
1157
|
+
|
|
1158
|
+
const response = await fetchImpl(
|
|
1159
|
+
`${baseUrl}/v1/agents/${encodeURIComponent(sandbox.agentId)}/sandboxes/refresh`,
|
|
1160
|
+
{
|
|
1161
|
+
method: "POST",
|
|
1162
|
+
headers: cloudHeaders(this.cloudOptions),
|
|
1163
|
+
body: JSON.stringify({ ttlMinutes: sandbox.ttlMinutes }),
|
|
1164
|
+
},
|
|
1165
|
+
);
|
|
1166
|
+
const body = await parseJsonResponse(response);
|
|
1167
|
+
assertOkResponse(response, body, "Cloud refresh managed sandbox", `${baseUrl}/v1/agents/${encodeURIComponent(sandbox.agentId)}/sandboxes/refresh`);
|
|
1168
|
+
if (!isCloudAgentSandboxRefresh(body) || !body.success) {
|
|
1169
|
+
throw new Error("Cloud refresh managed sandbox response did not confirm refresh.");
|
|
1170
|
+
}
|
|
1171
|
+
if (body.sandboxId !== sandbox.sandboxId) {
|
|
1172
|
+
throw new CloudManagedSandboxOwnershipError(
|
|
1173
|
+
`Cloud managed sandbox ownership changed for agent ${sandbox.agentId}: expected ${sandbox.sandboxId}, got ${body.sandboxId}.`,
|
|
1174
|
+
);
|
|
1175
|
+
}
|
|
1176
|
+
}
|
|
1177
|
+
|
|
1178
|
+
private async terminateManagedSandbox(sandbox: ManagedCloudSandbox): Promise<void> {
|
|
1179
|
+
const fetchImpl = getFetch(this.cloudOptions.fetch);
|
|
1180
|
+
const baseUrl = normalizeCloudApiBaseUrl(this.cloudOptions.apiBaseUrl);
|
|
1181
|
+
|
|
1182
|
+
if (sandbox.conversationId) {
|
|
1183
|
+
// Terminate exactly this sandbox — the agent-scoped DELETE targets the
|
|
1184
|
+
// agent's "latest active" sandbox, which may not be ours.
|
|
1185
|
+
const response = await fetchImpl(
|
|
1186
|
+
`${baseUrl}/v1/sandboxes/${encodeURIComponent(sandbox.sandboxId)}/terminate`,
|
|
1187
|
+
{
|
|
1188
|
+
method: "POST",
|
|
1189
|
+
headers: cloudHeaders(this.cloudOptions),
|
|
1190
|
+
body: JSON.stringify({}),
|
|
1191
|
+
},
|
|
1192
|
+
);
|
|
1193
|
+
const body = await parseJsonResponse(response);
|
|
1194
|
+
if (response.status === 404) return;
|
|
1195
|
+
assertOkResponse(response, body, "Cloud terminate managed sandbox");
|
|
1196
|
+
return;
|
|
1197
|
+
}
|
|
1198
|
+
|
|
1199
|
+
// Agent-scoped: refresh first so the ownership check confirms the
|
|
1200
|
+
// "latest active" sandbox the DELETE will target is still ours.
|
|
1201
|
+
await this.refreshManagedSandbox(sandbox);
|
|
1202
|
+
|
|
1203
|
+
const response = await fetchImpl(
|
|
1204
|
+
`${baseUrl}/v1/agents/${encodeURIComponent(sandbox.agentId)}/sandboxes`,
|
|
1205
|
+
{
|
|
1206
|
+
method: "DELETE",
|
|
1207
|
+
headers: cloudHeaders(this.cloudOptions),
|
|
1208
|
+
},
|
|
1209
|
+
);
|
|
1210
|
+
const body = await parseJsonResponse(response);
|
|
1211
|
+
if (response.status === 404) return;
|
|
1212
|
+
assertOkResponse(response, body, "Cloud terminate managed sandbox", `${baseUrl}/v1/agents/${encodeURIComponent(sandbox.agentId)}/sandboxes`);
|
|
1213
|
+
}
|
|
1214
|
+
|
|
1215
|
+
private async waitForManagedSandboxConnection(
|
|
1216
|
+
sandbox: ManagedCloudSandbox,
|
|
1217
|
+
): Promise<{ connectionId: string }> {
|
|
1218
|
+
const deadline = Date.now() + sandbox.readyTimeoutMs;
|
|
1219
|
+
let lastError: unknown;
|
|
1220
|
+
|
|
1221
|
+
while (true) {
|
|
1222
|
+
try {
|
|
1223
|
+
const resolved = await this.remoteEnvironmentClient().resolveEnvironment({
|
|
1224
|
+
deviceId: sandbox.deviceId,
|
|
1225
|
+
});
|
|
1226
|
+
return { connectionId: resolved.connectionId };
|
|
1227
|
+
} catch (error) {
|
|
1228
|
+
lastError = error;
|
|
1229
|
+
if (!isRetryableManagedSandboxResolveError(error) || Date.now() >= deadline) {
|
|
1230
|
+
break;
|
|
1231
|
+
}
|
|
1232
|
+
const remainingMs = Math.max(0, deadline - Date.now());
|
|
1233
|
+
await sleep(Math.min(sandbox.readyPollIntervalMs, remainingMs));
|
|
1234
|
+
}
|
|
1235
|
+
}
|
|
1236
|
+
|
|
1237
|
+
const detail = lastError instanceof Error ? lastError.message : String(lastError);
|
|
1238
|
+
throw new Error(
|
|
1239
|
+
`Cloud managed sandbox ${sandbox.sandboxId} did not come online within ${sandbox.readyTimeoutMs}ms: ${detail}`,
|
|
1240
|
+
);
|
|
1241
|
+
}
|
|
1242
|
+
|
|
1243
|
+
private startManagedSandboxRefresh(sandbox: ManagedCloudSandbox): void {
|
|
1244
|
+
this.stopManagedSandboxRefresh();
|
|
1245
|
+
this.sandboxRefreshTimer = setInterval(() => {
|
|
1246
|
+
void this.refreshManagedSandbox(sandbox).catch((error) => {
|
|
1247
|
+
if (
|
|
1248
|
+
error instanceof CloudManagedSandboxOwnershipError ||
|
|
1249
|
+
error instanceof CloudManagedSandboxExpiredError
|
|
1250
|
+
) {
|
|
1251
|
+
this.stopManagedSandboxRefresh();
|
|
1252
|
+
}
|
|
1253
|
+
});
|
|
1254
|
+
}, sandbox.refreshIntervalMs);
|
|
1255
|
+
(this.sandboxRefreshTimer as { unref?: () => void }).unref?.();
|
|
1256
|
+
}
|
|
1257
|
+
|
|
1258
|
+
private stopManagedSandboxRefresh(): void {
|
|
1259
|
+
if (!this.sandboxRefreshTimer) return;
|
|
1260
|
+
clearInterval(this.sandboxRefreshTimer);
|
|
1261
|
+
this.sandboxRefreshTimer = null;
|
|
1262
|
+
}
|
|
1263
|
+
|
|
1264
|
+
private async cleanupManagedSandbox(): Promise<void> {
|
|
1265
|
+
this.sandboxLifecycleClosing = true;
|
|
1266
|
+
this.stopManagedSandboxRefresh();
|
|
1267
|
+
const sandbox = this.managedSandbox;
|
|
1268
|
+
this.managedSandbox = null;
|
|
1269
|
+
|
|
1270
|
+
// Do not race termination against a refresh already in flight. In
|
|
1271
|
+
// particular, this ensures cleanup observes the final refresh outcome
|
|
1272
|
+
// before issuing the by-id terminate request.
|
|
1273
|
+
try {
|
|
1274
|
+
await this.sandboxRefreshInFlight;
|
|
1275
|
+
} catch {
|
|
1276
|
+
// Expiration/refresh failures do not prevent best-effort cleanup.
|
|
1277
|
+
}
|
|
1278
|
+
|
|
1279
|
+
if (!sandbox || !sandbox.terminateOnClose) return;
|
|
1280
|
+
try {
|
|
1281
|
+
await this.terminateManagedSandbox(sandbox);
|
|
1282
|
+
} catch {
|
|
1283
|
+
// Best-effort cleanup: Cloud TTL still bounds leaked managed sandboxes.
|
|
1284
|
+
}
|
|
1285
|
+
}
|
|
1286
|
+
|
|
1287
|
+
private remoteEnvironmentClient(): RemoteEnvironmentClient {
|
|
1288
|
+
return new RemoteEnvironmentClient({
|
|
1289
|
+
baseUrl: this.cloudOptions.apiBaseUrl,
|
|
1290
|
+
apiKey: getCloudApiKey(this.cloudOptions),
|
|
1291
|
+
headers: this.cloudOptions.headers,
|
|
1292
|
+
fetch: this.cloudOptions.fetch,
|
|
1293
|
+
});
|
|
1294
|
+
}
|
|
1295
|
+
|
|
1296
|
+
private effectiveEnvironment(): LettaCodeEnvironment | undefined {
|
|
1297
|
+
const modeEnvironment = this.mode.kind === "session"
|
|
1298
|
+
? this.mode.options.environment
|
|
1299
|
+
: undefined;
|
|
1300
|
+
return modeEnvironment ?? this.cloudOptions.environment;
|
|
1301
|
+
}
|
|
1302
|
+
|
|
1303
|
+
private effectiveSandboxOptions(): LettaCodeCloudSandboxOptions | undefined {
|
|
1304
|
+
const modeSandbox = this.mode.kind === "session"
|
|
1305
|
+
? this.mode.options.sandbox
|
|
1306
|
+
: undefined;
|
|
1307
|
+
return modeSandbox ?? this.cloudOptions.sandbox;
|
|
1308
|
+
}
|
|
1309
|
+
|
|
1310
|
+
private resolvedSandboxOptions(): LettaCodeCloudSandboxOptions {
|
|
1311
|
+
return this.effectiveSandboxOptions() ?? {};
|
|
1312
|
+
}
|
|
1313
|
+
}
|