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:
@@ -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",
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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<string, unknown>; // 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<string, unknown>;
|
||||
}
|
||||
|
||||
export type CliToSdkControlRequest =
|
||||
| CanUseToolControlRequest
|
||||
| ExecuteExternalToolRequest;
|
||||
|
||||
// Combined for parsing
|
||||
export type ControlRequestBody =
|
||||
@@ -296,7 +328,8 @@ export type ControlResponseBody =
|
||||
request_id: string;
|
||||
response?: CanUseToolResponse | Record<string, unknown>;
|
||||
}
|
||||
| { 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
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
Reference in New Issue
Block a user