@letta-ai/letta-agent-sdk 0.8.73 → 0.8.74

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 (47) hide show
  1. package/README.md +69 -0
  2. package/dist/app-server-session.d.ts +28 -7
  3. package/dist/app-server-session.d.ts.map +1 -1
  4. package/dist/client-base.d.ts +17 -0
  5. package/dist/client-base.d.ts.map +1 -1
  6. package/dist/client-entry.d.ts +1 -0
  7. package/dist/client-entry.d.ts.map +1 -1
  8. package/dist/client-entry.js +480 -77
  9. package/dist/client-entry.js.map +15 -12
  10. package/dist/client.d.ts +9 -0
  11. package/dist/client.d.ts.map +1 -1
  12. package/dist/cloud-session.d.ts.map +1 -1
  13. package/dist/cloud-status-transport.d.ts.map +1 -1
  14. package/dist/external-tools.d.ts +11 -0
  15. package/dist/external-tools.d.ts.map +1 -0
  16. package/dist/index.d.ts +2 -1
  17. package/dist/index.d.ts.map +1 -1
  18. package/dist/index.js +508 -102
  19. package/dist/index.js.map +16 -13
  20. package/dist/remote-client-session-core.d.ts.map +1 -1
  21. package/dist/remote-session-protocol.d.ts +3 -1
  22. package/dist/remote-session-protocol.d.ts.map +1 -1
  23. package/dist/remote-turn-coordinator.d.ts +14 -0
  24. package/dist/remote-turn-coordinator.d.ts.map +1 -1
  25. package/dist/runtime-tool-policy.d.ts +57 -0
  26. package/dist/runtime-tool-policy.d.ts.map +1 -0
  27. package/dist/structured-output-session.d.ts.map +1 -1
  28. package/dist/tool-registration.d.ts +67 -0
  29. package/dist/tool-registration.d.ts.map +1 -0
  30. package/dist/types.d.ts +30 -2
  31. package/dist/types.d.ts.map +1 -1
  32. package/package.json +1 -1
  33. package/src/app-server-session.ts +104 -60
  34. package/src/client-base.ts +57 -14
  35. package/src/client-entry.ts +1 -0
  36. package/src/client.ts +6 -2
  37. package/src/cloud-session.ts +12 -3
  38. package/src/cloud-status-transport.ts +4 -0
  39. package/src/external-tools.ts +69 -0
  40. package/src/index.ts +3 -0
  41. package/src/remote-client-session-core.ts +4 -4
  42. package/src/remote-session-protocol.ts +3 -0
  43. package/src/remote-turn-coordinator.ts +80 -15
  44. package/src/runtime-tool-policy.ts +114 -0
  45. package/src/structured-output-session.ts +2 -1
  46. package/src/tool-registration.ts +311 -0
  47. package/src/types.ts +39 -1
@@ -1,7 +1,6 @@
1
1
  import {
2
2
  createAppServerClient,
3
3
  type AppServerClient,
4
- type AppServerExternalToolCallHandler,
5
4
  type AppServerSocketConstructor,
6
5
  } from "@letta-ai/letta-code/app-server-client";
7
6
  import type {
@@ -25,6 +24,16 @@ import {
25
24
  type McpToolBridge,
26
25
  } from "./mcp-runtime.js";
27
26
  import { applyUniqueRequestIds } from "./request-ids.js";
27
+ import { createExternalToolCallHandler, externalToolGroups } from "./external-tools.js";
28
+ import {
29
+ clientToolsetPayload,
30
+ mergeInheritedTools,
31
+ runtimeToolPolicy,
32
+ supportsRuntimeToolPolicy,
33
+ type InheritedToolSource,
34
+ type RuntimeToolPolicy,
35
+ type SessionToolSelection,
36
+ } from "./runtime-tool-policy.js";
28
37
  import {
29
38
  RemoteClientSessionCore,
30
39
  ensureSuccess,
@@ -104,41 +113,6 @@ export type AppServerSessionOptions = Partial<LettaCodeRemoteClientOptions> & {
104
113
 
105
114
  export type AppServerSessionMode = RuntimeSessionMode;
106
115
 
107
- export function externalToolGroups(tools: AnyAgentTool[] | undefined): Array<Record<string, unknown>> | undefined {
108
- if (!tools || tools.length === 0) return undefined;
109
- return [
110
- {
111
- tools: tools.map((tool) => ({
112
- name: tool.name,
113
- label: tool.label,
114
- description: tool.description,
115
- parameters: tool.parameters as Record<string, unknown>,
116
- })),
117
- },
118
- ];
119
- }
120
-
121
- export function createExternalToolCallHandler(
122
- externalTools: Map<string, AnyAgentTool>,
123
- ): AppServerExternalToolCallHandler {
124
- return async (request) => {
125
- const tool = externalTools.get(request.tool_name);
126
- if (!tool) {
127
- throw new Error(`Unknown external tool: ${request.tool_name}`);
128
- }
129
- const result = await tool.execute(request.tool_call_id, request.input);
130
- return {
131
- content: result.content.map((part) => ({
132
- type: part.type,
133
- ...(part.text !== undefined ? { text: part.text } : {}),
134
- ...(part.data !== undefined ? { data: part.data } : {}),
135
- ...(part.mimeType !== undefined ? { mimeType: part.mimeType } : {}),
136
- })),
137
- ...(result.isError === true ? { is_error: true } : {}),
138
- };
139
- };
140
- }
141
-
142
116
  type AppServerApprovalOptions = LettaCodeClientSessionOptions | CreateAgentOptions;
143
117
 
144
118
  type AppServerApprovalResponseDecision =
@@ -393,10 +367,32 @@ export class AppServerRuntimeController implements RemoteClientRuntimeController
393
367
  constructor(
394
368
  private readonly client: AppServerClient,
395
369
  private readonly options: AppServerSessionOptions,
396
- private readonly clientToolAllowlist?: readonly string[],
397
- private readonly clientToolset?: ClientToolsetConfig,
370
+ private clientToolAllowlist?: readonly string[],
371
+ private clientToolset?: ClientToolsetConfig,
372
+ /** The listener applies runtime_start's tool_policy to every turn. */
373
+ private readonly toolPolicyApplied = false,
398
374
  ) {}
399
375
 
376
+ /**
377
+ * Replace this connection's tools and policy for the runtime, as when a
378
+ * `registerTools()` registration the session inherits changes. Older
379
+ * listeners get the tools here and the selection with each message.
380
+ */
381
+ async updateToolSelection(runtime: RuntimeScope, selection: SessionToolSelection): Promise<void> {
382
+ this.clientToolAllowlist = selection.allowedTools;
383
+ this.clientToolset = selection.toolset;
384
+ const response = await this.client.runtimeExternalToolsUpdate({
385
+ updates: [{
386
+ runtimes: [runtime],
387
+ external_tools: externalToolGroups(selection.tools) ?? [],
388
+ ...(this.toolPolicyApplied
389
+ ? { tool_policy: runtimeToolPolicy(selection.allowedTools, selection.toolset) }
390
+ : {}),
391
+ }],
392
+ } as unknown as Parameters<AppServerClient["runtimeExternalToolsUpdate"]>[0]);
393
+ if (!response.success) throw new Error(response.error ?? "Failed to update session tools");
394
+ }
395
+
400
396
  onMessage(handler: (message: ProtocolMessage, channel?: string) => void): () => void {
401
397
  return this.client.onMessage((message, channel) => {
402
398
  handler(message as unknown as ProtocolMessage, channel);
@@ -425,18 +421,12 @@ export class AppServerRuntimeController implements RemoteClientRuntimeController
425
421
  },
426
422
  ],
427
423
  };
428
- if (this.clientToolAllowlist !== undefined) {
424
+ // Older listeners only honour tool selection sent with each message.
425
+ if (!this.toolPolicyApplied && this.clientToolAllowlist !== undefined) {
429
426
  payload.client_tool_allowlist = [...new Set(this.clientToolAllowlist)];
430
427
  }
431
- if (this.clientToolset !== undefined) {
432
- payload.client_toolset = {
433
- ...(this.clientToolset.base !== undefined
434
- ? { base: this.clientToolset.base }
435
- : {}),
436
- ...(this.clientToolset.include !== undefined
437
- ? { include: [...new Set(this.clientToolset.include)] }
438
- : {}),
439
- };
428
+ if (!this.toolPolicyApplied && this.clientToolset !== undefined) {
429
+ payload.client_toolset = clientToolsetPayload(this.clientToolset);
440
430
  }
441
431
  if (options.outputFormat !== undefined) {
442
432
  payload.response_format = {
@@ -451,7 +441,7 @@ export class AppServerRuntimeController implements RemoteClientRuntimeController
451
441
  // SDK sessions are headless: interactive user-input tools are always
452
442
  // excluded from the toolset (harnesses without support ignore the flag
453
443
  // and the SDK's runtime denial still applies).
454
- payload.exclude_interactive_tools = true;
444
+ if (!this.toolPolicyApplied) payload.exclude_interactive_tools = true;
455
445
  this.client.input({
456
446
  runtime,
457
447
  payload,
@@ -590,6 +580,10 @@ export class AppServerSession extends RemoteClientSessionCore {
590
580
  private mcpCleanup: Promise<void> = Promise.resolve();
591
581
  private removeExternalToolHandler: (() => void) | null = null;
592
582
  private removeControlRequestHandler: (() => void) | null = null;
583
+ private inheritedTools: InheritedToolSource | null = null;
584
+ private removeInheritedListener: (() => void) | null = null;
585
+ /** This session's own selection, after MCP tools and wildcards resolve. */
586
+ private ownSelection: SessionToolSelection | null = null;
593
587
 
594
588
  constructor(
595
589
  private readonly remoteOptions: AppServerSessionOptions,
@@ -605,6 +599,34 @@ export class AppServerSession extends RemoteClientSessionCore {
605
599
  }
606
600
  }
607
601
 
602
+ /**
603
+ * Use the tools and policy of the `registerTools()` registration covering
604
+ * this conversation, and follow its updates while the session is open.
605
+ */
606
+ inheritTools(source: InheritedToolSource): void {
607
+ this.inheritedTools = source;
608
+ this.removeInheritedListener = source.onChange(() => {
609
+ void this.refreshInheritedTools().catch(() => undefined);
610
+ });
611
+ }
612
+
613
+ /** Rebuild the effective tool map in place; call handlers hold the map. */
614
+ private applyToolSelection(): SessionToolSelection {
615
+ const selection = mergeInheritedTools(
616
+ this.inheritedTools?.current() ?? null,
617
+ this.ownSelection ?? { tools: [] },
618
+ );
619
+ this.externalTools.clear();
620
+ for (const tool of selection.tools) this.externalTools.set(tool.name, tool);
621
+ return selection;
622
+ }
623
+
624
+ private async refreshInheritedTools(): Promise<void> {
625
+ if (!this.ownSelection || !this.runtime || !(this.controller instanceof AppServerRuntimeController)) return;
626
+ const selection = this.applyToolSelection();
627
+ await this.controller.updateToolSelection(this.runtime, selection);
628
+ }
629
+
608
630
  protected override shouldEnableMemfs(options: LettaCodeClientSessionOptions | CreateAgentOptions): boolean {
609
631
  if (this.mode.kind !== "create-agent") return false;
610
632
  return (options as CreateAgentOptions).memfs !== false;
@@ -627,6 +649,8 @@ export class AppServerSession extends RemoteClientSessionCore {
627
649
  }));
628
650
 
629
651
  const options = this.currentOptions();
652
+ this.externalTools.clear();
653
+ for (const tool of this.mode.options.tools ?? []) this.externalTools.set(tool.name, tool);
630
654
  this.mcpBridge = await connectMcpServers(
631
655
  "mcpServers" in options ? options.mcpServers : undefined,
632
656
  {
@@ -637,6 +661,15 @@ export class AppServerSession extends RemoteClientSessionCore {
637
661
  for (const tool of this.mcpBridge.tools) {
638
662
  this.externalTools.set(tool.name, tool);
639
663
  }
664
+ const mcpToolNames = this.mcpBridge.tools.map((tool) => tool.name);
665
+ const ownToolset = "toolset" in options ? options.toolset : undefined;
666
+ const ownAllowedTools = expandMcpToolWildcards(options.allowedTools, mcpToolNames);
667
+ this.ownSelection = {
668
+ tools: [...this.externalTools.values()],
669
+ ...(ownAllowedTools !== undefined ? { allowedTools: ownAllowedTools } : {}),
670
+ ...(ownToolset !== undefined ? { toolset: ownToolset } : {}),
671
+ };
672
+ const { allowedTools, toolset: clientToolset } = this.applyToolSelection();
640
673
 
641
674
  this.removeControlRequestHandler = registerAppServerControlRequestHandler({
642
675
  client,
@@ -644,9 +677,9 @@ export class AppServerSession extends RemoteClientSessionCore {
644
677
  getOptions: () => this.currentOptions(),
645
678
  });
646
679
 
647
- if (this.externalTools.size > 0) {
680
+ if (this.externalTools.size > 0 || this.inheritedTools) {
648
681
  this.removeExternalToolHandler = client.onExternalToolCall(
649
- createExternalToolCallHandler(this.externalTools),
682
+ createExternalToolCallHandler(this.externalTools, () => this.runtime),
650
683
  );
651
684
  }
652
685
 
@@ -667,21 +700,19 @@ export class AppServerSession extends RemoteClientSessionCore {
667
700
  );
668
701
  }
669
702
  }
670
- const response = await this.startRuntime(client);
703
+ const response = await this.startRuntime(
704
+ client,
705
+ runtimeToolPolicy(allowedTools, clientToolset),
706
+ );
671
707
  if (!response.success || !response.runtime) {
672
708
  throw new Error(response.error ?? "Failed to start app-server runtime");
673
709
  }
674
710
 
675
711
  this.watchTransportDisconnect(client);
712
+ const toolPolicyApplied = await supportsRuntimeToolPolicy(client);
676
713
 
677
714
  const tools = agentToolNames(response.agent);
678
715
  const skillSources = options.skillSources;
679
- const clientToolset = "toolset" in options ? options.toolset : undefined;
680
- const mcpToolNames = this.mcpBridge?.tools.map((tool) => tool.name) ?? [];
681
- const allowedTools = expandMcpToolWildcards(
682
- options.allowedTools,
683
- mcpToolNames,
684
- );
685
716
  const availableTools =
686
717
  tools === undefined && mcpToolNames.length === 0
687
718
  ? undefined
@@ -692,6 +723,7 @@ export class AppServerSession extends RemoteClientSessionCore {
692
723
  this.remoteOptions,
693
724
  allowedTools,
694
725
  clientToolset,
726
+ toolPolicyApplied,
695
727
  ),
696
728
  runtime: response.runtime,
697
729
  model:
@@ -719,6 +751,10 @@ export class AppServerSession extends RemoteClientSessionCore {
719
751
  }
720
752
 
721
753
  protected override onCoreClose(): void {
754
+ if (this.closed) {
755
+ this.removeInheritedListener?.();
756
+ this.removeInheritedListener = null;
757
+ }
722
758
  this.removeExternalToolHandler?.();
723
759
  this.removeExternalToolHandler = null;
724
760
  this.removeControlRequestHandler?.();
@@ -756,12 +792,16 @@ export class AppServerSession extends RemoteClientSessionCore {
756
792
 
757
793
  private async startRuntime(
758
794
  client: AppServerClient,
795
+ toolPolicy: RuntimeToolPolicy,
759
796
  ): Promise<RuntimeStartResponseMessage> {
760
- const command = await this.buildRuntimeStartCommand(client);
797
+ const command = await this.buildRuntimeStartCommand(client, toolPolicy);
761
798
  return client.runtimeStart(command);
762
799
  }
763
800
 
764
- private async buildRuntimeStartCommand(client: AppServerClient): Promise<RuntimeStartCommand> {
801
+ private async buildRuntimeStartCommand(
802
+ client: AppServerClient,
803
+ toolPolicy: RuntimeToolPolicy,
804
+ ): Promise<RuntimeStartCommand> {
765
805
  const options = this.mode.options;
766
806
  const command: Record<string, unknown> = {
767
807
  client_info: {
@@ -799,6 +839,10 @@ export class AppServerSession extends RemoteClientSessionCore {
799
839
  }
800
840
  const groups = externalToolGroups([...this.externalTools.values()]);
801
841
  if (groups) command.external_tools = groups;
842
+ // Sent on create, resume and every reconnect: the listener keeps it only
843
+ // while this connection is open, and applies it to every turn, including
844
+ // task notifications and cron prompts the runtime starts itself.
845
+ command.tool_policy = toolPolicy;
802
846
 
803
847
  if (this.mode.kind === "create-agent") {
804
848
  command.create_agent = {
@@ -33,6 +33,11 @@ import type {
33
33
  ModelsClient,
34
34
  } from "./management-types.js";
35
35
  import { createQuery } from "./query.js";
36
+ import {
37
+ ToolRegistry,
38
+ type RegisterToolsOptions,
39
+ type ToolRegistration,
40
+ } from "./tool-registration.js";
36
41
  import type {
37
42
  Query,
38
43
  QueryOptions,
@@ -161,6 +166,7 @@ export class LettaAgentClientBase implements AsyncDisposable {
161
166
  private agentRepositoriesClient: AgentRepositoriesClient | null = null;
162
167
  private cloudClient: Letta | null = null;
163
168
  private managementTransport: ManagementTransport | null = null;
169
+ private toolRegistry: ToolRegistry | null = null;
164
170
  private closed = false;
165
171
  private closePromise: Promise<void> | null = null;
166
172
 
@@ -351,6 +357,11 @@ export class LettaAgentClientBase implements AsyncDisposable {
351
357
  *
352
358
  * `options.environment` overrides the client's default execution target for
353
359
  * Letta Cloud sessions. Remote app-server URLs already select the runtime.
360
+ *
361
+ * If `client.registerTools()` covers this conversation, the session uses the
362
+ * registration's tools, `allowedTools` and `toolset` and follows its
363
+ * updates. Session `tools` are added on top, and session `allowedTools` can
364
+ * only narrow the registration's.
354
365
  */
355
366
  resumeSession(
356
367
  id: string,
@@ -360,21 +371,25 @@ export class LettaAgentClientBase implements AsyncDisposable {
360
371
  const sessionOptions = stripCloudExecutionOptions(options);
361
372
  validateCreateSessionOptions(sessionOptions);
362
373
  this.assertSessionBackend("resumeSession", options);
374
+ const inherited = this.backend === "cloud"
375
+ ? null
376
+ : this.toolRegistry?.inheritFor(id, sessionOptions) ?? null;
363
377
 
364
378
  if (this.backend === "remote") {
365
- if (looksLikeConversationId(id)) {
366
- return new AppServerSession(this.appServerSessionOptions(), {
367
- kind: "session",
368
- conversationId: id,
369
- options,
370
- });
371
- }
372
- return new AppServerSession(this.appServerSessionOptions(), {
373
- kind: "session",
374
- agentId: id,
375
- defaultConversation: true,
376
- options,
377
- });
379
+ const session = looksLikeConversationId(id)
380
+ ? new AppServerSession(this.appServerSessionOptions(), {
381
+ kind: "session",
382
+ conversationId: id,
383
+ options,
384
+ })
385
+ : new AppServerSession(this.appServerSessionOptions(), {
386
+ kind: "session",
387
+ agentId: id,
388
+ defaultConversation: true,
389
+ options,
390
+ });
391
+ if (inherited) session.inheritTools(inherited);
392
+ return session;
378
393
  }
379
394
  if (this.backend === "cloud") {
380
395
  if (looksLikeConversationId(id)) {
@@ -399,7 +414,9 @@ export class LettaAgentClientBase implements AsyncDisposable {
399
414
  this.getCloudClient(),
400
415
  );
401
416
  }
402
- return this.resumeLocalSession(id, options);
417
+ const session = this.resumeLocalSession(id, options);
418
+ if (inherited && session instanceof AppServerSession) session.inheritTools(inherited);
419
+ return session;
403
420
  }
404
421
 
405
422
  /**
@@ -740,6 +757,30 @@ export class LettaAgentClientBase implements AsyncDisposable {
740
757
  return this.managementTransport;
741
758
  }
742
759
 
760
+ /**
761
+ * Register client tools for many conversations over one long-lived
762
+ * connection. Every turn in those conversations can call them, including
763
+ * turns the runtime starts itself, such as task notifications and cron
764
+ * prompts, with or without an open session.
765
+ */
766
+ registerTools(options: RegisterToolsOptions): Promise<ToolRegistration> {
767
+ this.assertOpen();
768
+ if (this.backend === "cloud") {
769
+ return Promise.reject(new Error(
770
+ 'registerTools() requires backend: "local" or "remote". Cloud sessions register tools through the tools session option.',
771
+ ));
772
+ }
773
+ this.toolRegistry ??= new ToolRegistry(
774
+ this.backend === "remote" ? this.remoteOptions() : this.localAppServerConnection(),
775
+ );
776
+ return this.toolRegistry.register(options);
777
+ }
778
+
779
+ /** How the local backend reaches its App Server; overridden by the Node client. */
780
+ protected localAppServerConnection(): AppServerSessionOptions {
781
+ throw this.localBackendUnavailableError();
782
+ }
783
+
743
784
  /**
744
785
  * Release resources owned directly by this client, including its pooled
745
786
  * management connection. The Node client also stops its shared local App
@@ -748,6 +789,8 @@ export class LettaAgentClientBase implements AsyncDisposable {
748
789
  close(): Promise<void> {
749
790
  if (this.closePromise) return this.closePromise;
750
791
  this.closed = true;
792
+ this.toolRegistry?.close();
793
+ this.toolRegistry = null;
751
794
  const transport = this.managementTransport;
752
795
  this.managementTransport = null;
753
796
  const close = transport?.close() ?? Promise.resolve();
@@ -26,6 +26,7 @@ export class LettaAgentClient extends LettaAgentClientBase {
26
26
  export { CloudManagedSandboxExpiredError } from "./cloud-session.js";
27
27
  export { ConversationForkHydrationError } from "./management-errors.js";
28
28
  export { RepositoriesClient } from "./repositories.js";
29
+ export type { RegisterToolsOptions, ToolRegistration } from "./tool-registration.js";
29
30
  export type * from "./sandbox-files.js";
30
31
  export type {
31
32
  Computer,
package/src/client.ts CHANGED
@@ -42,11 +42,15 @@ export class LettaAgentClient extends LettaAgentClientBase {
42
42
  }
43
43
 
44
44
  protected override createLocalManagementTransport(): ManagementTransport {
45
+ return new AppServerManagementTransport(this.localAppServerConnection());
46
+ }
47
+
48
+ protected override localAppServerConnection() {
45
49
  const localOptions = (
46
50
  this.options as LettaCodeLocalClientOptions
47
51
  ).appServer;
48
52
  const sharedServer = this.sharedLocalServer();
49
- return new AppServerManagementTransport({
53
+ return {
50
54
  ...(localOptions?.url !== undefined
51
55
  ? { url: localOptions.url }
52
56
  : {
@@ -58,7 +62,7 @@ export class LettaAgentClient extends LettaAgentClientBase {
58
62
  ...(localOptions?.requestTimeoutMs !== undefined
59
63
  ? { requestTimeoutMs: localOptions.requestTimeoutMs }
60
64
  : {}),
61
- });
65
+ };
62
66
  }
63
67
 
64
68
  protected override async createLocalAgent(
@@ -14,16 +14,16 @@ import {
14
14
  import {
15
15
  AppServerRuntimeController,
16
16
  agentToolNames,
17
- createExternalToolCallHandler,
18
- externalToolGroups,
19
17
  registerAppServerControlRequestHandler,
20
18
  } from "./app-server-session.js";
19
+ import { createExternalToolCallHandler, externalToolGroups } from "./external-tools.js";
21
20
  import { createCloudStatusTransportConstructor } from "./cloud-status-transport.js";
22
21
  import {
23
22
  connectMcpServers,
24
23
  expandMcpToolWildcards,
25
24
  type McpToolBridge,
26
25
  } from "./mcp-runtime.js";
26
+ import { runtimeToolPolicy } from "./runtime-tool-policy.js";
27
27
  import { CloudManagementTransport } from "./cloud-management.js";
28
28
  import {
29
29
  createCloudClient,
@@ -588,7 +588,7 @@ export class CloudEnvironmentSession extends RemoteClientSessionCore {
588
588
  });
589
589
  if (this.externalTools.size > 0) {
590
590
  this.removeExternalToolHandler = client.onExternalToolCall(
591
- createExternalToolCallHandler(this.externalTools),
591
+ createExternalToolCallHandler(this.externalTools, () => this.runtime),
592
592
  );
593
593
  }
594
594
  }
@@ -656,6 +656,15 @@ export class CloudEnvironmentSession extends RemoteClientSessionCore {
656
656
  }
657
657
  const groups = externalToolGroups([...this.externalTools.values()]);
658
658
  if (groups) command.external_tools = groups;
659
+ // Re-sent on idle-transport recovery so a new connection restores it.
660
+ // Per-message fields are still sent: Cloud does not confirm the capability.
661
+ command.tool_policy = runtimeToolPolicy(
662
+ expandMcpToolWildcards(
663
+ options.allowedTools,
664
+ this.mcpBridge?.tools.map((tool) => tool.name) ?? [],
665
+ ),
666
+ "toolset" in options ? options.toolset : undefined,
667
+ );
659
668
 
660
669
  return (await client.runtimeStart(
661
670
  command as Parameters<AppServerClient["runtimeStart"]>[0],
@@ -189,6 +189,10 @@ export class CloudStatusTransport implements AppServerSocketLike {
189
189
  if (channel === "control" && message.type === "stream_delta") {
190
190
  return false;
191
191
  }
192
+ // Tool requests are answered on control; the stream copy is a duplicate.
193
+ if (channel === "stream" && message.type === "external_tool_call_request") {
194
+ return false;
195
+ }
192
196
  if (this.isDuplicate(message)) return false;
193
197
  this.trackEventSequence(message);
194
198
  return true;
@@ -0,0 +1,69 @@
1
+ import type { AppServerExternalToolCallHandler } from "@letta-ai/letta-code/app-server-client";
2
+ import type { RuntimeScope } from "./remote-session-protocol.js";
3
+ import type { AgentToolCallContext, AnyAgentTool } from "./types.js";
4
+
5
+ /** The `external_tools` groups that advertise client tools to the runtime. */
6
+ export function externalToolGroups(tools: AnyAgentTool[] | undefined): Array<Record<string, unknown>> | undefined {
7
+ if (!tools || tools.length === 0) return undefined;
8
+ return [
9
+ {
10
+ tools: tools.map((tool) => ({
11
+ name: tool.name,
12
+ label: tool.label,
13
+ description: tool.description,
14
+ parameters: tool.parameters as Record<string, unknown>,
15
+ })),
16
+ },
17
+ ];
18
+ }
19
+
20
+ const MAX_REMEMBERED_EXTERNAL_TOOL_CALLS = 200;
21
+
22
+ /**
23
+ * Run a runtime's external tool call against the client tools by name.
24
+ * `fallbackRuntime` covers listeners that send calls without a `runtime`.
25
+ */
26
+ export function createExternalToolCallHandler(
27
+ externalTools: Map<string, AnyAgentTool>,
28
+ fallbackRuntime?: () => RuntimeScope | null,
29
+ ): AppServerExternalToolCallHandler {
30
+ // A request can be delivered more than once (e.g. relayed on more than one
31
+ // socket). Share one execution per request_id so a tool never runs twice.
32
+ const calls = new Map<string, ReturnType<AppServerExternalToolCallHandler>>();
33
+ const execute: AppServerExternalToolCallHandler = async (request) => {
34
+ const tool = externalTools.get(request.tool_name);
35
+ if (!tool) {
36
+ throw new Error(`Unknown external tool: ${request.tool_name}`);
37
+ }
38
+ const runtime = request.runtime ?? fallbackRuntime?.() ?? null;
39
+ const context: AgentToolCallContext | undefined = runtime
40
+ ? { toolCallId: request.tool_call_id, agentId: runtime.agent_id, conversationId: runtime.conversation_id }
41
+ : undefined;
42
+ const result = await tool.execute(request.tool_call_id, request.input, undefined, undefined, context);
43
+ return {
44
+ content: result.content.map((part) => ({
45
+ type: part.type,
46
+ ...(part.text !== undefined ? { text: part.text } : {}),
47
+ ...(part.data !== undefined ? { data: part.data } : {}),
48
+ ...(part.mimeType !== undefined ? { mimeType: part.mimeType } : {}),
49
+ })),
50
+ ...(result.isError === true ? { is_error: true } : {}),
51
+ };
52
+ };
53
+ return (request) => {
54
+ const requestId = request.request_id;
55
+ if (typeof requestId !== "string" || requestId.length === 0) {
56
+ return execute(request);
57
+ }
58
+ const existing = calls.get(requestId);
59
+ if (existing) return existing;
60
+ const call = execute(request);
61
+ calls.set(requestId, call);
62
+ while (calls.size > MAX_REMEMBERED_EXTERNAL_TOOL_CALLS) {
63
+ const oldest = calls.keys().next().value;
64
+ if (oldest === undefined) break;
65
+ calls.delete(oldest);
66
+ }
67
+ return call;
68
+ };
69
+ }
package/src/index.ts CHANGED
@@ -68,6 +68,7 @@ export type {
68
68
  SDKToolResultMessage,
69
69
  SDKReasoningMessage,
70
70
  SDKResultMessage,
71
+ SDKTurnOrigin,
71
72
  SDKErrorCode,
72
73
  SDKStreamEventMessage,
73
74
  SDKStreamEventPayload,
@@ -147,6 +148,7 @@ export type {
147
148
  BootstrapStateResult,
148
149
  // Tool types
149
150
  AgentTool,
151
+ AgentToolCallContext,
150
152
  AgentToolResult,
151
153
  AgentToolResultContent,
152
154
  AgentToolUpdateCallback,
@@ -190,6 +192,7 @@ export type {
190
192
  } from "./management-types.js";
191
193
 
192
194
  export { RepositoriesClient } from "./repositories.js";
195
+ export type { RegisterToolsOptions, ToolRegistration } from "./tool-registration.js";
193
196
  export { ConversationForkHydrationError } from "./management-errors.js";
194
197
  export type {
195
198
  Computer,
@@ -289,9 +289,7 @@ export abstract class RemoteClientSessionCore implements LettaCodeSession {
289
289
  }
290
290
  await this.send(message);
291
291
  for await (const msg of this.stream()) {
292
- if (msg.type === "result") {
293
- return msg;
294
- }
292
+ if (msg.type === "result" && msg.origin === undefined) return msg;
295
293
  }
296
294
  return {
297
295
  type: "result",
@@ -323,7 +321,8 @@ export abstract class RemoteClientSessionCore implements LettaCodeSession {
323
321
  const msg = await this.turns.nextMessage();
324
322
  if (!msg) break;
325
323
  yield msg;
326
- if (msg.type === "result") break;
324
+ // Results of turns the runtime started are yielded; only ours end the pass.
325
+ if (msg.type === "result" && msg.origin === undefined) break;
327
326
  }
328
327
  }
329
328
 
@@ -717,6 +716,7 @@ export abstract class RemoteClientSessionCore implements LettaCodeSession {
717
716
  this.controller?.close();
718
717
  this.controller = null;
719
718
  const error = new Error(`${this.label} connection closed before send`);
719
+ this.turns.failServerTurn(error.message);
720
720
  for (const cancel of [...this.deviceStatusRefreshCancels]) cancel(error);
721
721
  this.deviceStatusRefreshCancels.clear();
722
722
  this.onIdleTransportDisconnect();
@@ -10,6 +10,7 @@ import type {
10
10
  RecoverPendingApprovalsResult,
11
11
  ReasoningEffort,
12
12
  SDKErrorCode,
13
+ SDKTurnOrigin,
13
14
  OutputFormat,
14
15
  SDKQueueItem,
15
16
  SendMessage,
@@ -151,6 +152,8 @@ export type TurnTracker = {
151
152
  /** Caller-supplied OTID for this turn's user message, when one was provided. */
152
153
  otid?: string;
153
154
  outputFormat?: OutputFormat;
155
+ /** Set when the runtime started this turn rather than the session. */
156
+ origin?: SDKTurnOrigin;
154
157
  queuedAt: number;
155
158
  startedAt: number;
156
159
  assistantText: string;