From 79ab4730906ff435ecb893261241d55ea53ca4a3 Mon Sep 17 00:00:00 2001 From: Sarah Wooders Date: Tue, 10 Feb 2026 11:56:47 -0800 Subject: [PATCH] Add custom tools support (CLI side) (#733) Co-authored-by: Letta Co-authored-by: letta-code <248085862+letta-code@users.noreply.github.com> Co-authored-by: Christina Tong --- src/headless.ts | 64 ++++++++++++++++++ src/tools/manager.ts | 149 +++++++++++++++++++++++++++++++++++++++++- src/types/protocol.ts | 60 ++++++++++++++++- 3 files changed, 269 insertions(+), 4 deletions(-) diff --git a/src/headless.ts b/src/headless.ts index 9b50983..6300d4e 100644 --- a/src/headless.ts +++ b/src/headless.ts @@ -40,6 +40,11 @@ import { } from "./cli/helpers/stream"; import { SYSTEM_REMINDER_CLOSE, SYSTEM_REMINDER_OPEN } from "./constants"; import { settingsManager } from "./settings-manager"; +import { + registerExternalTools, + setExternalToolExecutor, + type ExternalToolDefinition, +} from "./tools/manager"; import type { AutoApprovalMessage, CanUseToolControlRequest, @@ -2101,6 +2106,65 @@ async function runBidirectionalMode( uuid: crypto.randomUUID(), }; console.log(JSON.stringify(interruptResponse)); + } else if (subtype === "register_external_tools") { + // Register external tools from SDK + const toolsRequest = message.request as { tools?: ExternalToolDefinition[] }; + const tools = toolsRequest.tools ?? []; + + registerExternalTools(tools); + + // Set up the external tool executor to send requests back to SDK + setExternalToolExecutor(async (toolCallId, toolName, input) => { + // Send execute_external_tool request to SDK + const execRequest: ControlRequest = { + type: "control_request", + request_id: `ext-${toolCallId}`, + request: { + subtype: "execute_external_tool", + tool_call_id: toolCallId, + tool_name: toolName, + input, + } as unknown as CanUseToolControlRequest, // Type cast for compatibility + }; + console.log(JSON.stringify(execRequest)); + + // Wait for external_tool_result response + while (true) { + const line = await getNextLine(); + if (line === null) { + return { content: [{ type: "text", text: "stdin closed" }], isError: true }; + } + if (!line.trim()) continue; + + try { + const msg = JSON.parse(line); + if ( + msg.type === "control_response" && + msg.response?.subtype === "external_tool_result" && + msg.response?.tool_call_id === toolCallId + ) { + return { + content: msg.response.content ?? [{ type: "text", text: "" }], + isError: msg.response.is_error ?? false, + }; + } + } catch { + // Ignore parse errors, keep waiting + } + } + }); + + const registerResponse: ControlResponse = { + type: "control_response", + response: { + subtype: "success", + request_id: requestId ?? "", + response: { registered: tools.length }, + }, + session_id: sessionId, + uuid: crypto.randomUUID(), + }; + console.log(JSON.stringify(registerResponse)); } else { const errorResponse: ControlResponse = { type: "control_response", diff --git a/src/tools/manager.ts b/src/tools/manager.ts index 810dd09..8f0a627 100644 --- a/src/tools/manager.ts +++ b/src/tools/manager.ts @@ -360,12 +360,145 @@ export interface ClientTool { parameters?: { [key: string]: unknown } | null; } +// ═══════════════════════════════════════════════════════════════ +// EXTERNAL TOOLS (SDK-side execution) +// ═══════════════════════════════════════════════════════════════ + +/** + * External tool definition from SDK + */ +export interface ExternalToolDefinition { + name: string; + label?: string; + description: string; + parameters: Record; // JSON Schema +} + +/** + * Callback to execute an external tool via SDK + */ +export type ExternalToolExecutor = ( + toolCallId: string, + toolName: string, + input: Record, +) => Promise<{ + content: Array<{ type: string; text?: string; data?: string; mimeType?: string }>; + isError: boolean; +}>; + +// Storage for external tool definitions and executor +const EXTERNAL_TOOLS_KEY = Symbol.for("@letta/externalTools"); +const EXTERNAL_EXECUTOR_KEY = Symbol.for("@letta/externalToolExecutor"); + +type GlobalWithExternalTools = typeof globalThis & { + [EXTERNAL_TOOLS_KEY]?: Map; + [EXTERNAL_EXECUTOR_KEY]?: ExternalToolExecutor; +}; + +function getExternalToolsRegistry(): Map { + const global = globalThis as GlobalWithExternalTools; + if (!global[EXTERNAL_TOOLS_KEY]) { + global[EXTERNAL_TOOLS_KEY] = new Map(); + } + return global[EXTERNAL_TOOLS_KEY]; +} + +/** + * Register external tools from SDK + */ +export function registerExternalTools(tools: ExternalToolDefinition[]): void { + const registry = getExternalToolsRegistry(); + for (const tool of tools) { + registry.set(tool.name, tool); + } +} + +/** + * Set the executor callback for external tools + */ +export function setExternalToolExecutor(executor: ExternalToolExecutor): void { + (globalThis as GlobalWithExternalTools)[EXTERNAL_EXECUTOR_KEY] = executor; +} + +/** + * Clear external tools (for testing or session cleanup) + */ +export function clearExternalTools(): void { + getExternalToolsRegistry().clear(); + delete (globalThis as GlobalWithExternalTools)[EXTERNAL_EXECUTOR_KEY]; +} + +/** + * Check if a tool is external (SDK-executed) + */ +export function isExternalTool(name: string): boolean { + return getExternalToolsRegistry().has(name); +} + +/** + * Get external tool definition + */ +export function getExternalToolDefinition(name: string): ExternalToolDefinition | undefined { + return getExternalToolsRegistry().get(name); +} + +/** + * Get all external tools as ClientTool format + */ +export function getExternalToolsAsClientTools(): ClientTool[] { + return Array.from(getExternalToolsRegistry().values()).map((tool) => ({ + name: tool.name, + description: tool.description, + parameters: tool.parameters, + })); +} + +/** + * Execute an external tool via SDK + */ +export async function executeExternalTool( + toolCallId: string, + toolName: string, + input: Record, +): Promise { + const executor = (globalThis as GlobalWithExternalTools)[EXTERNAL_EXECUTOR_KEY]; + if (!executor) { + return { + toolReturn: `External tool executor not set for tool: ${toolName}`, + status: "error", + }; + } + + try { + const result = await executor(toolCallId, toolName, input); + + // Convert external tool result to ToolExecutionResult format + const textContent = result.content + .filter((c) => c.type === "text" && c.text) + .map((c) => c.text) + .join("\n"); + + return { + toolReturn: textContent || JSON.stringify(result.content), + status: result.isError ? "error" : "success", + }; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); + return { + toolReturn: `External tool execution error: ${errorMessage}`, + status: "error", + }; + } +} + /** * Get all loaded tools in the format expected by the Letta API's client_tools field. * Maps internal tool names to server-facing names for proper tool invocation. + * Includes both built-in tools and external tools. */ export function getClientToolsFromRegistry(): ClientTool[] { - return Array.from(toolRegistry.entries()).map(([name, tool]) => { + // Get built-in tools + const builtInTools = Array.from(toolRegistry.entries()).map(([name, tool]) => { const serverName = getServerToolName(name); return { name: serverName, @@ -373,6 +506,11 @@ export function getClientToolsFromRegistry(): ClientTool[] { parameters: tool.schema.input_schema, }; }); + + // Add external tools + const externalTools = getExternalToolsAsClientTools(); + + return [...builtInTools, ...externalTools]; } /** @@ -876,6 +1014,15 @@ export async function executeTool( onOutput?: (chunk: string, stream: "stdout" | "stderr") => void; }, ): Promise { + // Check if this is an external tool (SDK-executed) + if (isExternalTool(name)) { + return executeExternalTool( + options?.toolCallId ?? `ext-${Date.now()}`, + name, + args as Record, + ); + } + const internalName = resolveInternalToolName(name); if (!internalName) { return { diff --git a/src/types/protocol.ts b/src/types/protocol.ts index d33c48f..b217f8b 100644 --- a/src/types/protocol.ts +++ b/src/types/protocol.ts @@ -263,7 +263,27 @@ export interface ControlRequest { // SDK → CLI request subtypes export type SdkToCliControlRequest = | { subtype: "initialize" } - | { subtype: "interrupt" }; + | { subtype: "interrupt" } + | RegisterExternalToolsRequest; + +/** + * Request to register external tools (SDK → CLI) + * External tools are executed by the SDK, not the CLI. + */ +export interface RegisterExternalToolsRequest { + subtype: "register_external_tools"; + tools: ExternalToolDefinition[]; +} + +/** + * External tool definition (from SDK) + */ +export interface ExternalToolDefinition { + name: string; + label?: string; + description: string; + parameters: Record; // JSON Schema +} // CLI → SDK request subtypes export interface CanUseToolControlRequest { @@ -277,7 +297,19 @@ export interface CanUseToolControlRequest { blocked_path: string | null; } -export type CliToSdkControlRequest = CanUseToolControlRequest; +/** + * Request to execute an external tool (CLI → SDK) + */ +export interface ExecuteExternalToolRequest { + subtype: "execute_external_tool"; + tool_call_id: string; + tool_name: string; + input: Record; +} + +export type CliToSdkControlRequest = + | CanUseToolControlRequest + | ExecuteExternalToolRequest; // Combined for parsing export type ControlRequestBody = @@ -296,7 +328,8 @@ export type ControlResponseBody = request_id: string; response?: CanUseToolResponse | Record; } - | { subtype: "error"; request_id: string; error: string }; + | { subtype: "error"; request_id: string; error: string } + | ExternalToolResultResponse; // --- can_use_tool response payloads --- export interface CanUseToolResponseAllow { @@ -318,6 +351,27 @@ export type CanUseToolResponse = | CanUseToolResponseAllow | CanUseToolResponseDeny; +/** + * External tool result content block (matches SDK AgentToolResultContent) + */ +export interface ExternalToolResultContent { + type: "text" | "image"; + text?: string; + data?: string; // base64 for images + mimeType?: string; +} + +/** + * External tool result response (SDK → CLI) + */ +export interface ExternalToolResultResponse { + subtype: "external_tool_result"; + request_id: string; + tool_call_id: string; + content: ExternalToolResultContent[]; + is_error: boolean; +} + // ═══════════════════════════════════════════════════════════════ // USER INPUT // ═══════════════════════════════════════════════════════════════