@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.
- package/AGENTS.md +0 -3
- package/README.md +12 -0
- package/dist/app-server-management.d.ts +3 -48
- package/dist/app-server-management.d.ts.map +1 -1
- package/dist/app-server-session.d.ts +0 -6
- package/dist/app-server-session.d.ts.map +1 -1
- package/dist/client-base.d.ts +7 -15
- package/dist/client-base.d.ts.map +1 -1
- package/dist/client-entry.js +369 -468
- package/dist/client-entry.js.map +13 -12
- package/dist/client.d.ts +3 -3
- package/dist/client.d.ts.map +1 -1
- package/dist/cloud-session.d.ts.map +1 -1
- package/dist/cloud-status-transport.d.ts +53 -0
- package/dist/cloud-status-transport.d.ts.map +1 -0
- package/dist/index.d.ts +7 -16
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +896 -2019
- package/dist/index.js.map +18 -18
- package/dist/local-app-server-session.d.ts +1 -1
- package/dist/local-app-server-session.d.ts.map +1 -1
- package/dist/local-app-server.d.ts +11 -0
- package/dist/local-app-server.d.ts.map +1 -1
- package/dist/management.d.ts +0 -7
- package/dist/management.d.ts.map +1 -1
- package/dist/remote-session-protocol.d.ts +2 -0
- package/dist/remote-session-protocol.d.ts.map +1 -1
- package/dist/remote-turn-coordinator.d.ts.map +1 -1
- package/dist/types.d.ts +57 -66
- package/dist/types.d.ts.map +1 -1
- package/dist/validation.d.ts.map +1 -1
- package/package.json +2 -2
- package/src/app-server-management.ts +20 -256
- package/src/app-server-session.ts +0 -32
- package/src/client-base.ts +47 -73
- package/src/client.ts +31 -70
- package/src/cloud-session.ts +9 -267
- package/src/cloud-status-transport.ts +305 -0
- package/src/index.ts +11 -29
- package/src/local-app-server-session.ts +13 -3
- package/src/local-app-server.ts +97 -3
- package/src/management.ts +0 -7
- package/src/remote-session-protocol.ts +14 -0
- package/src/remote-turn-coordinator.ts +20 -2
- package/src/types.ts +64 -111
- package/src/validation.ts +21 -6
- package/dist/protocol.d.ts +0 -205
- package/dist/protocol.d.ts.map +0 -1
- package/dist/session.d.ts +0 -155
- package/dist/session.d.ts.map +0 -1
- package/dist/transport.d.ts +0 -53
- package/dist/transport.d.ts.map +0 -1
- package/src/protocol.ts +0 -249
- package/src/session.ts +0 -1638
- package/src/transport.ts +0 -484
package/src/cloud-session.ts
CHANGED
|
@@ -1,9 +1,6 @@
|
|
|
1
1
|
import {
|
|
2
2
|
createAppServerClient,
|
|
3
3
|
type AppServerClient,
|
|
4
|
-
type AppServerSocketConstructor,
|
|
5
|
-
type AppServerSocketLike,
|
|
6
|
-
type AppServerSocketOptions,
|
|
7
4
|
} from "@letta-ai/letta-code/app-server-client";
|
|
8
5
|
import {
|
|
9
6
|
AppServerRuntimeController,
|
|
@@ -13,6 +10,7 @@ import {
|
|
|
13
10
|
externalToolGroups,
|
|
14
11
|
registerAppServerControlRequestHandler,
|
|
15
12
|
} from "./app-server-session.js";
|
|
13
|
+
import { createCloudStatusTransportConstructor } from "./cloud-status-transport.js";
|
|
16
14
|
import {
|
|
17
15
|
RemoteEnvironmentClient,
|
|
18
16
|
type RemoteEnvironmentTarget,
|
|
@@ -33,7 +31,6 @@ import type {
|
|
|
33
31
|
LettaCodeCloudClientOptions,
|
|
34
32
|
LettaCodeEnvironment,
|
|
35
33
|
LettaCodeSocketConstructor,
|
|
36
|
-
LettaCodeSocketLike,
|
|
37
34
|
RepositoryResource,
|
|
38
35
|
} from "./types.js";
|
|
39
36
|
import {
|
|
@@ -53,24 +50,6 @@ const SDK_AGENT_ORIGIN = "@letta-ai/letta-agent-sdk";
|
|
|
53
50
|
|
|
54
51
|
type FetchLike = typeof fetch;
|
|
55
52
|
|
|
56
|
-
type CloudStatusMessage = ProtocolMessage & {
|
|
57
|
-
type: string;
|
|
58
|
-
seq?: unknown;
|
|
59
|
-
event_seq?: unknown;
|
|
60
|
-
idempotency_key?: unknown;
|
|
61
|
-
runId?: unknown;
|
|
62
|
-
run_id?: unknown;
|
|
63
|
-
stopReason?: unknown;
|
|
64
|
-
stop_reason?: unknown;
|
|
65
|
-
conversation_id?: unknown;
|
|
66
|
-
conversationId?: unknown;
|
|
67
|
-
agent_id?: unknown;
|
|
68
|
-
agentId?: unknown;
|
|
69
|
-
request?: unknown;
|
|
70
|
-
loop_status?: unknown;
|
|
71
|
-
delta?: unknown;
|
|
72
|
-
};
|
|
73
|
-
|
|
74
53
|
type CloudRuntimeStartResponse = ProtocolMessage & {
|
|
75
54
|
type: "runtime_start_response";
|
|
76
55
|
success: boolean;
|
|
@@ -328,231 +307,6 @@ function buildCloudStatusWebSocketUrl(params: {
|
|
|
328
307
|
return base.toString();
|
|
329
308
|
}
|
|
330
309
|
|
|
331
|
-
function addSocketListener(
|
|
332
|
-
socket: LettaCodeSocketLike,
|
|
333
|
-
type: string,
|
|
334
|
-
listener: (event: unknown) => void,
|
|
335
|
-
): () => void {
|
|
336
|
-
if (socket.addEventListener) {
|
|
337
|
-
socket.addEventListener(type, listener);
|
|
338
|
-
return () => socket.removeEventListener?.(type, listener);
|
|
339
|
-
}
|
|
340
|
-
if (socket.on) {
|
|
341
|
-
socket.on(type, listener);
|
|
342
|
-
return () => socket.off?.(type, listener);
|
|
343
|
-
}
|
|
344
|
-
throw new Error("WebSocket implementation does not support event listeners.");
|
|
345
|
-
}
|
|
346
|
-
|
|
347
|
-
function messageEventData(event: unknown): string | null {
|
|
348
|
-
if (typeof event === "string") return event;
|
|
349
|
-
if (event && typeof event === "object") {
|
|
350
|
-
const data = (event as { data?: unknown }).data;
|
|
351
|
-
if (typeof data === "string") return data;
|
|
352
|
-
if (data instanceof ArrayBuffer) {
|
|
353
|
-
return new TextDecoder().decode(data);
|
|
354
|
-
}
|
|
355
|
-
if (data instanceof Uint8Array) {
|
|
356
|
-
return new TextDecoder().decode(data);
|
|
357
|
-
}
|
|
358
|
-
}
|
|
359
|
-
return null;
|
|
360
|
-
}
|
|
361
|
-
|
|
362
|
-
type CloudStatusSocketControl = AppServerSocketLike & {
|
|
363
|
-
sendCloudCommand(command: Record<string, unknown>): void;
|
|
364
|
-
};
|
|
365
|
-
|
|
366
|
-
type CloudStatusSocketState = {
|
|
367
|
-
runtime: RuntimeScope;
|
|
368
|
-
seenIdempotencyKeys: Set<string>;
|
|
369
|
-
seenIdempotencyOrder: string[];
|
|
370
|
-
lastEventSeq: number | null;
|
|
371
|
-
controlSocket: CloudStatusSocketControl | null;
|
|
372
|
-
};
|
|
373
|
-
|
|
374
|
-
function createCloudStatusWebSocketConstructor(params: {
|
|
375
|
-
cloudOptions: LettaCodeCloudClientOptions;
|
|
376
|
-
runtime: RuntimeScope;
|
|
377
|
-
}): AppServerSocketConstructor {
|
|
378
|
-
const WebSocketCtor = getWebSocketConstructor(params.cloudOptions.WebSocket);
|
|
379
|
-
const authMode = params.cloudOptions.webSocketAuth ?? "header";
|
|
380
|
-
const cloudHeaders = authMode === "header" ? cloudWebSocketHeaders(params.cloudOptions) : undefined;
|
|
381
|
-
const pingIntervalMs = params.cloudOptions.pingIntervalMs ?? DEFAULT_PING_INTERVAL_MS;
|
|
382
|
-
const state: CloudStatusSocketState = {
|
|
383
|
-
runtime: params.runtime,
|
|
384
|
-
seenIdempotencyKeys: new Set<string>(),
|
|
385
|
-
seenIdempotencyOrder: [],
|
|
386
|
-
lastEventSeq: null,
|
|
387
|
-
controlSocket: null,
|
|
388
|
-
};
|
|
389
|
-
|
|
390
|
-
class CloudStatusSocketAdapter implements AppServerSocketLike {
|
|
391
|
-
private readonly socket: LettaCodeSocketLike;
|
|
392
|
-
private readonly channel: string | null;
|
|
393
|
-
private readonly listenerRemovers = new Map<string, Map<(event: unknown) => void, () => void>>();
|
|
394
|
-
private pingTimer: ReturnType<typeof setInterval> | null = null;
|
|
395
|
-
private removeCloseListener: (() => void) | null = null;
|
|
396
|
-
|
|
397
|
-
constructor(url: string, options?: AppServerSocketOptions) {
|
|
398
|
-
this.channel = new URL(url).searchParams.get("channel");
|
|
399
|
-
const mergedHeaders = authMode === "header"
|
|
400
|
-
? {
|
|
401
|
-
...(options?.headers ?? {}),
|
|
402
|
-
...(cloudHeaders ?? {}),
|
|
403
|
-
}
|
|
404
|
-
: undefined;
|
|
405
|
-
const socketOptions = mergedHeaders && Object.keys(mergedHeaders).length > 0
|
|
406
|
-
? { headers: mergedHeaders }
|
|
407
|
-
: undefined;
|
|
408
|
-
this.socket = new WebSocketCtor(url, socketOptions);
|
|
409
|
-
if (this.channel === "control") {
|
|
410
|
-
state.controlSocket = this;
|
|
411
|
-
}
|
|
412
|
-
this.removeCloseListener = addSocketListener(this.socket, "close", () => {
|
|
413
|
-
this.stopPing();
|
|
414
|
-
if (state.controlSocket === this) state.controlSocket = null;
|
|
415
|
-
});
|
|
416
|
-
this.startPing(pingIntervalMs);
|
|
417
|
-
}
|
|
418
|
-
|
|
419
|
-
get readyState(): number {
|
|
420
|
-
return this.socket.readyState;
|
|
421
|
-
}
|
|
422
|
-
|
|
423
|
-
send(data: string): void {
|
|
424
|
-
this.socket.send(data);
|
|
425
|
-
}
|
|
426
|
-
|
|
427
|
-
close(): void {
|
|
428
|
-
this.stopPing();
|
|
429
|
-
this.removeCloseListener?.();
|
|
430
|
-
this.removeCloseListener = null;
|
|
431
|
-
if (state.controlSocket === this) state.controlSocket = null;
|
|
432
|
-
this.socket.close();
|
|
433
|
-
}
|
|
434
|
-
|
|
435
|
-
addEventListener(type: string, listener: (event: unknown) => void): void {
|
|
436
|
-
const wrapped = type === "message"
|
|
437
|
-
? (event: unknown) => {
|
|
438
|
-
if (this.handleIncomingMessage(event)) {
|
|
439
|
-
listener(event);
|
|
440
|
-
}
|
|
441
|
-
}
|
|
442
|
-
: listener;
|
|
443
|
-
const remove = addSocketListener(this.socket, type, wrapped);
|
|
444
|
-
let typeRemovers = this.listenerRemovers.get(type);
|
|
445
|
-
if (!typeRemovers) {
|
|
446
|
-
typeRemovers = new Map();
|
|
447
|
-
this.listenerRemovers.set(type, typeRemovers);
|
|
448
|
-
}
|
|
449
|
-
typeRemovers.set(listener, remove);
|
|
450
|
-
}
|
|
451
|
-
|
|
452
|
-
removeEventListener(type: string, listener: (event: unknown) => void): void {
|
|
453
|
-
const typeRemovers = this.listenerRemovers.get(type);
|
|
454
|
-
const remove = typeRemovers?.get(listener);
|
|
455
|
-
if (!remove) return;
|
|
456
|
-
remove();
|
|
457
|
-
typeRemovers?.delete(listener);
|
|
458
|
-
if (typeRemovers?.size === 0) {
|
|
459
|
-
this.listenerRemovers.delete(type);
|
|
460
|
-
}
|
|
461
|
-
}
|
|
462
|
-
|
|
463
|
-
private handleIncomingMessage(event: unknown): boolean {
|
|
464
|
-
const data = messageEventData(event);
|
|
465
|
-
if (!data) return true;
|
|
466
|
-
|
|
467
|
-
let message: CloudStatusMessage;
|
|
468
|
-
try {
|
|
469
|
-
message = JSON.parse(data) as CloudStatusMessage;
|
|
470
|
-
} catch {
|
|
471
|
-
return true;
|
|
472
|
-
}
|
|
473
|
-
|
|
474
|
-
this.ackIfSequenced(message);
|
|
475
|
-
|
|
476
|
-
// The status gateway can mirror device stream frames to the control
|
|
477
|
-
// subscriber. Drop them at the Cloud transport boundary instead of making
|
|
478
|
-
// shared app-server session code understand Cloud fanout quirks. Do this
|
|
479
|
-
// before idempotency/event tracking so the canonical stream-channel frame
|
|
480
|
-
// is still delivered and sequenced.
|
|
481
|
-
if (this.channel === "control" && message.type === "stream_delta") {
|
|
482
|
-
return false;
|
|
483
|
-
}
|
|
484
|
-
|
|
485
|
-
if (this.isDuplicate(message)) return false;
|
|
486
|
-
this.trackEventSeq(message);
|
|
487
|
-
return true;
|
|
488
|
-
}
|
|
489
|
-
|
|
490
|
-
private ackIfSequenced(message: CloudStatusMessage): void {
|
|
491
|
-
if (typeof message.seq !== "number") return;
|
|
492
|
-
this.sendCloudCommand({ type: "ack", seq: message.seq });
|
|
493
|
-
}
|
|
494
|
-
|
|
495
|
-
private isDuplicate(message: CloudStatusMessage): boolean {
|
|
496
|
-
const idempotencyKey =
|
|
497
|
-
typeof message.idempotency_key === "string" ? message.idempotency_key : undefined;
|
|
498
|
-
if (!idempotencyKey) return false;
|
|
499
|
-
if (state.seenIdempotencyKeys.has(idempotencyKey)) return true;
|
|
500
|
-
state.seenIdempotencyKeys.add(idempotencyKey);
|
|
501
|
-
state.seenIdempotencyOrder.push(idempotencyKey);
|
|
502
|
-
while (state.seenIdempotencyOrder.length > 1_000) {
|
|
503
|
-
const oldest = state.seenIdempotencyOrder.shift();
|
|
504
|
-
if (oldest) state.seenIdempotencyKeys.delete(oldest);
|
|
505
|
-
}
|
|
506
|
-
return false;
|
|
507
|
-
}
|
|
508
|
-
|
|
509
|
-
private trackEventSeq(message: CloudStatusMessage): void {
|
|
510
|
-
if (typeof message.event_seq !== "number") return;
|
|
511
|
-
if (state.lastEventSeq !== null && message.event_seq > state.lastEventSeq + 1) {
|
|
512
|
-
this.sendSync(true);
|
|
513
|
-
}
|
|
514
|
-
if (state.lastEventSeq === null || message.event_seq > state.lastEventSeq) {
|
|
515
|
-
state.lastEventSeq = message.event_seq;
|
|
516
|
-
}
|
|
517
|
-
}
|
|
518
|
-
|
|
519
|
-
private sendSync(recoverApprovals: boolean): void {
|
|
520
|
-
const target = state.controlSocket?.readyState === 1 ? state.controlSocket : this;
|
|
521
|
-
target.sendCloudCommand({
|
|
522
|
-
type: "sync",
|
|
523
|
-
runtime: state.runtime,
|
|
524
|
-
recover_approvals: recoverApprovals,
|
|
525
|
-
force_device_status: true,
|
|
526
|
-
});
|
|
527
|
-
}
|
|
528
|
-
|
|
529
|
-
sendCloudCommand(command: Record<string, unknown>): void {
|
|
530
|
-
if (this.readyState !== 1) return;
|
|
531
|
-
try {
|
|
532
|
-
this.socket.send(JSON.stringify(command));
|
|
533
|
-
} catch {
|
|
534
|
-
// Best-effort Cloud status reliability command.
|
|
535
|
-
}
|
|
536
|
-
}
|
|
537
|
-
|
|
538
|
-
private startPing(intervalMs: number): void {
|
|
539
|
-
if (this.pingTimer) return;
|
|
540
|
-
this.pingTimer = setInterval(() => {
|
|
541
|
-
this.sendCloudCommand({ type: "ping" });
|
|
542
|
-
}, intervalMs);
|
|
543
|
-
(this.pingTimer as { unref?: () => void }).unref?.();
|
|
544
|
-
}
|
|
545
|
-
|
|
546
|
-
private stopPing(): void {
|
|
547
|
-
if (!this.pingTimer) return;
|
|
548
|
-
clearInterval(this.pingTimer);
|
|
549
|
-
this.pingTimer = null;
|
|
550
|
-
}
|
|
551
|
-
}
|
|
552
|
-
|
|
553
|
-
return CloudStatusSocketAdapter as AppServerSocketConstructor;
|
|
554
|
-
}
|
|
555
|
-
|
|
556
310
|
function isCloudConversation(value: unknown): value is CloudConversation & { id: string } {
|
|
557
311
|
return Boolean(value && typeof value === "object" && typeof (value as CloudConversation).id === "string");
|
|
558
312
|
}
|
|
@@ -638,24 +392,6 @@ export function assertCloudSessionOptionsSupported(
|
|
|
638
392
|
if (options.environment !== undefined && options.sandbox !== undefined) {
|
|
639
393
|
throw new Error(`Letta Cloud ${action}() cannot specify both environment and sandbox options.`);
|
|
640
394
|
}
|
|
641
|
-
if (options.systemPrompt !== undefined) {
|
|
642
|
-
throw new Error(`Letta Cloud ${action}() cannot rewrite an existing agent's systemPrompt from the SDK adapter yet.`);
|
|
643
|
-
}
|
|
644
|
-
if (options.disallowedTools !== undefined) {
|
|
645
|
-
throw new Error(`Letta Cloud ${action}() has not wired disallowedTools to the remote device protocol yet.`);
|
|
646
|
-
}
|
|
647
|
-
if (options.systemInfoReminder !== undefined) {
|
|
648
|
-
throw new Error(`Letta Cloud ${action}() has not wired systemInfoReminder to the remote device protocol yet.`);
|
|
649
|
-
}
|
|
650
|
-
if (options.dreaming?.behavior !== undefined) {
|
|
651
|
-
throw new Error(`Letta Cloud ${action}() does not yet support dreaming.behavior overrides.`);
|
|
652
|
-
}
|
|
653
|
-
if ((options as { memfsStartup?: unknown }).memfsStartup !== undefined) {
|
|
654
|
-
throw new Error(`Letta Cloud ${action}() does not support memfsStartup.`);
|
|
655
|
-
}
|
|
656
|
-
if (options.includePartialMessages !== undefined) {
|
|
657
|
-
throw new Error(`Letta Cloud ${action}() streams Remote Client deltas directly; includePartialMessages is not a separate toggle.`);
|
|
658
|
-
}
|
|
659
395
|
}
|
|
660
396
|
|
|
661
397
|
export class CloudEnvironmentSession extends RemoteClientSessionCore {
|
|
@@ -699,8 +435,14 @@ export class CloudEnvironmentSession extends RemoteClientSessionCore {
|
|
|
699
435
|
});
|
|
700
436
|
const client = applyUniqueRequestIds(createAppServerClient({
|
|
701
437
|
url,
|
|
702
|
-
WebSocket:
|
|
703
|
-
|
|
438
|
+
WebSocket: createCloudStatusTransportConstructor({
|
|
439
|
+
url,
|
|
440
|
+
WebSocket: getWebSocketConstructor(this.cloudOptions.WebSocket),
|
|
441
|
+
...((this.cloudOptions.webSocketAuth ?? "header") === "header"
|
|
442
|
+
? { headers: cloudWebSocketHeaders(this.cloudOptions) }
|
|
443
|
+
: {}),
|
|
444
|
+
pingIntervalMs:
|
|
445
|
+
this.cloudOptions.pingIntervalMs ?? DEFAULT_PING_INTERVAL_MS,
|
|
704
446
|
runtime: resolved.runtime,
|
|
705
447
|
}),
|
|
706
448
|
requestTimeoutMs: this.cloudOptions.requestTimeoutMs ?? DEFAULT_TURN_TIMEOUT_MS,
|
|
@@ -0,0 +1,305 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
AppServerSocketConstructor,
|
|
3
|
+
AppServerSocketLike,
|
|
4
|
+
AppServerSocketOptions,
|
|
5
|
+
} from "@letta-ai/letta-code/app-server-client";
|
|
6
|
+
import type { ProtocolMessage, RuntimeScope } from "./remote-session-protocol.js";
|
|
7
|
+
import type {
|
|
8
|
+
LettaCodeSocketConstructor,
|
|
9
|
+
LettaCodeSocketLike,
|
|
10
|
+
} from "./types.js";
|
|
11
|
+
|
|
12
|
+
type CloudStatusMessage = ProtocolMessage & {
|
|
13
|
+
type: string;
|
|
14
|
+
seq?: unknown;
|
|
15
|
+
event_seq?: unknown;
|
|
16
|
+
idempotency_key?: unknown;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
type CloudStatusTransportOptions = {
|
|
20
|
+
url: string;
|
|
21
|
+
WebSocket: LettaCodeSocketConstructor;
|
|
22
|
+
headers?: Record<string, string>;
|
|
23
|
+
pingIntervalMs: number;
|
|
24
|
+
runtime: RuntimeScope;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
type SocketChannel = "control" | "stream";
|
|
28
|
+
type Listener = (event: unknown) => void;
|
|
29
|
+
|
|
30
|
+
const CONNECTING = 0;
|
|
31
|
+
const OPEN = 1;
|
|
32
|
+
const CLOSING = 2;
|
|
33
|
+
const CLOSED = 3;
|
|
34
|
+
const MAX_IDEMPOTENCY_KEYS = 1_000;
|
|
35
|
+
|
|
36
|
+
function addSocketListener(
|
|
37
|
+
socket: LettaCodeSocketLike,
|
|
38
|
+
type: string,
|
|
39
|
+
listener: Listener,
|
|
40
|
+
): () => void {
|
|
41
|
+
if (socket.addEventListener) {
|
|
42
|
+
socket.addEventListener(type, listener);
|
|
43
|
+
return () => socket.removeEventListener?.(type, listener);
|
|
44
|
+
}
|
|
45
|
+
if (socket.on) {
|
|
46
|
+
socket.on(type, listener);
|
|
47
|
+
return () => socket.off?.(type, listener);
|
|
48
|
+
}
|
|
49
|
+
throw new Error("WebSocket implementation does not support event listeners.");
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function messageEventData(event: unknown): string | null {
|
|
53
|
+
if (typeof event === "string") return event;
|
|
54
|
+
if (!event || typeof event !== "object") return null;
|
|
55
|
+
const data = (event as { data?: unknown }).data;
|
|
56
|
+
if (typeof data === "string") return data;
|
|
57
|
+
if (data instanceof ArrayBuffer) {
|
|
58
|
+
return new TextDecoder().decode(data);
|
|
59
|
+
}
|
|
60
|
+
if (data instanceof Uint8Array) {
|
|
61
|
+
return new TextDecoder().decode(data);
|
|
62
|
+
}
|
|
63
|
+
return null;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function channelUrl(url: string, channel: SocketChannel): string {
|
|
67
|
+
const parsed = new URL(url);
|
|
68
|
+
parsed.searchParams.set("channel", channel);
|
|
69
|
+
return parsed.toString();
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Cloud's status relay still exposes separate control and stream sockets.
|
|
74
|
+
*
|
|
75
|
+
* This adapter presents those two relay sockets as one AppServerSocketLike,
|
|
76
|
+
* allowing CloudSession to reuse AppServerClient's request correlation and
|
|
77
|
+
* the SDK's shared runtime controller without weakening the local app-server's
|
|
78
|
+
* strict one-socket contract. Remove it when LET-10236 migrates the relay.
|
|
79
|
+
*/
|
|
80
|
+
export class CloudStatusTransport implements AppServerSocketLike {
|
|
81
|
+
readonly controlSocket: LettaCodeSocketLike;
|
|
82
|
+
readonly streamSocket: LettaCodeSocketLike;
|
|
83
|
+
|
|
84
|
+
private state = CONNECTING;
|
|
85
|
+
private openedChannels = new Set<SocketChannel>();
|
|
86
|
+
private listeners = new Map<string, Set<Listener>>();
|
|
87
|
+
private removers: Array<() => void> = [];
|
|
88
|
+
private seenIdempotencyKeys = new Set<string>();
|
|
89
|
+
private seenIdempotencyOrder: string[] = [];
|
|
90
|
+
private lastEventSeq: number | null = null;
|
|
91
|
+
private pingTimer: ReturnType<typeof setInterval> | null = null;
|
|
92
|
+
private closeEmitted = false;
|
|
93
|
+
|
|
94
|
+
constructor(private readonly options: CloudStatusTransportOptions) {
|
|
95
|
+
const socketOptions = options.headers
|
|
96
|
+
? { headers: options.headers }
|
|
97
|
+
: undefined;
|
|
98
|
+
this.controlSocket = new options.WebSocket(
|
|
99
|
+
channelUrl(options.url, "control"),
|
|
100
|
+
socketOptions,
|
|
101
|
+
);
|
|
102
|
+
this.streamSocket = new options.WebSocket(
|
|
103
|
+
channelUrl(options.url, "stream"),
|
|
104
|
+
socketOptions,
|
|
105
|
+
);
|
|
106
|
+
this.bindSocket("control", this.controlSocket);
|
|
107
|
+
this.bindSocket("stream", this.streamSocket);
|
|
108
|
+
this.startPing();
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
get readyState(): number {
|
|
112
|
+
return this.state;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
send(data: string): void {
|
|
116
|
+
if (this.state !== OPEN || this.controlSocket.readyState !== OPEN) {
|
|
117
|
+
throw new Error("Cloud status control socket is not open");
|
|
118
|
+
}
|
|
119
|
+
this.controlSocket.send(data);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
close(): void {
|
|
123
|
+
if (this.state === CLOSED || this.state === CLOSING) return;
|
|
124
|
+
this.state = CLOSING;
|
|
125
|
+
this.stopPing();
|
|
126
|
+
this.closeUnderlyingSockets();
|
|
127
|
+
this.finishClose({});
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
addEventListener(type: string, listener: Listener): void {
|
|
131
|
+
let listeners = this.listeners.get(type);
|
|
132
|
+
if (!listeners) {
|
|
133
|
+
listeners = new Set();
|
|
134
|
+
this.listeners.set(type, listeners);
|
|
135
|
+
}
|
|
136
|
+
listeners.add(listener);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
removeEventListener(type: string, listener: Listener): void {
|
|
140
|
+
this.listeners.get(type)?.delete(listener);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
private bindSocket(
|
|
144
|
+
channel: SocketChannel,
|
|
145
|
+
socket: LettaCodeSocketLike,
|
|
146
|
+
): void {
|
|
147
|
+
this.removers.push(
|
|
148
|
+
addSocketListener(socket, "open", () => {
|
|
149
|
+
this.openedChannels.add(channel);
|
|
150
|
+
if (this.openedChannels.size === 2 && this.state === CONNECTING) {
|
|
151
|
+
this.state = OPEN;
|
|
152
|
+
this.emit("open", {});
|
|
153
|
+
}
|
|
154
|
+
}),
|
|
155
|
+
addSocketListener(socket, "message", (event) => {
|
|
156
|
+
if (this.shouldForwardMessage(channel, socket, event)) {
|
|
157
|
+
this.emit("message", event);
|
|
158
|
+
}
|
|
159
|
+
}),
|
|
160
|
+
addSocketListener(socket, "error", (event) => {
|
|
161
|
+
this.emit("error", event);
|
|
162
|
+
}),
|
|
163
|
+
addSocketListener(socket, "close", (event) => {
|
|
164
|
+
if (this.state !== CLOSED) {
|
|
165
|
+
this.state = CLOSING;
|
|
166
|
+
this.stopPing();
|
|
167
|
+
this.closeUnderlyingSockets(socket);
|
|
168
|
+
this.finishClose(event);
|
|
169
|
+
}
|
|
170
|
+
}),
|
|
171
|
+
);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
private shouldForwardMessage(
|
|
175
|
+
channel: SocketChannel,
|
|
176
|
+
socket: LettaCodeSocketLike,
|
|
177
|
+
event: unknown,
|
|
178
|
+
): boolean {
|
|
179
|
+
const data = messageEventData(event);
|
|
180
|
+
if (!data) return true;
|
|
181
|
+
let message: CloudStatusMessage;
|
|
182
|
+
try {
|
|
183
|
+
message = JSON.parse(data) as CloudStatusMessage;
|
|
184
|
+
} catch {
|
|
185
|
+
return true;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
this.ackIfSequenced(socket, message);
|
|
189
|
+
if (channel === "control" && message.type === "stream_delta") {
|
|
190
|
+
return false;
|
|
191
|
+
}
|
|
192
|
+
if (this.isDuplicate(message)) return false;
|
|
193
|
+
this.trackEventSequence(message);
|
|
194
|
+
return true;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
private ackIfSequenced(
|
|
198
|
+
socket: LettaCodeSocketLike,
|
|
199
|
+
message: CloudStatusMessage,
|
|
200
|
+
): void {
|
|
201
|
+
if (typeof message.seq !== "number") return;
|
|
202
|
+
this.sendCommand(socket, { type: "ack", seq: message.seq });
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
private isDuplicate(message: CloudStatusMessage): boolean {
|
|
206
|
+
const key =
|
|
207
|
+
typeof message.idempotency_key === "string"
|
|
208
|
+
? message.idempotency_key
|
|
209
|
+
: null;
|
|
210
|
+
if (!key) return false;
|
|
211
|
+
if (this.seenIdempotencyKeys.has(key)) return true;
|
|
212
|
+
this.seenIdempotencyKeys.add(key);
|
|
213
|
+
this.seenIdempotencyOrder.push(key);
|
|
214
|
+
while (this.seenIdempotencyOrder.length > MAX_IDEMPOTENCY_KEYS) {
|
|
215
|
+
const oldest = this.seenIdempotencyOrder.shift();
|
|
216
|
+
if (oldest) this.seenIdempotencyKeys.delete(oldest);
|
|
217
|
+
}
|
|
218
|
+
return false;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
private trackEventSequence(message: CloudStatusMessage): void {
|
|
222
|
+
if (typeof message.event_seq !== "number") return;
|
|
223
|
+
if (
|
|
224
|
+
this.lastEventSeq !== null &&
|
|
225
|
+
message.event_seq > this.lastEventSeq + 1
|
|
226
|
+
) {
|
|
227
|
+
this.sendCommand(this.controlSocket, {
|
|
228
|
+
type: "sync",
|
|
229
|
+
runtime: this.options.runtime,
|
|
230
|
+
recover_approvals: true,
|
|
231
|
+
force_device_status: true,
|
|
232
|
+
});
|
|
233
|
+
}
|
|
234
|
+
if (
|
|
235
|
+
this.lastEventSeq === null ||
|
|
236
|
+
message.event_seq > this.lastEventSeq
|
|
237
|
+
) {
|
|
238
|
+
this.lastEventSeq = message.event_seq;
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
private sendCommand(
|
|
243
|
+
socket: LettaCodeSocketLike,
|
|
244
|
+
command: Record<string, unknown>,
|
|
245
|
+
): void {
|
|
246
|
+
if (socket.readyState !== OPEN) return;
|
|
247
|
+
try {
|
|
248
|
+
socket.send(JSON.stringify(command));
|
|
249
|
+
} catch {
|
|
250
|
+
// ACK, sync recovery, and ping are best-effort relay reliability frames.
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
private startPing(): void {
|
|
255
|
+
this.pingTimer = setInterval(() => {
|
|
256
|
+
this.sendCommand(this.controlSocket, { type: "ping" });
|
|
257
|
+
this.sendCommand(this.streamSocket, { type: "ping" });
|
|
258
|
+
}, this.options.pingIntervalMs);
|
|
259
|
+
(this.pingTimer as { unref?: () => void }).unref?.();
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
private stopPing(): void {
|
|
263
|
+
if (!this.pingTimer) return;
|
|
264
|
+
clearInterval(this.pingTimer);
|
|
265
|
+
this.pingTimer = null;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
private closeUnderlyingSockets(
|
|
269
|
+
except?: LettaCodeSocketLike,
|
|
270
|
+
): void {
|
|
271
|
+
for (const socket of [this.controlSocket, this.streamSocket]) {
|
|
272
|
+
if (
|
|
273
|
+
socket !== except &&
|
|
274
|
+
(socket.readyState === CONNECTING || socket.readyState === OPEN)
|
|
275
|
+
) {
|
|
276
|
+
socket.close();
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
private finishClose(event: unknown): void {
|
|
282
|
+
if (this.closeEmitted) return;
|
|
283
|
+
this.closeEmitted = true;
|
|
284
|
+
this.state = CLOSED;
|
|
285
|
+
this.stopPing();
|
|
286
|
+
for (const remove of this.removers.splice(0)) remove();
|
|
287
|
+
this.emit("close", event);
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
private emit(type: string, event: unknown): void {
|
|
291
|
+
for (const listener of this.listeners.get(type) ?? []) {
|
|
292
|
+
listener(event);
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
export function createCloudStatusTransportConstructor(
|
|
298
|
+
options: CloudStatusTransportOptions,
|
|
299
|
+
): AppServerSocketConstructor {
|
|
300
|
+
return class CloudStatusTransportSocket extends CloudStatusTransport {
|
|
301
|
+
constructor(_url: string, _socketOptions?: AppServerSocketOptions) {
|
|
302
|
+
super(options);
|
|
303
|
+
}
|
|
304
|
+
};
|
|
305
|
+
}
|