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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (70) hide show
  1. package/AGENTS.md +44 -0
  2. package/README.md +29 -0
  3. package/dist/app-server-management.d.ts +3 -48
  4. package/dist/app-server-management.d.ts.map +1 -1
  5. package/dist/app-server-session.d.ts +0 -6
  6. package/dist/app-server-session.d.ts.map +1 -1
  7. package/dist/client-base.d.ts +7 -15
  8. package/dist/client-base.d.ts.map +1 -1
  9. package/dist/client-entry.js +979 -1010
  10. package/dist/client-entry.js.map +15 -11
  11. package/dist/client.d.ts +3 -3
  12. package/dist/client.d.ts.map +1 -1
  13. package/dist/cloud-sandbox.d.ts +33 -0
  14. package/dist/cloud-sandbox.d.ts.map +1 -0
  15. package/dist/cloud-session.d.ts.map +1 -1
  16. package/dist/cloud-status-transport.d.ts +53 -0
  17. package/dist/cloud-status-transport.d.ts.map +1 -0
  18. package/dist/index.d.ts +7 -16
  19. package/dist/index.d.ts.map +1 -1
  20. package/dist/index.js +3242 -4298
  21. package/dist/index.js.map +20 -17
  22. package/dist/local-app-server-session.d.ts +1 -1
  23. package/dist/local-app-server-session.d.ts.map +1 -1
  24. package/dist/local-app-server.d.ts +11 -0
  25. package/dist/local-app-server.d.ts.map +1 -1
  26. package/dist/management.d.ts +0 -7
  27. package/dist/management.d.ts.map +1 -1
  28. package/dist/remote-client-session-core.d.ts +6 -93
  29. package/dist/remote-client-session-core.d.ts.map +1 -1
  30. package/dist/remote-session-protocol.d.ts +132 -0
  31. package/dist/remote-session-protocol.d.ts.map +1 -0
  32. package/dist/remote-turn-coordinator.d.ts +49 -0
  33. package/dist/remote-turn-coordinator.d.ts.map +1 -0
  34. package/dist/types.d.ts +59 -86
  35. package/dist/types.d.ts.map +1 -1
  36. package/dist/validation.d.ts.map +1 -1
  37. package/package.json +6 -3
  38. package/src/app-server-management.ts +405 -0
  39. package/src/app-server-session.ts +916 -0
  40. package/src/cli-resolver.ts +46 -0
  41. package/src/client-base.ts +456 -0
  42. package/src/client-entry.ts +31 -0
  43. package/src/client.ts +99 -0
  44. package/src/cloud-management.ts +360 -0
  45. package/src/cloud-sandbox.ts +117 -0
  46. package/src/cloud-session.ts +1055 -0
  47. package/src/cloud-status-transport.ts +305 -0
  48. package/src/index.ts +422 -0
  49. package/src/interactiveToolPolicy.ts +62 -0
  50. package/src/local-app-server-session.ts +49 -0
  51. package/src/local-app-server.ts +231 -0
  52. package/src/management-types.ts +133 -0
  53. package/src/management.ts +199 -0
  54. package/src/remote-client-session-core.ts +786 -0
  55. package/src/remote-session-protocol.ts +674 -0
  56. package/src/remote-turn-coordinator.ts +523 -0
  57. package/src/remote.ts +177 -0
  58. package/src/repositories.ts +340 -0
  59. package/src/request-ids.ts +33 -0
  60. package/src/stream-events.ts +88 -0
  61. package/src/tool-helpers.ts +147 -0
  62. package/src/types.ts +1281 -0
  63. package/src/validation.ts +238 -0
  64. package/src/websocket.ts +22 -0
  65. package/dist/protocol.d.ts +0 -205
  66. package/dist/protocol.d.ts.map +0 -1
  67. package/dist/session.d.ts +0 -155
  68. package/dist/session.d.ts.map +0 -1
  69. package/dist/transport.d.ts +0 -53
  70. package/dist/transport.d.ts.map +0 -1
@@ -0,0 +1,916 @@
1
+ import {
2
+ createAppServerClient,
3
+ type AppServerClient,
4
+ type AppServerExternalToolCallHandler,
5
+ type AppServerSocketConstructor,
6
+ } from "@letta-ai/letta-code/app-server-client";
7
+ import {
8
+ buildCreateAgentRequestForPersonality,
9
+ buildSystemPrompt,
10
+ GIT_MEMORY_ENABLED_TAG,
11
+ LETTA_CODE_ORIGIN_TAG,
12
+ } from "@letta-ai/letta-code/agent-presets";
13
+ import {
14
+ buildCanUseToolContext,
15
+ isHeadlessAutoAllowTool,
16
+ requiresRuntimeUserInput,
17
+ } from "./interactiveToolPolicy.js";
18
+ import { applyUniqueRequestIds } from "./request-ids.js";
19
+ import {
20
+ RemoteClientSessionCore,
21
+ ensureSuccess,
22
+ isUnrestrictedPermissionMode,
23
+ mapPermissionMode,
24
+ normalizeSendMessage,
25
+ type ProtocolMessage,
26
+ type RemoteClientRuntimeController,
27
+ type RuntimeSendTurnOptions,
28
+ type RuntimeScope,
29
+ type RuntimeSessionInit,
30
+ type RuntimeSessionMode,
31
+ } from "./remote-client-session-core.js";
32
+ import type {
33
+ AnyAgentTool,
34
+ CanUseToolContext,
35
+ CanUseToolResponse,
36
+ CreateAgentOptions,
37
+ LettaCodeRemoteClientOptions,
38
+ LettaCodeClientSessionOptions,
39
+ ListModelsResult,
40
+ ListMessagesOptions,
41
+ ListMessagesResult,
42
+ MessageContentItem,
43
+ RecoverPendingApprovalsOptions,
44
+ RecoverPendingApprovalsResult,
45
+ SendMessage,
46
+ UpdateModelResult,
47
+ } from "./types.js";
48
+
49
+ type RuntimeStartResponse = ProtocolMessage & {
50
+ type: "runtime_start_response";
51
+ success: boolean;
52
+ runtime: RuntimeScope | null;
53
+ agent: (Record<string, unknown> & {
54
+ id?: string;
55
+ model?: string | null;
56
+ model_settings?: Record<string, unknown> | null;
57
+ }) | null;
58
+ conversation: (Record<string, unknown> & { id?: string; agent_id?: string }) | null;
59
+ error?: string;
60
+ };
61
+
62
+ type ConversationRetrieveResponse = ProtocolMessage & {
63
+ type: "conversation_retrieve_response";
64
+ success: boolean;
65
+ conversation: (Record<string, unknown> & { id?: string; agent_id?: string }) | null;
66
+ error?: string;
67
+ };
68
+
69
+ type ConversationMessagesListResponse = ProtocolMessage & {
70
+ type: "conversation_messages_list_response";
71
+ success: boolean;
72
+ messages: unknown[];
73
+ next_before?: string | null;
74
+ nextBefore?: string | null;
75
+ has_more?: boolean;
76
+ hasMore?: boolean;
77
+ error?: string;
78
+ };
79
+
80
+ type ListModelsResponse = ProtocolMessage & {
81
+ type: "list_models_response";
82
+ success: boolean;
83
+ entries?: unknown;
84
+ available_handles?: unknown;
85
+ byok_provider_aliases?: unknown;
86
+ error?: string;
87
+ };
88
+
89
+ type UpdateModelResponse = ProtocolMessage & {
90
+ type: "update_model_response";
91
+ success: boolean;
92
+ applied_to?: unknown;
93
+ model_id?: unknown;
94
+ model_handle?: unknown;
95
+ model_settings?: unknown;
96
+ error?: string;
97
+ };
98
+
99
+ type UpdateModelPayload = {
100
+ model_id?: string;
101
+ model_handle?: string;
102
+ };
103
+
104
+ type RuntimeStartCommand = Parameters<AppServerClient["runtimeStart"]>[0];
105
+
106
+ export function agentToolNames(
107
+ agent: Record<string, unknown> | null | undefined,
108
+ ): string[] | undefined {
109
+ const tools = agent?.tools;
110
+ if (!Array.isArray(tools)) return undefined;
111
+ return tools.flatMap((tool) => {
112
+ if (typeof tool === "string" && tool.length > 0) return [tool];
113
+ if (!tool || typeof tool !== "object") return [];
114
+ const name = (tool as { name?: unknown }).name;
115
+ return typeof name === "string" && name.length > 0 ? [name] : [];
116
+ });
117
+ }
118
+
119
+ export type AppServerSessionOptions = Partial<LettaCodeRemoteClientOptions> & {
120
+ /** Base websocket URL. */
121
+ url?: string;
122
+ /**
123
+ * Internal lazy connection hook. The Node entry point uses this to start a
124
+ * local app-server without pulling process-management code into `/client`.
125
+ */
126
+ connect?: (
127
+ sessionEnv?: Record<string, string>,
128
+ ) => Promise<{ url: string; close(): void }>;
129
+ /** Whether SDK create-agent payloads should add the origin tag automatically. */
130
+ includeSdkOriginTag?: boolean;
131
+ };
132
+
133
+ export type AppServerSessionMode = RuntimeSessionMode;
134
+
135
+ function isPresetSystemPrompt(value: string): boolean {
136
+ return [
137
+ "default",
138
+ "letta-claude",
139
+ "letta-codex",
140
+ "letta-gemini",
141
+ "claude",
142
+ "codex",
143
+ "gemini",
144
+ ].includes(value);
145
+ }
146
+
147
+ function assertRemoteCreateAgentOptionsSupported(options: CreateAgentOptions): void {
148
+ if (options.allowedTools !== undefined || options.disallowedTools !== undefined) {
149
+ throw new Error("App-server createAgent() does not yet support allowedTools/disallowedTools.");
150
+ }
151
+ if (options.canUseTool !== undefined) {
152
+ throw new Error("App-server createAgent() does not yet support canUseTool callbacks.");
153
+ }
154
+ if (options.systemInfoReminder !== undefined) {
155
+ throw new Error("App-server createAgent() does not yet support systemInfoReminder overrides.");
156
+ }
157
+ if (options.dreaming?.behavior !== undefined) {
158
+ throw new Error("App-server createAgent() does not yet support dreaming.behavior overrides.");
159
+ }
160
+ }
161
+
162
+ function normalizeMemoryBlock(block: Record<string, unknown>): Record<string, unknown> {
163
+ const normalized = { ...block };
164
+ if (normalized.value === undefined && typeof normalized.content === "string") {
165
+ normalized.value = normalized.content;
166
+ }
167
+ return normalized;
168
+ }
169
+
170
+ function upsertMemoryBlock(
171
+ blocks: Array<Record<string, unknown>>,
172
+ block: Record<string, unknown>,
173
+ ): void {
174
+ const label = block.label;
175
+ if (typeof label === "string") {
176
+ const existingIndex = blocks.findIndex((candidate) => candidate.label === label);
177
+ if (existingIndex >= 0) {
178
+ blocks[existingIndex] = block;
179
+ return;
180
+ }
181
+ }
182
+ blocks.push(block);
183
+ }
184
+
185
+ export async function createAgentBody(
186
+ options: CreateAgentOptions,
187
+ settings: { includeSdkOriginTag?: boolean } = {},
188
+ ): Promise<Record<string, unknown>> {
189
+ assertRemoteCreateAgentOptionsSupported(options);
190
+
191
+ const includeOriginTag = settings.includeSdkOriginTag ?? true;
192
+ const body: Record<string, unknown> = {
193
+ ...(await buildCreateAgentRequestForPersonality({
194
+ personalityId: options.personality ?? "memo",
195
+ ...(options.name !== undefined ? { name: options.name } : {}),
196
+ ...(options.description !== undefined
197
+ ? { description: options.description }
198
+ : {}),
199
+ ...(options.model !== undefined ? { model: options.model } : {}),
200
+ ...(options.tags !== undefined ? { extraTags: options.tags } : {}),
201
+ })),
202
+ };
203
+
204
+ if (Array.isArray(body.tags)) {
205
+ body.tags = body.tags.filter(
206
+ (tag) =>
207
+ (includeOriginTag || tag !== LETTA_CODE_ORIGIN_TAG) &&
208
+ (options.memfs !== false || tag !== GIT_MEMORY_ENABLED_TAG),
209
+ );
210
+ }
211
+
212
+ if (options.embedding !== undefined) body.embedding = options.embedding;
213
+ if (options.hidden !== undefined) body.hidden = options.hidden;
214
+ // When baseTools is omitted, the harness applies its created-agent
215
+ // defaults (web_search, fetch_webpage). An explicit list is pinned exactly:
216
+ // `tools` alone does not suppress the Letta agent type's defaults, so both
217
+ // default tool attachment and its default tool rules are disabled here.
218
+ if (options.baseTools === undefined) {
219
+ delete body.tools;
220
+ delete body.include_base_tools;
221
+ delete body.include_base_tool_rules;
222
+ } else {
223
+ body.tools = options.baseTools;
224
+ body.include_base_tools = false;
225
+ body.include_base_tool_rules = false;
226
+ }
227
+
228
+ if (options.systemPrompt === undefined) {
229
+ if (options.memfs === false) {
230
+ body.system = buildSystemPrompt("default", "standard");
231
+ }
232
+ } else {
233
+ if (typeof options.systemPrompt === "string") {
234
+ if (isPresetSystemPrompt(options.systemPrompt)) {
235
+ throw new Error("createAgent() does not yet support system prompt presets for this backend.");
236
+ }
237
+ body.system = options.systemPrompt;
238
+ } else {
239
+ throw new Error("createAgent() does not yet support system prompt preset objects for this backend.");
240
+ }
241
+ }
242
+
243
+ const memoryBlocks = Array.isArray(body.memory_blocks)
244
+ ? body.memory_blocks
245
+ .filter(
246
+ (block): block is Record<string, unknown> =>
247
+ block !== null && typeof block === "object",
248
+ )
249
+ .map((block) => ({ ...block }))
250
+ : [];
251
+ const blockIds: string[] = [];
252
+ for (const item of options.memory ?? []) {
253
+ if (typeof item === "string") {
254
+ throw new Error("App-server createAgent() does not yet support memory preset names.");
255
+ }
256
+ if ("blockId" in item) {
257
+ blockIds.push(item.blockId);
258
+ } else {
259
+ upsertMemoryBlock(
260
+ memoryBlocks,
261
+ normalizeMemoryBlock(item as unknown as Record<string, unknown>),
262
+ );
263
+ }
264
+ }
265
+ if (options.persona !== undefined) {
266
+ upsertMemoryBlock(memoryBlocks, {
267
+ label: "persona",
268
+ value: options.persona,
269
+ });
270
+ }
271
+ if (options.human !== undefined) {
272
+ upsertMemoryBlock(memoryBlocks, { label: "human", value: options.human });
273
+ }
274
+ body.memory_blocks = memoryBlocks;
275
+ if (blockIds.length > 0) body.block_ids = blockIds;
276
+
277
+ return body;
278
+ }
279
+
280
+ export function externalToolGroups(tools: AnyAgentTool[] | undefined): Array<Record<string, unknown>> | undefined {
281
+ if (!tools || tools.length === 0) return undefined;
282
+ return [
283
+ {
284
+ tools: tools.map((tool) => ({
285
+ name: tool.name,
286
+ label: tool.label,
287
+ description: tool.description,
288
+ parameters: tool.parameters as Record<string, unknown>,
289
+ })),
290
+ },
291
+ ];
292
+ }
293
+
294
+ export function createExternalToolCallHandler(
295
+ externalTools: Map<string, AnyAgentTool>,
296
+ ): AppServerExternalToolCallHandler {
297
+ return async (request) => {
298
+ const tool = externalTools.get(request.tool_name);
299
+ if (!tool) {
300
+ throw new Error(`Unknown external tool: ${request.tool_name}`);
301
+ }
302
+ const result = await tool.execute(request.tool_call_id, request.input);
303
+ return {
304
+ content: result.content.map((part) => ({
305
+ type: part.type,
306
+ ...(part.text !== undefined ? { text: part.text } : {}),
307
+ ...(part.data !== undefined ? { data: part.data } : {}),
308
+ ...(part.mimeType !== undefined ? { mimeType: part.mimeType } : {}),
309
+ })),
310
+ };
311
+ };
312
+ }
313
+
314
+ type AppServerApprovalOptions = LettaCodeClientSessionOptions | CreateAgentOptions;
315
+
316
+ type AppServerApprovalResponseDecision =
317
+ | {
318
+ behavior: "allow";
319
+ message?: string;
320
+ updated_input?: Record<string, unknown> | null;
321
+ selected_permission_suggestion_ids?: string[];
322
+ }
323
+ | {
324
+ behavior: "deny";
325
+ message: string;
326
+ };
327
+
328
+ export async function resolveAppServerToolApproval(
329
+ options: AppServerApprovalOptions,
330
+ toolName: string,
331
+ toolInput: Record<string, unknown>,
332
+ context?: CanUseToolContext,
333
+ ): Promise<CanUseToolResponse> {
334
+ const hasCallback = typeof options.canUseTool === "function";
335
+ const toolNeedsRuntimeUserInput = requiresRuntimeUserInput(toolName);
336
+
337
+ if (toolNeedsRuntimeUserInput && !hasCallback) {
338
+ return {
339
+ behavior: "deny",
340
+ message: "No canUseTool callback registered",
341
+ interrupt: false,
342
+ };
343
+ }
344
+
345
+ if (isUnrestrictedPermissionMode(options.permissionMode) && !toolNeedsRuntimeUserInput) {
346
+ return { behavior: "allow", updatedInput: null, updatedPermissions: [] };
347
+ }
348
+
349
+ if (hasCallback) {
350
+ try {
351
+ const result = await options.canUseTool!(toolName, toolInput, context);
352
+ if (result.behavior === "allow") {
353
+ return {
354
+ behavior: "allow",
355
+ message: result.message,
356
+ updatedInput: result.updatedInput ?? null,
357
+ updatedPermissions: result.updatedPermissions ?? [],
358
+ };
359
+ }
360
+ return {
361
+ behavior: "deny",
362
+ message: result.message ?? "Denied by canUseTool callback",
363
+ interrupt: result.interrupt ?? false,
364
+ };
365
+ } catch (error) {
366
+ return {
367
+ behavior: "deny",
368
+ message: error instanceof Error ? error.message : "Callback error",
369
+ interrupt: false,
370
+ };
371
+ }
372
+ }
373
+
374
+ if (isHeadlessAutoAllowTool(toolName)) {
375
+ return { behavior: "allow", updatedInput: null, updatedPermissions: [] };
376
+ }
377
+
378
+ return {
379
+ behavior: "deny",
380
+ message: "No canUseTool callback registered",
381
+ interrupt: false,
382
+ };
383
+ }
384
+
385
+ function permissionSuggestionId(value: unknown): string | null {
386
+ if (typeof value === "string") return value;
387
+ if (!value || typeof value !== "object") return null;
388
+ const record = value as Record<string, unknown>;
389
+ if (typeof record.id === "string") return record.id;
390
+ if (typeof record.suggestion_id === "string") return record.suggestion_id;
391
+ if (typeof record.permission_suggestion_id === "string") return record.permission_suggestion_id;
392
+ return null;
393
+ }
394
+
395
+ export function toAppServerApprovalDecision(
396
+ decision: CanUseToolResponse,
397
+ ): AppServerApprovalResponseDecision {
398
+ if (decision.behavior === "deny") {
399
+ return {
400
+ behavior: "deny",
401
+ message: decision.message,
402
+ };
403
+ }
404
+
405
+ const selectedPermissionSuggestionIds = (decision.updatedPermissions ?? [])
406
+ .map(permissionSuggestionId)
407
+ .filter((id): id is string => id !== null);
408
+
409
+ return {
410
+ behavior: "allow",
411
+ ...(decision.message !== undefined ? { message: decision.message } : {}),
412
+ updated_input: decision.updatedInput ?? null,
413
+ selected_permission_suggestion_ids: selectedPermissionSuggestionIds,
414
+ };
415
+ }
416
+
417
+ function stringRecord(value: unknown): Record<string, string> | undefined {
418
+ if (!value || typeof value !== "object" || Array.isArray(value)) return undefined;
419
+ const record: Record<string, string> = {};
420
+ for (const [key, entry] of Object.entries(value as Record<string, unknown>)) {
421
+ if (typeof entry === "string") record[key] = entry;
422
+ }
423
+ return record;
424
+ }
425
+
426
+ function objectRecord(value: unknown): Record<string, unknown> | undefined {
427
+ if (!value || typeof value !== "object" || Array.isArray(value)) return undefined;
428
+ return { ...(value as Record<string, unknown>) };
429
+ }
430
+
431
+ function normalizeListModelsResponse(response: ListModelsResponse): ListModelsResult {
432
+ if (!response.success) {
433
+ throw new Error(response.error ?? "listModels failed");
434
+ }
435
+
436
+ const entries = Array.isArray(response.entries)
437
+ ? response.entries.flatMap((entry) => {
438
+ if (!entry || typeof entry !== "object") return [];
439
+ const record = entry as Record<string, unknown>;
440
+ if (
441
+ typeof record.id !== "string" ||
442
+ typeof record.handle !== "string" ||
443
+ typeof record.label !== "string" ||
444
+ typeof record.description !== "string"
445
+ ) {
446
+ return [];
447
+ }
448
+ const updateArgs = objectRecord(record.updateArgs);
449
+ return [
450
+ {
451
+ id: record.id,
452
+ handle: record.handle,
453
+ label: record.label,
454
+ description: record.description,
455
+ ...(typeof record.isDefault === "boolean" ? { isDefault: record.isDefault } : {}),
456
+ ...(typeof record.isFeatured === "boolean" ? { isFeatured: record.isFeatured } : {}),
457
+ ...(typeof record.free === "boolean" ? { free: record.free } : {}),
458
+ ...(updateArgs ? { updateArgs } : {}),
459
+ },
460
+ ];
461
+ })
462
+ : [];
463
+
464
+ const result: ListModelsResult = { entries };
465
+ if (response.available_handles === null) {
466
+ result.availableHandles = null;
467
+ } else if (Array.isArray(response.available_handles)) {
468
+ result.availableHandles = response.available_handles.filter(
469
+ (handle): handle is string => typeof handle === "string",
470
+ );
471
+ }
472
+ const aliases = stringRecord(response.byok_provider_aliases);
473
+ if (aliases) result.byokProviderAliases = aliases;
474
+ return result;
475
+ }
476
+
477
+ function normalizeUpdateModelResponse(response: UpdateModelResponse): UpdateModelResult {
478
+ if (!response.success) {
479
+ throw new Error(response.error ?? "Failed to update model");
480
+ }
481
+ const result: UpdateModelResult = {};
482
+ if (response.applied_to === "agent" || response.applied_to === "conversation") {
483
+ result.appliedTo = response.applied_to;
484
+ }
485
+ if (typeof response.model_id === "string") result.modelId = response.model_id;
486
+ if (typeof response.model_handle === "string") result.modelHandle = response.model_handle;
487
+ if (response.model_settings === null) {
488
+ result.modelSettings = null;
489
+ } else if (response.model_settings && typeof response.model_settings === "object") {
490
+ result.modelSettings = { ...(response.model_settings as Record<string, unknown>) };
491
+ }
492
+ return result;
493
+ }
494
+
495
+ function runtimeScopeFromMessage(message: ProtocolMessage): RuntimeScope | null {
496
+ if (message.runtime) return message.runtime;
497
+ const agentId = typeof message.agent_id === "string" ? message.agent_id : null;
498
+ const conversationId = typeof message.conversation_id === "string" ? message.conversation_id : null;
499
+ if (agentId && conversationId) {
500
+ return { agent_id: agentId, conversation_id: conversationId };
501
+ }
502
+ return null;
503
+ }
504
+
505
+ export function registerAppServerControlRequestHandler(config: {
506
+ client: AppServerClient;
507
+ getRuntime: () => RuntimeScope | null;
508
+ getOptions: () => AppServerApprovalOptions;
509
+ }): () => void {
510
+ return config.client.onMessage((rawMessage, channel) => {
511
+ const message = rawMessage as unknown as ProtocolMessage;
512
+ if (channel !== "control" || message.type !== "control_request") return;
513
+ void respondToAppServerControlRequest(config, message).catch(() => {
514
+ // Permission callback failures are converted into deny responses by resolveAppServerToolApproval().
515
+ });
516
+ });
517
+ }
518
+
519
+ async function respondToAppServerControlRequest(
520
+ config: {
521
+ client: AppServerClient;
522
+ getRuntime: () => RuntimeScope | null;
523
+ getOptions: () => AppServerApprovalOptions;
524
+ },
525
+ message: ProtocolMessage,
526
+ ): Promise<void> {
527
+ const runtime = runtimeScopeFromMessage(message) ?? config.getRuntime();
528
+ if (!runtime) return;
529
+
530
+ const requestId = typeof message.request_id === "string" ? message.request_id : undefined;
531
+ const request = message.request;
532
+ if (!requestId || !request || typeof request !== "object") return;
533
+ const requestRecord = request as Record<string, unknown>;
534
+ if (requestRecord.subtype !== "can_use_tool") return;
535
+
536
+ const toolName = typeof requestRecord.tool_name === "string" ? requestRecord.tool_name : "unknown";
537
+ const toolInput =
538
+ requestRecord.input && typeof requestRecord.input === "object" && !Array.isArray(requestRecord.input)
539
+ ? (requestRecord.input as Record<string, unknown>)
540
+ : {};
541
+ const decision = await resolveAppServerToolApproval(
542
+ config.getOptions(),
543
+ toolName,
544
+ toolInput,
545
+ buildCanUseToolContext(requestRecord, requestId),
546
+ );
547
+ config.client.input({
548
+ runtime,
549
+ payload: {
550
+ kind: "approval_response",
551
+ request_id: requestId,
552
+ decision: toAppServerApprovalDecision(decision),
553
+ },
554
+ } as Parameters<AppServerClient["input"]>[0]);
555
+ }
556
+
557
+ export class AppServerRuntimeController implements RemoteClientRuntimeController {
558
+ constructor(
559
+ private readonly client: AppServerClient,
560
+ private readonly options: AppServerSessionOptions,
561
+ private readonly clientToolAllowlist?: readonly string[],
562
+ ) {}
563
+
564
+ onMessage(handler: (message: ProtocolMessage, channel?: string) => void): () => void {
565
+ return this.client.onMessage((message, channel) => {
566
+ handler(message as unknown as ProtocolMessage, channel);
567
+ });
568
+ }
569
+
570
+ send(command: Record<string, unknown>): void {
571
+ this.client.send(command as unknown as Parameters<AppServerClient["send"]>[0]);
572
+ }
573
+
574
+ sendTurnMessage(
575
+ runtime: RuntimeScope,
576
+ message: SendMessage,
577
+ options: RuntimeSendTurnOptions,
578
+ ): void {
579
+ const payload: Record<string, unknown> = {
580
+ kind: "create_message",
581
+ messages: [
582
+ {
583
+ role: "user",
584
+ content: normalizeSendMessage(message),
585
+ client_message_id: options.clientMessageId,
586
+ },
587
+ ],
588
+ };
589
+ if (this.clientToolAllowlist !== undefined) {
590
+ payload.client_tool_allowlist = [...new Set(this.clientToolAllowlist)];
591
+ }
592
+ // SDK sessions are headless: interactive user-input tools are always
593
+ // excluded from the toolset (harnesses without support ignore the flag
594
+ // and the SDK's runtime denial still applies).
595
+ payload.exclude_interactive_tools = true;
596
+ this.client.input({
597
+ runtime,
598
+ payload,
599
+ } as Parameters<AppServerClient["input"]>[0]);
600
+ }
601
+
602
+ async abort(runtime: RuntimeScope): Promise<void> {
603
+ await this.client.abort({ runtime } as Parameters<AppServerClient["abort"]>[0]);
604
+ }
605
+
606
+ request(
607
+ type: string,
608
+ body: Record<string, unknown>,
609
+ options: { timeoutMs?: number; predicate?: (message: ProtocolMessage) => boolean } = {},
610
+ ): Promise<ProtocolMessage> {
611
+ const request = this.client.request.bind(this.client) as unknown as (
612
+ commandType: string,
613
+ commandBody: Record<string, unknown>,
614
+ requestOptions?: {
615
+ timeoutMs?: number;
616
+ predicate?: (message: ProtocolMessage) => boolean;
617
+ },
618
+ ) => Promise<ProtocolMessage>;
619
+ return request(type, body, options);
620
+ }
621
+
622
+ async listModels(): Promise<ListModelsResult> {
623
+ const response = (await this.request(
624
+ "list_models",
625
+ {},
626
+ { predicate: (message) => message.type === "list_models_response" },
627
+ )) as ListModelsResponse;
628
+ return normalizeListModelsResponse(response);
629
+ }
630
+
631
+ async updateModel(
632
+ runtime: RuntimeScope,
633
+ payload: UpdateModelPayload,
634
+ ): Promise<UpdateModelResult> {
635
+ const response = (await this.request(
636
+ "update_model",
637
+ {
638
+ runtime,
639
+ payload,
640
+ },
641
+ { predicate: (message) => message.type === "update_model_response" },
642
+ )) as UpdateModelResponse;
643
+ return normalizeUpdateModelResponse(response);
644
+ }
645
+
646
+ async recoverPendingApprovals(
647
+ runtime: RuntimeScope,
648
+ options: RecoverPendingApprovalsOptions = {},
649
+ ): Promise<RecoverPendingApprovalsResult> {
650
+ const response = await this.client.sync(
651
+ {
652
+ runtime,
653
+ recover_approvals: true,
654
+ force_device_status: true,
655
+ },
656
+ options.timeoutMs !== undefined ? { timeoutMs: options.timeoutMs } : {},
657
+ );
658
+
659
+ if (!response.success) {
660
+ return {
661
+ recovered: false,
662
+ unsupported: false,
663
+ detail: response.error ?? "Failed to recover pending approvals",
664
+ };
665
+ }
666
+
667
+ return { recovered: true, unsupported: false };
668
+ }
669
+
670
+ async listMessages(
671
+ conversationId: string,
672
+ options: ListMessagesOptions = {},
673
+ ): Promise<ListMessagesResult> {
674
+ const query: Record<string, unknown> = {};
675
+ if (options.before !== undefined) query.before = options.before;
676
+ if (options.after !== undefined) query.after = options.after;
677
+ if (options.order !== undefined) query.order = options.order;
678
+ if (options.limit !== undefined) query.limit = options.limit;
679
+
680
+ const response = (await this.request(
681
+ "conversation_messages_list",
682
+ {
683
+ conversation_id: conversationId,
684
+ ...(Object.keys(query).length > 0 ? { query } : {}),
685
+ },
686
+ { predicate: (message) => message.type === "conversation_messages_list_response" },
687
+ )) as ConversationMessagesListResponse;
688
+
689
+ if (!response.success) {
690
+ throw new Error(response.error ?? "listMessages failed");
691
+ }
692
+
693
+ const result: ListMessagesResult = {
694
+ messages: response.messages ?? [],
695
+ };
696
+ const nextBefore =
697
+ typeof response.nextBefore === "string" || response.nextBefore === null
698
+ ? response.nextBefore
699
+ : typeof response.next_before === "string" || response.next_before === null
700
+ ? response.next_before
701
+ : undefined;
702
+ if (nextBefore !== undefined) result.nextBefore = nextBefore;
703
+ const hasMore =
704
+ typeof response.hasMore === "boolean"
705
+ ? response.hasMore
706
+ : typeof response.has_more === "boolean"
707
+ ? response.has_more
708
+ : undefined;
709
+ if (hasMore !== undefined) result.hasMore = hasMore;
710
+ return result;
711
+ }
712
+
713
+ close(): void {
714
+ this.client.close();
715
+ }
716
+ }
717
+
718
+ export class AppServerSession extends RemoteClientSessionCore {
719
+ private ownedConnection: { close(): void } | null = null;
720
+ private externalTools = new Map<string, AnyAgentTool>();
721
+ private removeExternalToolHandler: (() => void) | null = null;
722
+ private removeControlRequestHandler: (() => void) | null = null;
723
+
724
+ constructor(
725
+ private readonly remoteOptions: AppServerSessionOptions,
726
+ mode: AppServerSessionMode,
727
+ ) {
728
+ super(mode, {
729
+ label: "app-server",
730
+ requestTimeoutMs: remoteOptions.requestTimeoutMs,
731
+ });
732
+ const tools = mode.options.tools;
733
+ for (const tool of tools ?? []) {
734
+ this.externalTools.set(tool.name, tool);
735
+ }
736
+ }
737
+
738
+ protected override shouldEnableMemfs(options: LettaCodeClientSessionOptions | CreateAgentOptions): boolean {
739
+ if (this.mode.kind !== "create-agent") return false;
740
+ return (options as CreateAgentOptions).memfs !== false;
741
+ }
742
+
743
+ protected override async initializeRuntimeController(): Promise<RuntimeSessionInit> {
744
+ const url = await this.resolveAppServerUrl();
745
+ const client = applyUniqueRequestIds(createAppServerClient({
746
+ url,
747
+ ...(this.remoteOptions.authToken !== undefined
748
+ ? { authToken: this.remoteOptions.authToken }
749
+ : {}),
750
+ ...(this.remoteOptions.WebSocket
751
+ ? { WebSocket: this.remoteOptions.WebSocket as AppServerSocketConstructor }
752
+ : {}),
753
+ ...(this.remoteOptions.requestTimeoutMs !== undefined
754
+ ? { requestTimeoutMs: this.remoteOptions.requestTimeoutMs }
755
+ : {}),
756
+ }));
757
+
758
+ this.removeControlRequestHandler = registerAppServerControlRequestHandler({
759
+ client,
760
+ getRuntime: () => this.runtime,
761
+ getOptions: () => this.currentOptions(),
762
+ });
763
+
764
+ if (this.externalTools.size > 0) {
765
+ this.removeExternalToolHandler = client.onExternalToolCall(
766
+ createExternalToolCallHandler(this.externalTools),
767
+ );
768
+ }
769
+
770
+ try {
771
+ await client.connect();
772
+ const response = await this.startRuntime(client);
773
+ if (!response.success || !response.runtime) {
774
+ throw new Error(response.error ?? "Failed to start app-server runtime");
775
+ }
776
+
777
+ const tools = agentToolNames(response.agent);
778
+ const skillSources = this.currentOptions().skillSources;
779
+ return {
780
+ controller: new AppServerRuntimeController(
781
+ client,
782
+ this.remoteOptions,
783
+ this.currentOptions().allowedTools,
784
+ ),
785
+ runtime: response.runtime,
786
+ model: typeof response.agent?.model === "string" ? response.agent.model : "",
787
+ modelSettings: response.agent?.model_settings ?? null,
788
+ ...(tools !== undefined ? { tools } : {}),
789
+ ...(skillSources !== undefined ? { skillSources: [...skillSources] } : {}),
790
+ };
791
+ } catch (error) {
792
+ this.removeExternalToolHandler?.();
793
+ this.removeExternalToolHandler = null;
794
+ this.removeControlRequestHandler?.();
795
+ this.removeControlRequestHandler = null;
796
+ client.close();
797
+ throw error;
798
+ }
799
+ }
800
+
801
+ protected override onCoreClose(): void {
802
+ this.removeExternalToolHandler?.();
803
+ this.removeExternalToolHandler = null;
804
+ this.removeControlRequestHandler?.();
805
+ this.removeControlRequestHandler = null;
806
+ this.ownedConnection?.close();
807
+ this.ownedConnection = null;
808
+ }
809
+
810
+ private async resolveAppServerUrl(): Promise<string> {
811
+ if (this.remoteOptions.url) {
812
+ return this.remoteOptions.url;
813
+ }
814
+ if (!this.remoteOptions.connect) {
815
+ throw new Error("App-server session requires a url.");
816
+ }
817
+ const sessionEnv = (
818
+ this.mode.options as { env?: Record<string, string> }
819
+ ).env;
820
+ const connection = await this.remoteOptions.connect(sessionEnv);
821
+ this.ownedConnection = connection;
822
+ return connection.url;
823
+ }
824
+
825
+ private async startRuntime(client: AppServerClient): Promise<RuntimeStartResponse> {
826
+ const command = await this.buildRuntimeStartCommand(client);
827
+ const response = (await client.runtimeStart(command)) as unknown as RuntimeStartResponse;
828
+ return response;
829
+ }
830
+
831
+ private async buildRuntimeStartCommand(client: AppServerClient): Promise<RuntimeStartCommand> {
832
+ const options = this.mode.options;
833
+ const command: Record<string, unknown> = {
834
+ client_info: {
835
+ name: "@letta-ai/letta-agent-sdk",
836
+ title: "Letta Agent SDK",
837
+ },
838
+ recover_approvals: false,
839
+ force_device_status: true,
840
+ };
841
+
842
+ const mode = mapPermissionMode(options.permissionMode);
843
+ if (mode) command.mode = mode;
844
+ if (options.cwd !== undefined) command.cwd = options.cwd;
845
+ // Keep the distinction between omitted (use harness defaults) and []
846
+ // (disable bundled/global/agent/project skills). The app-server runtime is
847
+ // session-scoped, so this must be sent on creation and every resume.
848
+ if (options.skillSources !== undefined) {
849
+ command.skill_sources = [...new Set(options.skillSources)];
850
+ }
851
+ const groups = externalToolGroups(options.tools);
852
+ if (groups) command.external_tools = groups;
853
+
854
+ if (this.mode.kind === "create-agent") {
855
+ command.create_agent = {
856
+ body: await createAgentBody(this.mode.options, {
857
+ includeSdkOriginTag: this.remoteOptions.includeSdkOriginTag,
858
+ }),
859
+ // Hidden (worker-style) agents default to unpinned.
860
+ pin_global:
861
+ this.remoteOptions.pinGlobalAgent ??
862
+ (this.mode.options as CreateAgentOptions).hidden !== true,
863
+ // Signal memfs-less (worker-style) creation to the harness so it
864
+ // skips the memfs tag/settings/clone entirely; older harnesses
865
+ // ignore the field, and the client-side enable_memfs skip still
866
+ // applies either way.
867
+ ...((this.mode.options as CreateAgentOptions).memfs === false
868
+ ? { memfs: false }
869
+ : {}),
870
+ };
871
+ return command as RuntimeStartCommand;
872
+ }
873
+
874
+ if (this.mode.agentId) {
875
+ command.agent_id = this.mode.agentId;
876
+ if (this.mode.newConversation) {
877
+ command.create_conversation = { body: {} };
878
+ } else if (this.mode.defaultConversation) {
879
+ command.conversation_id = "default";
880
+ }
881
+ return command as RuntimeStartCommand;
882
+ }
883
+
884
+ if (this.mode.conversationId) {
885
+ const agentId = await this.resolveConversationAgentId(client, this.mode.conversationId);
886
+ command.agent_id = agentId;
887
+ command.conversation_id = this.mode.conversationId;
888
+ return command as RuntimeStartCommand;
889
+ }
890
+
891
+ throw new Error(
892
+ "App-server createSession() requires an agent id. Call createAgent() first or pass an agent id.",
893
+ );
894
+ }
895
+
896
+ private async resolveConversationAgentId(
897
+ client: AppServerClient,
898
+ conversationId: string,
899
+ ): Promise<string> {
900
+ const request = client.request.bind(client) as unknown as (
901
+ commandType: string,
902
+ commandBody: Record<string, unknown>,
903
+ options?: { predicate?: (message: ProtocolMessage) => boolean },
904
+ ) => Promise<ProtocolMessage>;
905
+ const response = (await request(
906
+ "conversation_retrieve",
907
+ { conversation_id: conversationId },
908
+ { predicate: (message) => message.type === "conversation_retrieve_response" },
909
+ )) as ConversationRetrieveResponse;
910
+ if (!response.success || !response.conversation?.agent_id) {
911
+ throw new Error(response.error ?? `Failed to retrieve conversation ${conversationId}`);
912
+ }
913
+ return response.conversation.agent_id;
914
+ }
915
+
916
+ }