Add custom tools support (CLI side) (#733)

Co-authored-by: Letta <noreply@letta.com>
Co-authored-by: letta-code <248085862+letta-code@users.noreply.github.com>
Co-authored-by: Christina Tong <christinatong01@users.noreply.github.com>
This commit is contained in:
Sarah Wooders
2026-02-10 11:56:47 -08:00
committed by GitHub
parent d78ff62bc5
commit 79ab473090
3 changed files with 269 additions and 4 deletions

View File

@@ -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<string, unknown>; // JSON Schema
}
/**
* Callback to execute an external tool via SDK
*/
export type ExternalToolExecutor = (
toolCallId: string,
toolName: string,
input: Record<string, unknown>,
) => 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<string, ExternalToolDefinition>;
[EXTERNAL_EXECUTOR_KEY]?: ExternalToolExecutor;
};
function getExternalToolsRegistry(): Map<string, ExternalToolDefinition> {
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<string, unknown>,
): Promise<ToolExecutionResult> {
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<ToolExecutionResult> {
// Check if this is an external tool (SDK-executed)
if (isExternalTool(name)) {
return executeExternalTool(
options?.toolCallId ?? `ext-${Date.now()}`,
name,
args as Record<string, unknown>,
);
}
const internalName = resolveInternalToolName(name);
if (!internalName) {
return {