@letta-ai/letta-agent-sdk 0.3.1 → 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 (64) hide show
  1. package/AGENTS.md +47 -0
  2. package/README.md +17 -0
  3. package/dist/app-server-management.d.ts +58 -0
  4. package/dist/app-server-management.d.ts.map +1 -1
  5. package/dist/app-server-session.d.ts +5 -0
  6. package/dist/app-server-session.d.ts.map +1 -1
  7. package/dist/client-base.d.ts +7 -0
  8. package/dist/client-base.d.ts.map +1 -1
  9. package/dist/client-entry.js +1123 -776
  10. package/dist/client-entry.js.map +13 -9
  11. package/dist/client.d.ts.map +1 -1
  12. package/dist/cloud-sandbox.d.ts +33 -0
  13. package/dist/cloud-sandbox.d.ts.map +1 -0
  14. package/dist/cloud-session.d.ts.map +1 -1
  15. package/dist/index.d.ts +1 -1
  16. package/dist/index.d.ts.map +1 -1
  17. package/dist/index.js +1176 -791
  18. package/dist/index.js.map +17 -13
  19. package/dist/local-app-server-session.d.ts +1 -1
  20. package/dist/local-app-server-session.d.ts.map +1 -1
  21. package/dist/management.d.ts +7 -0
  22. package/dist/management.d.ts.map +1 -1
  23. package/dist/remote-client-session-core.d.ts +24 -93
  24. package/dist/remote-client-session-core.d.ts.map +1 -1
  25. package/dist/remote-session-protocol.d.ts +130 -0
  26. package/dist/remote-session-protocol.d.ts.map +1 -0
  27. package/dist/remote-turn-coordinator.d.ts +49 -0
  28. package/dist/remote-turn-coordinator.d.ts.map +1 -0
  29. package/dist/request-ids.d.ts +24 -0
  30. package/dist/request-ids.d.ts.map +1 -0
  31. package/dist/session.d.ts +10 -1
  32. package/dist/session.d.ts.map +1 -1
  33. package/dist/types.d.ts +6 -20
  34. package/dist/types.d.ts.map +1 -1
  35. package/package.json +5 -2
  36. package/src/app-server-management.ts +641 -0
  37. package/src/app-server-session.ts +948 -0
  38. package/src/cli-resolver.ts +46 -0
  39. package/src/client-base.ts +482 -0
  40. package/src/client-entry.ts +31 -0
  41. package/src/client.ts +138 -0
  42. package/src/cloud-management.ts +360 -0
  43. package/src/cloud-sandbox.ts +117 -0
  44. package/src/cloud-session.ts +1313 -0
  45. package/src/index.ts +440 -0
  46. package/src/interactiveToolPolicy.ts +62 -0
  47. package/src/local-app-server-session.ts +39 -0
  48. package/src/local-app-server.ts +137 -0
  49. package/src/management-types.ts +133 -0
  50. package/src/management.ts +206 -0
  51. package/src/protocol.ts +249 -0
  52. package/src/remote-client-session-core.ts +786 -0
  53. package/src/remote-session-protocol.ts +660 -0
  54. package/src/remote-turn-coordinator.ts +505 -0
  55. package/src/remote.ts +177 -0
  56. package/src/repositories.ts +340 -0
  57. package/src/request-ids.ts +33 -0
  58. package/src/session.ts +1638 -0
  59. package/src/stream-events.ts +88 -0
  60. package/src/tool-helpers.ts +147 -0
  61. package/src/transport.ts +484 -0
  62. package/src/types.ts +1328 -0
  63. package/src/validation.ts +223 -0
  64. package/src/websocket.ts +22 -0
@@ -0,0 +1,484 @@
1
+ /**
2
+ * SubprocessTransport
3
+ *
4
+ * Spawns the Letta Code CLI and communicates via stdin/stdout JSON streams.
5
+ */
6
+
7
+ import { spawn, type ChildProcess } from "node:child_process";
8
+ import { createInterface, type Interface } from "node:readline";
9
+ import { normalizePermissionMode } from "./remote-client-session-core.js";
10
+ import type { InternalSessionOptions, WireMessage } from "./types.js";
11
+
12
+ // All logging gated behind DEBUG_SDK env var
13
+ function sdkLog(tag: string, ...args: unknown[]) {
14
+ if (process.env.DEBUG_SDK) console.error(`[SDK-Transport] [${tag}]`, ...args);
15
+ }
16
+
17
+ const SDK_AGENT_ORIGIN_TAG = "origin:letta-code";
18
+
19
+ function shouldApplyNewAgentDefaults(options: InternalSessionOptions): boolean {
20
+ return options.createOnly === true;
21
+ }
22
+
23
+ function includeSdkAgentOriginTag(tags: string[] | undefined): string[] {
24
+ const normalizedTags: string[] = [];
25
+ let hasOriginTag = false;
26
+
27
+ for (const tag of tags ?? []) {
28
+ if (tag === SDK_AGENT_ORIGIN_TAG) {
29
+ if (hasOriginTag) continue;
30
+ hasOriginTag = true;
31
+ }
32
+ normalizedTags.push(tag);
33
+ }
34
+
35
+ if (!hasOriginTag) {
36
+ normalizedTags.push(SDK_AGENT_ORIGIN_TAG);
37
+ }
38
+
39
+ return normalizedTags;
40
+ }
41
+
42
+ /**
43
+ * Build the CLI argument array for a given set of session options.
44
+ *
45
+ * Exported as a pure function for testing — this IS the real production code
46
+ * path. SubprocessTransport.buildArgs() delegates here.
47
+ */
48
+ export function buildCliArgs(options: InternalSessionOptions): string[] {
49
+ const args: string[] = [
50
+ "--output-format",
51
+ "stream-json",
52
+ "--input-format",
53
+ "stream-json",
54
+ ];
55
+ const applyNewAgentDefaults = shouldApplyNewAgentDefaults(options);
56
+
57
+ // Conversation and agent handling
58
+ if (options.conversationId) {
59
+ args.push("--conversation", options.conversationId);
60
+ } else if (options.agentId) {
61
+ args.push("--agent", options.agentId);
62
+ if (options.newConversation) {
63
+ args.push("--new");
64
+ } else if (options.defaultConversation) {
65
+ args.push("--conversation", "default");
66
+ }
67
+ } else if (options.createOnly) {
68
+ args.push("--new-agent");
69
+ } else if (options.newConversation) {
70
+ args.push("--new");
71
+ }
72
+
73
+ // Model
74
+ if (options.model) {
75
+ args.push("-m", options.model);
76
+ }
77
+
78
+ // Partial message streaming
79
+ if (options.includePartialMessages) {
80
+ args.push("--include-partial-messages");
81
+ }
82
+
83
+ // Embedding model
84
+ if (options.embedding) {
85
+ args.push("--embedding", options.embedding);
86
+ }
87
+
88
+ // System prompt configuration
89
+ if (options.systemPrompt !== undefined) {
90
+ if (typeof options.systemPrompt === "string") {
91
+ const validPresets = [
92
+ "default",
93
+ "letta-claude",
94
+ "letta-codex",
95
+ "letta-gemini",
96
+ "claude",
97
+ "codex",
98
+ "gemini",
99
+ ];
100
+ if (validPresets.includes(options.systemPrompt)) {
101
+ args.push("--system", options.systemPrompt);
102
+ } else {
103
+ args.push("--system-custom", options.systemPrompt);
104
+ }
105
+ } else {
106
+ args.push("--system", options.systemPrompt.preset);
107
+ if (options.systemPrompt.append) {
108
+ args.push("--system-append", options.systemPrompt.append);
109
+ }
110
+ }
111
+ }
112
+
113
+ // Memory blocks (only for new agents)
114
+ if (options.memory !== undefined && !options.agentId) {
115
+ if (options.memory.length === 0) {
116
+ args.push("--init-blocks", "");
117
+ } else {
118
+ const presetNames: string[] = [];
119
+ const memoryBlocksJson: Array<
120
+ | { label: string; value: string }
121
+ | { blockId: string }
122
+ > = [];
123
+
124
+ for (const item of options.memory) {
125
+ if (typeof item === "string") {
126
+ presetNames.push(item);
127
+ } else if ("blockId" in item) {
128
+ memoryBlocksJson.push(item as { blockId: string });
129
+ } else {
130
+ memoryBlocksJson.push(item as { label: string; value: string });
131
+ }
132
+ }
133
+
134
+ if (memoryBlocksJson.length > 0) {
135
+ args.push("--memory-blocks", JSON.stringify(memoryBlocksJson));
136
+ if (presetNames.length > 0) {
137
+ console.warn(
138
+ "[letta-agent-sdk] Using custom memory blocks. " +
139
+ `Preset blocks are ignored when custom blocks are provided: ${presetNames.join(", ")}`
140
+ );
141
+ }
142
+ } else if (presetNames.length > 0) {
143
+ args.push("--init-blocks", presetNames.join(","));
144
+ }
145
+ }
146
+ }
147
+
148
+ // Convenience props for block values (only for new agents)
149
+ if (!options.agentId) {
150
+ if (options.persona !== undefined) {
151
+ args.push("--block-value", `persona=${options.persona}`);
152
+ }
153
+ if (options.human !== undefined) {
154
+ args.push("--block-value", `human=${options.human}`);
155
+ }
156
+ }
157
+
158
+ const mode = normalizePermissionMode(options.permissionMode);
159
+ if (mode !== undefined) {
160
+ args.push("--permission-mode", mode);
161
+ }
162
+
163
+ // Allowed / disallowed tools. Interactive user-input tools need no handling
164
+ // here: the CLI's headless path already excludes them from the toolset.
165
+ if (options.allowedTools) {
166
+ args.push("--allowedTools", options.allowedTools.join(","));
167
+ }
168
+ if (options.disallowedTools) {
169
+ args.push("--disallowedTools", options.disallowedTools.join(","));
170
+ }
171
+
172
+ // Tags
173
+ const tags = applyNewAgentDefaults
174
+ ? includeSdkAgentOriginTag(options.tags)
175
+ : options.tags;
176
+ if (tags && tags.length > 0) {
177
+ args.push("--tags", tags.join(","));
178
+ }
179
+
180
+ // SDK-created agents always use the git-backed memory filesystem.
181
+ if (applyNewAgentDefaults) {
182
+ args.push("--memfs");
183
+ // When omitted, the CLI applies its created-agent defaults. An explicit
184
+ // list is forwarded; "none" is the CLI's sentinel for an empty list.
185
+ if (options.baseTools !== undefined) {
186
+ args.push(
187
+ "--base-tools",
188
+ options.baseTools.length > 0 ? options.baseTools.join(",") : "none",
189
+ );
190
+ }
191
+ }
192
+
193
+ // Skills sources
194
+ if (options.skillSources !== undefined) {
195
+ const sources = [...new Set(options.skillSources)];
196
+ if (sources.length === 0) {
197
+ args.push("--no-skills");
198
+ } else {
199
+ args.push("--skill-sources", sources.join(","));
200
+ }
201
+ }
202
+
203
+ // Session context reminder toggle
204
+ if (options.systemInfoReminder === false) {
205
+ args.push("--no-system-info-reminder");
206
+ }
207
+
208
+ // Dreaming settings (forwarded to the CLI's underlying reflection flags)
209
+ if (options.dreaming?.trigger !== undefined) {
210
+ args.push("--reflection-trigger", options.dreaming.trigger);
211
+ }
212
+ if (options.dreaming?.behavior !== undefined) {
213
+ args.push("--reflection-behavior", options.dreaming.behavior);
214
+ }
215
+ if (options.dreaming?.stepCount !== undefined) {
216
+ args.push("--reflection-step-count", String(options.dreaming.stepCount));
217
+ }
218
+
219
+ return args;
220
+ }
221
+
222
+ export class SubprocessTransport {
223
+ private process: ChildProcess | null = null;
224
+ private stdout: Interface | null = null;
225
+ private messageQueue: WireMessage[] = [];
226
+ private messageResolvers: Array<(msg: WireMessage | null) => void> = [];
227
+ private closed = false;
228
+ private agentId?: string;
229
+ private wireMessageCount = 0;
230
+ private lastMessageAt = 0;
231
+ private stderrLines: string[] = [];
232
+
233
+ constructor(
234
+ private options: InternalSessionOptions = {}
235
+ ) {}
236
+
237
+ /**
238
+ * Start the CLI subprocess
239
+ */
240
+ async connect(): Promise<void> {
241
+ const args = this.buildArgs();
242
+
243
+ // Find the CLI - use the installed letta-code package
244
+ const cliPath = await this.findCli();
245
+ sdkLog("connect", `CLI: ${cliPath}`);
246
+ sdkLog("connect", `args: ${args.join(" ")}`);
247
+ sdkLog("connect", `cwd: ${this.options.cwd || process.cwd()}`);
248
+ sdkLog("connect", `permissionMode: ${this.options.permissionMode || "default"}`);
249
+
250
+ this.process = spawn("node", [cliPath, ...args], {
251
+ cwd: this.options.cwd || process.cwd(),
252
+ stdio: ["pipe", "pipe", "pipe"],
253
+ env: { ...process.env },
254
+ });
255
+
256
+ const pid = this.process.pid;
257
+ sdkLog("connect", `CLI process spawned, pid=${pid}`);
258
+
259
+ if (!this.process.stdout || !this.process.stdin) {
260
+ throw new Error("Failed to create subprocess pipes");
261
+ }
262
+
263
+ // Set up stdout reading
264
+ this.stdout = createInterface({
265
+ input: this.process.stdout,
266
+ crlfDelay: Infinity,
267
+ });
268
+
269
+ this.stdout.on("line", (line) => {
270
+ if (!line.trim()) return;
271
+ try {
272
+ const msg = JSON.parse(line) as WireMessage;
273
+ this.handleMessage(msg);
274
+ } catch {
275
+ // Non-JSON line from CLI stdout - could be important debug info
276
+ sdkLog("stdout", `[non-JSON] ${line.slice(0, 500)}`);
277
+ }
278
+ });
279
+
280
+ // Log stderr for debugging (CLI errors, auth failures, etc.)
281
+ // Also buffer lines so session.initialize() can include them in errors.
282
+ if (this.process.stderr) {
283
+ this.process.stderr.on("data", (data: Buffer) => {
284
+ const msg = data.toString().trim();
285
+ if (msg) {
286
+ console.error("[letta-agent-sdk] CLI stderr:", msg);
287
+ this.stderrLines.push(msg);
288
+ }
289
+ });
290
+ }
291
+
292
+ // Handle process exit
293
+ //
294
+ // BUG FIX: When the CLI subprocess exits while read() has a pending
295
+ // resolver waiting for the next message, that resolver would never fire.
296
+ // The messages() async generator would be stuck in `await this.read()`
297
+ // forever, causing session.stream() to hang, which deadlocks the
298
+ // caller's processing mutex. Resolving pending readers with null on
299
+ // process exit lets messages() break out of its loop cleanly.
300
+ this.process.on("close", (code, signal) => {
301
+ if (code !== 0 && code !== null) {
302
+ console.error(`[letta-agent-sdk] CLI process exited with code ${code}`);
303
+ }
304
+ sdkLog("close", `CLI process exited: pid=${pid} code=${code} signal=${signal} wireMessages=${this.wireMessageCount} msSinceLastMsg=${this.lastMessageAt ? Date.now() - this.lastMessageAt : 0} pendingResolvers=${this.messageResolvers.length} queueLen=${this.messageQueue.length}`);
305
+ this.closed = true;
306
+ // Flush pending readers so they don't hang forever (see comment above)
307
+ for (const resolve of this.messageResolvers) {
308
+ resolve(null);
309
+ }
310
+ this.messageResolvers = [];
311
+ });
312
+
313
+ this.process.on("error", (err) => {
314
+ console.error("[letta-agent-sdk] CLI process error:", err);
315
+ this.closed = true;
316
+ });
317
+ }
318
+
319
+ /**
320
+ * Send a message to the CLI via stdin
321
+ */
322
+ async write(data: object): Promise<void> {
323
+ if (!this.process?.stdin || this.closed) {
324
+ const err = new Error(`Transport not connected (closed=${this.closed}, pid=${this.process?.pid}, stdin=${!!this.process?.stdin})`);
325
+ sdkLog("write", err.message);
326
+ throw err;
327
+ }
328
+ const payload = data as Record<string, unknown>;
329
+ sdkLog("write", `type=${payload.type} subtype=${(payload.request as Record<string, unknown>)?.subtype || (payload.response as Record<string, unknown>)?.subtype || "N/A"}`);
330
+ this.process.stdin.write(JSON.stringify(data) + "\n");
331
+ }
332
+
333
+ /**
334
+ * Read the next message from the CLI
335
+ */
336
+ async read(): Promise<WireMessage | null> {
337
+ // Return queued message if available
338
+ if (this.messageQueue.length > 0) {
339
+ return this.messageQueue.shift()!;
340
+ }
341
+
342
+ // If closed, no more messages
343
+ if (this.closed) {
344
+ sdkLog("read", `returning null (closed), total wireMessages=${this.wireMessageCount}`);
345
+ return null;
346
+ }
347
+
348
+ // Wait for next message
349
+ sdkLog("read", `waiting for next message (resolvers=${this.messageResolvers.length + 1}, queue=${this.messageQueue.length})`);
350
+ return new Promise((resolve) => {
351
+ this.messageResolvers.push(resolve);
352
+ });
353
+ }
354
+
355
+ /**
356
+ * Async iterator for messages
357
+ */
358
+ async *messages(): AsyncGenerator<WireMessage> {
359
+ while (true) {
360
+ const msg = await this.read();
361
+ if (msg === null) {
362
+ sdkLog("messages", `iterator ending (closed=${this.closed}, wireMessages=${this.wireMessageCount})`);
363
+ break;
364
+ }
365
+ yield msg;
366
+ }
367
+ }
368
+
369
+ /**
370
+ * Close the transport
371
+ */
372
+ close(): void {
373
+ sdkLog("close", `explicit close called (wireMessages=${this.wireMessageCount}, pendingResolvers=${this.messageResolvers.length}, pid=${this.process?.pid})`);
374
+ if (this.process) {
375
+ this.process.stdin?.end();
376
+ this.process.kill();
377
+ this.process = null;
378
+ }
379
+ this.closed = true;
380
+
381
+ // Resolve any pending readers with null
382
+ for (const resolve of this.messageResolvers) {
383
+ resolve(null);
384
+ }
385
+ this.messageResolvers = [];
386
+ }
387
+
388
+ get isClosed(): boolean {
389
+ return this.closed;
390
+ }
391
+
392
+ /** Return buffered stderr output from the CLI subprocess. */
393
+ getStderr(): string {
394
+ return this.stderrLines.join("\n");
395
+ }
396
+
397
+ private handleMessage(msg: WireMessage): void {
398
+ this.wireMessageCount++;
399
+ this.lastMessageAt = Date.now();
400
+
401
+ // Compact log of every wire message for traceability
402
+ const wirePayload = msg as unknown as Record<string, unknown>;
403
+ const msgType = wirePayload.message_type || wirePayload.subtype || "";
404
+ sdkLog("wire", `#${this.wireMessageCount} type=${msg.type} ${msgType ? `msg_type=${msgType}` : ""} resolvers=${this.messageResolvers.length} queue=${this.messageQueue.length}`);
405
+
406
+ // Always log critical message types (result, errors, approval)
407
+ if (msg.type === "result") {
408
+ const result = wirePayload as unknown as { subtype?: string; result?: string; duration_ms?: number; stop_reason?: string };
409
+ sdkLog("wire", `RESULT: subtype=${result.subtype} stop_reason=${result.stop_reason || "N/A"} duration=${result.duration_ms}ms resultLen=${result.result?.length || 0}`);
410
+ }
411
+
412
+ // Track agent_id from init message
413
+ if (msg.type === "system" && "subtype" in msg && msg.subtype === "init") {
414
+ this.agentId = (msg as unknown as { agent_id: string }).agent_id;
415
+ sdkLog("wire", `INIT: agent_id=${this.agentId}`);
416
+ }
417
+
418
+ // Log control requests (approval flow)
419
+ if (msg.type === "control_request") {
420
+ const req = wirePayload as unknown as { request_id?: string; request?: { subtype?: string; tool_name?: string } };
421
+ sdkLog("wire", `CONTROL_REQUEST: id=${req.request_id} subtype=${req.request?.subtype} tool=${req.request?.tool_name || "N/A"}`);
422
+ }
423
+
424
+ // If someone is waiting for a message, give it to them
425
+ if (this.messageResolvers.length > 0) {
426
+ const resolve = this.messageResolvers.shift()!;
427
+ resolve(msg);
428
+ } else {
429
+ // Otherwise queue it
430
+ this.messageQueue.push(msg);
431
+ }
432
+ }
433
+
434
+ private buildArgs(): string[] {
435
+ return buildCliArgs(this.options);
436
+ }
437
+
438
+
439
+
440
+ private async findCli(): Promise<string> {
441
+ // Try multiple resolution strategies
442
+ const { existsSync } = await import("node:fs");
443
+ const { dirname, join } = await import("node:path");
444
+ const { fileURLToPath } = await import("node:url");
445
+
446
+ // Strategy 1: Check LETTA_CLI_PATH env var
447
+ if (process.env.LETTA_CLI_PATH && existsSync(process.env.LETTA_CLI_PATH)) {
448
+ return process.env.LETTA_CLI_PATH;
449
+ }
450
+
451
+ // Strategy 2: Try to resolve from node_modules
452
+ // Note: resolve the package main export (not /letta.js subpath) because
453
+ // the package.json "exports" field doesn't expose the subpath directly.
454
+ try {
455
+ const { createRequire } = await import("node:module");
456
+ const require = createRequire(import.meta.url);
457
+ const resolved = require.resolve("@letta-ai/letta-code");
458
+ if (existsSync(resolved)) {
459
+ return resolved;
460
+ }
461
+ } catch {
462
+ // Continue to next strategy
463
+ }
464
+
465
+ // Strategy 3: Check relative to this file (for local file: deps)
466
+ const __filename = fileURLToPath(import.meta.url);
467
+ const __dirname = dirname(__filename);
468
+ const localPaths = [
469
+ join(__dirname, "../../@letta-ai/letta-code/letta.js"),
470
+ join(__dirname, "../../../letta-code-prod/letta.js"),
471
+ join(__dirname, "../../../letta-code/letta.js"),
472
+ ];
473
+
474
+ for (const p of localPaths) {
475
+ if (existsSync(p)) {
476
+ return p;
477
+ }
478
+ }
479
+
480
+ throw new Error(
481
+ "Letta Code CLI not found. Set LETTA_CLI_PATH or install @letta-ai/letta-code."
482
+ );
483
+ }
484
+ }