1488 lines
44 KiB
TypeScript
1488 lines
44 KiB
TypeScript
import { getDisplayableToolReturn } from "../agent/approval-execution";
|
|
import { getModelInfo } from "../agent/model";
|
|
import { getAllSubagentConfigs } from "../agent/subagents";
|
|
import { INTERRUPTED_BY_USER } from "../constants";
|
|
import {
|
|
runPostToolUseFailureHooks,
|
|
runPostToolUseHooks,
|
|
runPreToolUseHooks,
|
|
} from "../hooks";
|
|
import { OPENAI_CODEX_PROVIDER_NAME } from "../providers/openai-codex-provider";
|
|
import { telemetry } from "../telemetry";
|
|
import { debugLog } from "../utils/debug";
|
|
import { TOOL_DEFINITIONS, type ToolName } from "./toolDefinitions";
|
|
|
|
export const TOOL_NAMES = Object.keys(TOOL_DEFINITIONS) as ToolName[];
|
|
const STREAMING_SHELL_TOOLS = new Set([
|
|
"Bash",
|
|
"BashOutput",
|
|
"TaskOutput",
|
|
"shell_command",
|
|
"ShellCommand",
|
|
"shell",
|
|
"Shell",
|
|
"run_shell_command",
|
|
"RunShellCommand",
|
|
]);
|
|
|
|
// Maps internal tool names to server/model-facing tool names
|
|
// This allows us to have multiple implementations (e.g., write_file_gemini, Write from Anthropic)
|
|
// that map to the same server tool name since only one toolset is active at a time
|
|
const TOOL_NAME_MAPPINGS: Partial<Record<ToolName, string>> = {
|
|
// Gemini tools - map to their original Gemini CLI names
|
|
glob_gemini: "glob",
|
|
write_todos: "write_todos",
|
|
write_file_gemini: "write_file",
|
|
replace: "replace",
|
|
search_file_content: "search_file_content",
|
|
read_many_files: "read_many_files",
|
|
read_file_gemini: "read_file",
|
|
list_directory: "list_directory",
|
|
run_shell_command: "run_shell_command",
|
|
};
|
|
|
|
/**
|
|
* Get the server-facing name for a tool (maps internal names to what the model sees)
|
|
*/
|
|
export function getServerToolName(internalName: string): string {
|
|
return TOOL_NAME_MAPPINGS[internalName as ToolName] || internalName;
|
|
}
|
|
|
|
/**
|
|
* Get the internal tool name from a server-facing name
|
|
* Used when the server sends back tool calls/approvals with server names
|
|
*/
|
|
export function getInternalToolName(serverName: string): string {
|
|
// Build reverse mapping
|
|
for (const [internal, server] of Object.entries(TOOL_NAME_MAPPINGS)) {
|
|
if (server === serverName) {
|
|
return internal;
|
|
}
|
|
}
|
|
// If not in mapping, the server name is the internal name
|
|
return serverName;
|
|
}
|
|
|
|
export const ANTHROPIC_DEFAULT_TOOLS: ToolName[] = [
|
|
"AskUserQuestion",
|
|
"Bash",
|
|
"TaskOutput",
|
|
"Edit",
|
|
"EnterPlanMode",
|
|
"ExitPlanMode",
|
|
"Glob",
|
|
"Grep",
|
|
"TaskStop",
|
|
// "MultiEdit",
|
|
// "LS",
|
|
"Read",
|
|
"Skill",
|
|
"Task",
|
|
"TodoWrite",
|
|
"Write",
|
|
];
|
|
|
|
export const OPENAI_DEFAULT_TOOLS: ToolName[] = [
|
|
"shell_command",
|
|
// TODO(codex-parity): add once request_user_input tool exists in raw codex path.
|
|
// "request_user_input",
|
|
"apply_patch",
|
|
"update_plan",
|
|
"view_image",
|
|
];
|
|
|
|
export const GEMINI_DEFAULT_TOOLS: ToolName[] = [
|
|
"run_shell_command",
|
|
"read_file_gemini",
|
|
"list_directory",
|
|
"glob_gemini",
|
|
"search_file_content",
|
|
"replace",
|
|
"write_file_gemini",
|
|
"write_todos",
|
|
"read_many_files",
|
|
"Skill",
|
|
"Task",
|
|
];
|
|
|
|
// PascalCase toolsets (codex-2 and gemini-2) for consistency with Skill tool naming
|
|
export const OPENAI_PASCAL_TOOLS: ToolName[] = [
|
|
// Additional Letta Code tools
|
|
"AskUserQuestion",
|
|
"EnterPlanMode",
|
|
"ExitPlanMode",
|
|
"Task",
|
|
"TaskOutput",
|
|
"TaskStop",
|
|
"Skill",
|
|
// Standard Codex tools
|
|
"ShellCommand",
|
|
"ViewImage",
|
|
"ApplyPatch",
|
|
"UpdatePlan",
|
|
];
|
|
|
|
export const GEMINI_PASCAL_TOOLS: ToolName[] = [
|
|
// Additional Letta Code tools
|
|
"AskUserQuestion",
|
|
"EnterPlanMode",
|
|
"ExitPlanMode",
|
|
"Skill",
|
|
"Task",
|
|
// Standard Gemini tools
|
|
"RunShellCommand",
|
|
"ReadFileGemini",
|
|
"ListDirectory",
|
|
"GlobGemini",
|
|
"SearchFileContent",
|
|
"Replace",
|
|
"WriteFileGemini",
|
|
"WriteTodos",
|
|
"ReadManyFiles",
|
|
];
|
|
|
|
// Tool permissions configuration
|
|
const TOOL_PERMISSIONS: Record<ToolName, { requiresApproval: boolean }> = {
|
|
AskUserQuestion: { requiresApproval: true },
|
|
Bash: { requiresApproval: true },
|
|
BashOutput: { requiresApproval: false },
|
|
TaskOutput: { requiresApproval: false },
|
|
Edit: { requiresApproval: true },
|
|
EnterPlanMode: { requiresApproval: true },
|
|
ExitPlanMode: { requiresApproval: false },
|
|
Glob: { requiresApproval: false },
|
|
Grep: { requiresApproval: false },
|
|
KillBash: { requiresApproval: true },
|
|
TaskStop: { requiresApproval: true },
|
|
LS: { requiresApproval: false },
|
|
MultiEdit: { requiresApproval: true },
|
|
Read: { requiresApproval: false },
|
|
view_image: { requiresApproval: false },
|
|
ViewImage: { requiresApproval: false },
|
|
ReadLSP: { requiresApproval: false },
|
|
Skill: { requiresApproval: false },
|
|
Task: { requiresApproval: true },
|
|
TodoWrite: { requiresApproval: false },
|
|
Write: { requiresApproval: true },
|
|
shell_command: { requiresApproval: true },
|
|
shell: { requiresApproval: true },
|
|
read_file: { requiresApproval: false },
|
|
list_dir: { requiresApproval: false },
|
|
grep_files: { requiresApproval: false },
|
|
apply_patch: { requiresApproval: true },
|
|
update_plan: { requiresApproval: false },
|
|
// Gemini toolset
|
|
glob_gemini: { requiresApproval: false },
|
|
list_directory: { requiresApproval: false },
|
|
read_file_gemini: { requiresApproval: false },
|
|
read_many_files: { requiresApproval: false },
|
|
replace: { requiresApproval: true },
|
|
run_shell_command: { requiresApproval: true },
|
|
search_file_content: { requiresApproval: false },
|
|
write_todos: { requiresApproval: false },
|
|
write_file_gemini: { requiresApproval: true },
|
|
// Codex-2 toolset (PascalCase)
|
|
ShellCommand: { requiresApproval: true },
|
|
Shell: { requiresApproval: true },
|
|
ReadFile: { requiresApproval: false },
|
|
ListDir: { requiresApproval: false },
|
|
GrepFiles: { requiresApproval: false },
|
|
ApplyPatch: { requiresApproval: true },
|
|
UpdatePlan: { requiresApproval: false },
|
|
// Gemini-2 toolset (PascalCase)
|
|
RunShellCommand: { requiresApproval: true },
|
|
ReadFileGemini: { requiresApproval: false },
|
|
ListDirectory: { requiresApproval: false },
|
|
GlobGemini: { requiresApproval: false },
|
|
SearchFileContent: { requiresApproval: false },
|
|
Replace: { requiresApproval: true },
|
|
WriteFileGemini: { requiresApproval: true },
|
|
WriteTodos: { requiresApproval: false },
|
|
ReadManyFiles: { requiresApproval: false },
|
|
};
|
|
|
|
interface JsonSchema {
|
|
properties?: Record<string, JsonSchema>;
|
|
required?: string[];
|
|
[key: string]: unknown;
|
|
}
|
|
|
|
type ToolArgs = Record<string, unknown>;
|
|
|
|
interface ToolSchema {
|
|
name: string;
|
|
description: string;
|
|
input_schema: JsonSchema;
|
|
}
|
|
|
|
interface ToolDefinition {
|
|
schema: ToolSchema;
|
|
fn: (args: ToolArgs) => Promise<unknown>;
|
|
}
|
|
|
|
import type {
|
|
ImageContent,
|
|
TextContent,
|
|
} from "@letta-ai/letta-client/resources/agents/messages";
|
|
|
|
// Tool return content can be a string or array of text/image content parts
|
|
export type ToolReturnContent = string | Array<TextContent | ImageContent>;
|
|
|
|
export type ToolExecutionResult = {
|
|
toolReturn: ToolReturnContent;
|
|
status: "success" | "error";
|
|
stdout?: string[];
|
|
stderr?: string[];
|
|
};
|
|
|
|
type ToolRegistry = Map<string, ToolDefinition>;
|
|
|
|
// Use globalThis to ensure singleton across bundle duplicates
|
|
// This prevents Bun's bundler from creating duplicate instances
|
|
const REGISTRY_KEY = Symbol.for("@letta/toolRegistry");
|
|
const SWITCH_LOCK_KEY = Symbol.for("@letta/toolSwitchLock");
|
|
const EXECUTION_CONTEXTS_KEY = Symbol.for("@letta/toolExecutionContexts");
|
|
|
|
interface SwitchLockState {
|
|
promise: Promise<void> | null;
|
|
resolve: (() => void) | null;
|
|
refCount: number; // Ref-counted to handle overlapping switches
|
|
}
|
|
|
|
type GlobalWithToolState = typeof globalThis & {
|
|
[REGISTRY_KEY]?: ToolRegistry;
|
|
[SWITCH_LOCK_KEY]?: SwitchLockState;
|
|
[EXECUTION_CONTEXTS_KEY]?: Map<string, ToolExecutionContextSnapshot>;
|
|
};
|
|
|
|
function getRegistry(): ToolRegistry {
|
|
const global = globalThis as GlobalWithToolState;
|
|
if (!global[REGISTRY_KEY]) {
|
|
global[REGISTRY_KEY] = new Map();
|
|
}
|
|
return global[REGISTRY_KEY];
|
|
}
|
|
|
|
function getSwitchLock(): SwitchLockState {
|
|
const global = globalThis as GlobalWithToolState;
|
|
if (!global[SWITCH_LOCK_KEY]) {
|
|
global[SWITCH_LOCK_KEY] = { promise: null, resolve: null, refCount: 0 };
|
|
}
|
|
return global[SWITCH_LOCK_KEY];
|
|
}
|
|
|
|
const toolRegistry = getRegistry();
|
|
let toolExecutionContextCounter = 0;
|
|
|
|
type ToolExecutionContextSnapshot = {
|
|
toolRegistry: ToolRegistry;
|
|
externalTools: Map<string, ExternalToolDefinition>;
|
|
externalExecutor?: ExternalToolExecutor;
|
|
};
|
|
|
|
export type CapturedToolExecutionContext = {
|
|
contextId: string;
|
|
clientTools: ClientTool[];
|
|
};
|
|
|
|
function getExecutionContexts(): Map<string, ToolExecutionContextSnapshot> {
|
|
const global = globalThis as GlobalWithToolState;
|
|
if (!global[EXECUTION_CONTEXTS_KEY]) {
|
|
global[EXECUTION_CONTEXTS_KEY] = new Map();
|
|
}
|
|
return global[EXECUTION_CONTEXTS_KEY];
|
|
}
|
|
|
|
function saveExecutionContext(snapshot: ToolExecutionContextSnapshot): string {
|
|
const contexts = getExecutionContexts();
|
|
const contextId = `ctx-${Date.now()}-${toolExecutionContextCounter++}`;
|
|
contexts.set(contextId, snapshot);
|
|
|
|
// Keep memory bounded; stale turns won't need old snapshots.
|
|
const MAX_CONTEXTS = 4096;
|
|
if (contexts.size > MAX_CONTEXTS) {
|
|
const oldestContextId = contexts.keys().next().value;
|
|
if (oldestContextId) {
|
|
contexts.delete(oldestContextId);
|
|
}
|
|
}
|
|
|
|
return contextId;
|
|
}
|
|
|
|
function getExecutionContextById(
|
|
contextId: string,
|
|
): ToolExecutionContextSnapshot | undefined {
|
|
return getExecutionContexts().get(contextId);
|
|
}
|
|
|
|
export function clearCapturedToolExecutionContexts(): void {
|
|
getExecutionContexts().clear();
|
|
}
|
|
|
|
export function releaseToolExecutionContext(contextId: string): void {
|
|
getExecutionContexts().delete(contextId);
|
|
}
|
|
|
|
/**
|
|
* Acquires the toolset switch lock. Call before starting async tool loading.
|
|
* Ref-counted: multiple overlapping switches will keep the lock held until all complete.
|
|
* Any calls to waitForToolsetReady() will block until all switches finish.
|
|
*/
|
|
function acquireSwitchLock(): void {
|
|
const lock = getSwitchLock();
|
|
lock.refCount++;
|
|
|
|
// Only create a new promise if this is the first acquirer
|
|
if (lock.refCount === 1) {
|
|
lock.promise = new Promise((resolve) => {
|
|
lock.resolve = resolve;
|
|
});
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Releases the toolset switch lock. Call after atomic registry swap completes.
|
|
* Only actually releases when all acquirers have released (ref-count drops to 0).
|
|
*/
|
|
function releaseSwitchLock(): void {
|
|
const lock = getSwitchLock();
|
|
|
|
if (lock.refCount > 0) {
|
|
lock.refCount--;
|
|
}
|
|
|
|
// Only resolve when all switches are done
|
|
if (lock.refCount === 0 && lock.resolve) {
|
|
lock.resolve();
|
|
lock.promise = null;
|
|
lock.resolve = null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Waits for any in-progress toolset switch to complete.
|
|
* Call this before reading from the registry to ensure you get the final toolset.
|
|
* Returns immediately if no switch is in progress.
|
|
*/
|
|
export async function waitForToolsetReady(): Promise<void> {
|
|
const lock = getSwitchLock();
|
|
if (lock.promise) {
|
|
await lock.promise;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Checks if a toolset switch is currently in progress.
|
|
* Useful for synchronous checks where awaiting isn't possible.
|
|
*/
|
|
export function isToolsetSwitchInProgress(): boolean {
|
|
return getSwitchLock().refCount > 0;
|
|
}
|
|
|
|
/**
|
|
* Resolve a server/visible tool name to an internal tool name
|
|
* based on the currently loaded toolset.
|
|
*
|
|
* - If a tool with the exact name is loaded, prefer that.
|
|
* - Otherwise, fall back to the alias mapping used for Gemini tools.
|
|
* - Returns undefined if no matching tool is loaded.
|
|
*/
|
|
function resolveInternalToolName(
|
|
name: string,
|
|
registry: ToolRegistry = toolRegistry,
|
|
): string | undefined {
|
|
if (registry.has(name)) {
|
|
return name;
|
|
}
|
|
|
|
const internalName = getInternalToolName(name);
|
|
if (registry.has(internalName)) {
|
|
return internalName;
|
|
}
|
|
|
|
return undefined;
|
|
}
|
|
|
|
/**
|
|
* ClientTool interface matching the Letta SDK's expected format.
|
|
* Used when passing client-side tools via the client_tools field.
|
|
*/
|
|
export interface ClientTool {
|
|
name: string;
|
|
description?: string | null;
|
|
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;
|
|
}
|
|
|
|
function getExternalToolExecutor(): ExternalToolExecutor | undefined {
|
|
return (globalThis as GlobalWithExternalTools)[EXTERNAL_EXECUTOR_KEY];
|
|
}
|
|
|
|
/**
|
|
* 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>,
|
|
executorOverride?: ExternalToolExecutor,
|
|
): Promise<ToolExecutionResult> {
|
|
const executor = executorOverride ?? getExternalToolExecutor();
|
|
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[] {
|
|
// Get built-in tools
|
|
const builtInTools = Array.from(toolRegistry.entries()).map(
|
|
([name, tool]) => {
|
|
const serverName = getServerToolName(name);
|
|
return {
|
|
name: serverName,
|
|
description: tool.schema.description,
|
|
parameters: tool.schema.input_schema,
|
|
};
|
|
},
|
|
);
|
|
|
|
// Add external tools
|
|
const externalTools = getExternalToolsAsClientTools();
|
|
|
|
return [...builtInTools, ...externalTools];
|
|
}
|
|
|
|
/**
|
|
* Capture a turn-scoped tool snapshot and corresponding client_tools payload.
|
|
* The returned context id can be used later to execute tool calls against this
|
|
* exact snapshot even if the global registry changes between dispatch and execute.
|
|
*/
|
|
export function captureToolExecutionContext(): CapturedToolExecutionContext {
|
|
const snapshot: ToolExecutionContextSnapshot = {
|
|
toolRegistry: new Map(toolRegistry),
|
|
externalTools: new Map(getExternalToolsRegistry()),
|
|
externalExecutor: getExternalToolExecutor(),
|
|
};
|
|
const contextId = saveExecutionContext(snapshot);
|
|
|
|
const builtInTools = Array.from(snapshot.toolRegistry.entries()).map(
|
|
([name, tool]) => ({
|
|
name: getServerToolName(name),
|
|
description: tool.schema.description,
|
|
parameters: tool.schema.input_schema,
|
|
}),
|
|
);
|
|
const externalTools = Array.from(snapshot.externalTools.values()).map(
|
|
(tool) => ({
|
|
name: tool.name,
|
|
description: tool.description,
|
|
parameters: tool.parameters,
|
|
}),
|
|
);
|
|
|
|
return {
|
|
contextId,
|
|
clientTools: [...builtInTools, ...externalTools],
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Get permissions for a specific tool.
|
|
* @param toolName - The name of the tool
|
|
* @returns Tool permissions object with requiresApproval flag
|
|
*/
|
|
export function getToolPermissions(toolName: string) {
|
|
return TOOL_PERMISSIONS[toolName as ToolName] || { requiresApproval: false };
|
|
}
|
|
|
|
/**
|
|
* Check if a tool requires approval before execution.
|
|
* @param toolName - The name of the tool
|
|
* @returns true if the tool requires approval, false otherwise
|
|
* @deprecated Use checkToolPermission instead for full permission system support
|
|
*/
|
|
export function requiresApproval(toolName: string): boolean {
|
|
return TOOL_PERMISSIONS[toolName as ToolName]?.requiresApproval ?? false;
|
|
}
|
|
|
|
/**
|
|
* Check permission for a tool execution using the full permission system.
|
|
* @param toolName - Name of the tool
|
|
* @param toolArgs - Tool arguments
|
|
* @param workingDirectory - Current working directory (defaults to process.cwd())
|
|
* @returns Permission decision: "allow", "deny", or "ask"
|
|
*/
|
|
export async function checkToolPermission(
|
|
toolName: string,
|
|
toolArgs: ToolArgs,
|
|
workingDirectory: string = process.cwd(),
|
|
): Promise<{
|
|
decision: "allow" | "deny" | "ask";
|
|
matchedRule?: string;
|
|
reason?: string;
|
|
}> {
|
|
const { checkPermissionWithHooks } = await import("../permissions/checker");
|
|
const { loadPermissions } = await import("../permissions/loader");
|
|
|
|
const permissions = await loadPermissions(workingDirectory);
|
|
return checkPermissionWithHooks(
|
|
toolName,
|
|
toolArgs,
|
|
permissions,
|
|
workingDirectory,
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Save a permission rule to settings
|
|
* @param rule - Permission rule (e.g., "Read(src/**)")
|
|
* @param ruleType - Type of rule ("allow", "deny", or "ask")
|
|
* @param scope - Where to save ("project", "local", "user", or "session")
|
|
* @param workingDirectory - Current working directory
|
|
*/
|
|
export async function savePermissionRule(
|
|
rule: string,
|
|
ruleType: "allow" | "deny" | "ask",
|
|
scope: "project" | "local" | "user" | "session",
|
|
workingDirectory: string = process.cwd(),
|
|
): Promise<void> {
|
|
// Handle session-only permissions
|
|
if (scope === "session") {
|
|
const { sessionPermissions } = await import("../permissions/session");
|
|
sessionPermissions.addRule(rule, ruleType);
|
|
return;
|
|
}
|
|
|
|
// Handle persisted permissions
|
|
const { savePermissionRule: save } = await import("../permissions/loader");
|
|
await save(rule, ruleType, scope, workingDirectory);
|
|
}
|
|
|
|
/**
|
|
* Analyze approval context for a tool execution
|
|
* @param toolName - Name of the tool
|
|
* @param toolArgs - Tool arguments
|
|
* @param workingDirectory - Current working directory
|
|
* @returns Approval context with recommended rule and button text
|
|
*/
|
|
export async function analyzeToolApproval(
|
|
toolName: string,
|
|
toolArgs: ToolArgs,
|
|
workingDirectory: string = process.cwd(),
|
|
): Promise<import("../permissions/analyzer").ApprovalContext> {
|
|
const { analyzeApprovalContext } = await import("../permissions/analyzer");
|
|
return analyzeApprovalContext(toolName, toolArgs, workingDirectory);
|
|
}
|
|
|
|
/**
|
|
* Atomically replaces the tool registry contents.
|
|
* This ensures no intermediate state where registry is empty or partial.
|
|
*
|
|
* @param newTools - Map of tools to replace the registry with
|
|
*/
|
|
function replaceRegistry(newTools: ToolRegistry): void {
|
|
// Single sync block - no awaits, no yields, no interleaving possible
|
|
toolRegistry.clear();
|
|
for (const [key, value] of newTools) {
|
|
toolRegistry.set(key, value);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Loads specific tools by name into the registry.
|
|
* Used when resuming an agent to load only the tools attached to that agent.
|
|
*
|
|
* Acquires the toolset switch lock during loading to prevent message sends from
|
|
* reading stale tools. Callers should use waitForToolsetReady() before sending messages.
|
|
*
|
|
* @param toolNames - Array of specific tool names to load
|
|
*/
|
|
export async function loadSpecificTools(toolNames: string[]): Promise<void> {
|
|
// Acquire lock to signal that a switch is in progress
|
|
acquireSwitchLock();
|
|
|
|
try {
|
|
// Import filter once, outside the loop (avoids repeated async yields)
|
|
const { toolFilter } = await import("./filter");
|
|
|
|
// Build new registry in a temporary map (all async work happens here)
|
|
const newRegistry: ToolRegistry = new Map();
|
|
|
|
for (const name of toolNames) {
|
|
// Skip if tool filter is active and this tool is not enabled
|
|
if (!toolFilter.isEnabled(name)) {
|
|
continue;
|
|
}
|
|
|
|
// Map server-facing name to our internal tool name
|
|
const internalName = getInternalToolName(name);
|
|
|
|
const definition = TOOL_DEFINITIONS[internalName as ToolName];
|
|
if (!definition) {
|
|
console.warn(
|
|
`Tool ${name} (internal: ${internalName}) not found in definitions, skipping`,
|
|
);
|
|
continue;
|
|
}
|
|
|
|
if (!definition.impl) {
|
|
throw new Error(`Tool implementation not found for ${internalName}`);
|
|
}
|
|
|
|
const toolSchema: ToolSchema = {
|
|
name: internalName,
|
|
description: definition.description,
|
|
input_schema: definition.schema,
|
|
};
|
|
|
|
// Add to temporary registry
|
|
newRegistry.set(internalName, {
|
|
schema: toolSchema,
|
|
fn: definition.impl,
|
|
});
|
|
}
|
|
|
|
// Atomic swap - no yields between clear and populate
|
|
replaceRegistry(newRegistry);
|
|
} finally {
|
|
// Always release the lock, even if an error occurred
|
|
releaseSwitchLock();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Loads all tools defined in TOOL_NAMES and constructs their full schemas + function references.
|
|
* This should be called on program startup.
|
|
* Will error if any expected tool files are missing.
|
|
*
|
|
* Acquires the toolset switch lock during loading to prevent message sends from
|
|
* reading stale tools. Callers should use waitForToolsetReady() before sending messages.
|
|
*
|
|
* @param modelIdentifier - Optional model identifier to select the appropriate toolset
|
|
* @param options - Optional configuration
|
|
* @param options.exclude - Tool names to exclude from the loaded toolset
|
|
* @returns Promise that resolves when all tools are loaded
|
|
*/
|
|
export async function loadTools(
|
|
modelIdentifier?: string,
|
|
options?: { exclude?: ToolName[] },
|
|
): Promise<void> {
|
|
// Acquire lock to signal that a switch is in progress
|
|
acquireSwitchLock();
|
|
|
|
try {
|
|
const { toolFilter } = await import("./filter");
|
|
|
|
// Get all subagents (built-in + custom) to inject into Task description
|
|
const allSubagentConfigs = await getAllSubagentConfigs();
|
|
const discoveredSubagents = Object.entries(allSubagentConfigs).map(
|
|
([name, config]) => ({
|
|
name,
|
|
description: config.description,
|
|
recommendedModel: config.recommendedModel,
|
|
}),
|
|
);
|
|
const filterActive = toolFilter.isActive();
|
|
|
|
let baseToolNames: ToolName[];
|
|
if (!filterActive && modelIdentifier && isGeminiModel(modelIdentifier)) {
|
|
baseToolNames = GEMINI_PASCAL_TOOLS;
|
|
} else if (
|
|
!filterActive &&
|
|
modelIdentifier &&
|
|
isOpenAIModel(modelIdentifier)
|
|
) {
|
|
baseToolNames = OPENAI_PASCAL_TOOLS;
|
|
} else if (!filterActive) {
|
|
baseToolNames = ANTHROPIC_DEFAULT_TOOLS;
|
|
} else {
|
|
// When user explicitly sets --tools, respect that and allow any tool name
|
|
baseToolNames = TOOL_NAMES;
|
|
}
|
|
|
|
// Apply exclusions (e.g. remove interactive-only tools in headless mode)
|
|
if (options?.exclude && options.exclude.length > 0) {
|
|
const excludeSet = new Set(options.exclude);
|
|
baseToolNames = baseToolNames.filter((name) => !excludeSet.has(name));
|
|
}
|
|
|
|
// Build new registry in a temporary map (all async work happens above)
|
|
const newRegistry: ToolRegistry = new Map();
|
|
|
|
for (const name of baseToolNames) {
|
|
if (!toolFilter.isEnabled(name)) {
|
|
continue;
|
|
}
|
|
|
|
try {
|
|
const definition = TOOL_DEFINITIONS[name];
|
|
if (!definition) {
|
|
throw new Error(`Missing tool definition for ${name}`);
|
|
}
|
|
|
|
if (!definition.impl) {
|
|
throw new Error(`Tool implementation not found for ${name}`);
|
|
}
|
|
|
|
// For Task tool, inject discovered subagent descriptions
|
|
let description = definition.description;
|
|
if (name === "Task" && discoveredSubagents.length > 0) {
|
|
description = injectSubagentsIntoTaskDescription(
|
|
description,
|
|
discoveredSubagents,
|
|
);
|
|
}
|
|
|
|
const toolSchema: ToolSchema = {
|
|
name,
|
|
description,
|
|
input_schema: definition.schema,
|
|
};
|
|
|
|
newRegistry.set(name, {
|
|
schema: toolSchema,
|
|
fn: definition.impl,
|
|
});
|
|
} catch (error) {
|
|
const message =
|
|
error instanceof Error ? error.message : JSON.stringify(error);
|
|
throw new Error(
|
|
`Required tool "${name}" could not be loaded from bundled assets. ${message}`,
|
|
);
|
|
}
|
|
}
|
|
|
|
// If LSP is enabled, swap Read with LSP-enhanced version
|
|
if (process.env.LETTA_ENABLE_LSP && newRegistry.has("Read")) {
|
|
const lspDefinition = TOOL_DEFINITIONS.ReadLSP;
|
|
if (lspDefinition) {
|
|
// Replace Read with ReadLSP (but keep the name "Read" for the agent)
|
|
newRegistry.set("Read", {
|
|
schema: {
|
|
name: "Read", // Keep the tool name as "Read" for the agent
|
|
description: lspDefinition.description,
|
|
input_schema: lspDefinition.schema,
|
|
},
|
|
fn: lspDefinition.impl,
|
|
});
|
|
}
|
|
}
|
|
|
|
// Atomic swap - no yields between clear and populate
|
|
replaceRegistry(newRegistry);
|
|
} finally {
|
|
// Always release the lock, even if an error occurred
|
|
releaseSwitchLock();
|
|
}
|
|
}
|
|
|
|
export function isOpenAIModel(modelIdentifier: string): boolean {
|
|
const info = getModelInfo(modelIdentifier);
|
|
if (info?.handle && typeof info.handle === "string") {
|
|
return (
|
|
info.handle.startsWith("openai/") ||
|
|
info.handle.startsWith(`${OPENAI_CODEX_PROVIDER_NAME}/`) ||
|
|
info.handle.startsWith("chatgpt_oauth/")
|
|
);
|
|
}
|
|
// Fallback: treat raw handle-style identifiers as OpenAI for openai/*
|
|
// and ChatGPT OAuth Codex provider handles.
|
|
return (
|
|
modelIdentifier.startsWith("openai/") ||
|
|
modelIdentifier.startsWith(`${OPENAI_CODEX_PROVIDER_NAME}/`) ||
|
|
modelIdentifier.startsWith("chatgpt_oauth/")
|
|
);
|
|
}
|
|
|
|
export function isGeminiModel(modelIdentifier: string): boolean {
|
|
const info = getModelInfo(modelIdentifier);
|
|
if (info?.handle && typeof info.handle === "string") {
|
|
return (
|
|
info.handle.startsWith("google/") || info.handle.startsWith("google_ai/")
|
|
);
|
|
}
|
|
// Fallback: treat raw handle-style identifiers as Gemini
|
|
return (
|
|
modelIdentifier.startsWith("google/") ||
|
|
modelIdentifier.startsWith("google_ai/")
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Inject discovered subagent descriptions into the Task tool description
|
|
*/
|
|
function injectSubagentsIntoTaskDescription(
|
|
baseDescription: string,
|
|
subagents: Array<{
|
|
name: string;
|
|
description: string;
|
|
recommendedModel: string;
|
|
}>,
|
|
): string {
|
|
if (subagents.length === 0) {
|
|
return baseDescription;
|
|
}
|
|
|
|
// Build subagents section
|
|
const agentsSection = subagents
|
|
.map((agent) => {
|
|
return `### ${agent.name}
|
|
- **Purpose**: ${agent.description}
|
|
- **Recommended model**: ${agent.recommendedModel}`;
|
|
})
|
|
.join("\n\n");
|
|
|
|
// Insert before ## Usage section
|
|
const usageMarker = "## Usage";
|
|
const usageIndex = baseDescription.indexOf(usageMarker);
|
|
|
|
if (usageIndex === -1) {
|
|
// Fallback: append at the end
|
|
return `${baseDescription}\n\n## Available Agents\n\n${agentsSection}`;
|
|
}
|
|
|
|
// Insert agents section before ## Usage
|
|
const before = baseDescription.slice(0, usageIndex);
|
|
const after = baseDescription.slice(usageIndex);
|
|
|
|
return `${before}## Available Agents\n\n${agentsSection}\n\n${after}`;
|
|
}
|
|
|
|
/**
|
|
* Helper to clip tool return text to a reasonable display size
|
|
* Used by UI components to truncate long responses for display
|
|
*/
|
|
export function clipToolReturn(
|
|
text: string,
|
|
maxLines: number = 3,
|
|
maxChars: number = 300,
|
|
): string {
|
|
if (!text) return text;
|
|
|
|
// Don't clip user rejection reasons - they contain important feedback
|
|
// All denials use format: "Error: request to call tool denied. User reason: ..."
|
|
if (text.includes("request to call tool denied")) {
|
|
return text;
|
|
}
|
|
|
|
// First apply character limit to avoid extremely long text
|
|
let clipped = text;
|
|
if (text.length > maxChars) {
|
|
clipped = text.slice(0, maxChars);
|
|
}
|
|
|
|
// Then split into lines and limit line count
|
|
const lines = clipped.split("\n");
|
|
if (lines.length > maxLines) {
|
|
clipped = lines.slice(0, maxLines).join("\n");
|
|
}
|
|
|
|
// Add ellipsis if we truncated
|
|
if (text.length > maxChars || lines.length > maxLines) {
|
|
// Try to break at a word boundary if possible
|
|
const lastSpace = clipped.lastIndexOf(" ");
|
|
if (lastSpace > maxChars * 0.8) {
|
|
clipped = clipped.slice(0, lastSpace);
|
|
}
|
|
clipped += "…";
|
|
}
|
|
|
|
return clipped;
|
|
}
|
|
|
|
/**
|
|
* Flattens a tool response to a simple string format.
|
|
* Extracts the actual content from structured responses to match what the LLM expects.
|
|
*
|
|
* @param result - The raw result from a tool execution
|
|
* @returns A flattened string representation of the result
|
|
*/
|
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
return typeof value === "object" && value !== null;
|
|
}
|
|
|
|
function isStringArray(value: unknown): value is string[] {
|
|
return (
|
|
Array.isArray(value) && value.every((item) => typeof item === "string")
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Check if an array contains multimodal content (text + images)
|
|
*/
|
|
function isMultimodalContent(
|
|
arr: unknown[],
|
|
): arr is Array<TextContent | ImageContent> {
|
|
return arr.every(
|
|
(item) => isRecord(item) && (item.type === "text" || item.type === "image"),
|
|
);
|
|
}
|
|
|
|
function flattenToolResponse(result: unknown): ToolReturnContent {
|
|
if (result === null || result === undefined) {
|
|
return "";
|
|
}
|
|
|
|
if (typeof result === "string") {
|
|
return result;
|
|
}
|
|
|
|
if (!isRecord(result)) {
|
|
return JSON.stringify(result);
|
|
}
|
|
|
|
if (typeof result.message === "string") {
|
|
// If there are other fields besides 'message', return the full object as JSON
|
|
|
|
const keys = Object.keys(result);
|
|
if (keys.length > 1) {
|
|
return JSON.stringify(result);
|
|
}
|
|
return result.message;
|
|
}
|
|
|
|
// Check for multimodal content (images) - return as-is without flattening
|
|
if (Array.isArray(result.content) && isMultimodalContent(result.content)) {
|
|
return result.content;
|
|
}
|
|
|
|
if (typeof result.content === "string") {
|
|
return result.content;
|
|
}
|
|
|
|
if (Array.isArray(result.content)) {
|
|
const textContent = result.content
|
|
.filter(
|
|
(item): item is { type: string; text: string } =>
|
|
isRecord(item) &&
|
|
item.type === "text" &&
|
|
typeof item.text === "string",
|
|
)
|
|
.map((item) => item.text)
|
|
.join("\n");
|
|
|
|
if (textContent) {
|
|
return textContent;
|
|
}
|
|
}
|
|
|
|
if (typeof result.output === "string") {
|
|
return result.output;
|
|
}
|
|
|
|
if (Array.isArray(result.files)) {
|
|
const files = result.files.filter(
|
|
(file): file is string => typeof file === "string",
|
|
);
|
|
if (files.length === 0) {
|
|
return "No files found";
|
|
}
|
|
return `Found ${files.length} file${files.length === 1 ? "" : "s"}\n${files.join("\n")}`;
|
|
}
|
|
|
|
if (typeof result.killed === "boolean") {
|
|
return result.killed
|
|
? "Process killed successfully"
|
|
: "Failed to kill process (may have already exited)";
|
|
}
|
|
|
|
if (typeof result.error === "string") {
|
|
return result.error;
|
|
}
|
|
|
|
if (Array.isArray(result.todos)) {
|
|
return `Updated ${result.todos.length} todo${result.todos.length !== 1 ? "s" : ""}`;
|
|
}
|
|
|
|
return JSON.stringify(result);
|
|
}
|
|
|
|
/**
|
|
* Executes a tool by name with the provided arguments.
|
|
*
|
|
* @param name - The name of the tool to execute
|
|
* @param args - Arguments object to pass to the tool
|
|
* @param options - Optional execution options (abort signal, tool call ID, streaming callback)
|
|
* @returns Promise with the tool's execution result including status and optional stdout/stderr
|
|
*/
|
|
export async function executeTool(
|
|
name: string,
|
|
args: ToolArgs,
|
|
options?: {
|
|
signal?: AbortSignal;
|
|
toolCallId?: string;
|
|
onOutput?: (chunk: string, stream: "stdout" | "stderr") => void;
|
|
toolContextId?: string;
|
|
},
|
|
): Promise<ToolExecutionResult> {
|
|
const context = options?.toolContextId
|
|
? getExecutionContextById(options.toolContextId)
|
|
: undefined;
|
|
if (options?.toolContextId && !context) {
|
|
return {
|
|
toolReturn: `Tool execution context not found: ${options.toolContextId}`,
|
|
status: "error",
|
|
};
|
|
}
|
|
const activeRegistry = context?.toolRegistry ?? toolRegistry;
|
|
const activeExternalTools =
|
|
context?.externalTools ?? getExternalToolsRegistry();
|
|
const activeExternalExecutor =
|
|
context?.externalExecutor ?? getExternalToolExecutor();
|
|
|
|
// Check if this is an external tool (SDK-executed)
|
|
if (activeExternalTools.has(name)) {
|
|
return executeExternalTool(
|
|
options?.toolCallId ?? `ext-${Date.now()}`,
|
|
name,
|
|
args as Record<string, unknown>,
|
|
activeExternalExecutor,
|
|
);
|
|
}
|
|
|
|
const internalName = resolveInternalToolName(name, activeRegistry);
|
|
if (!internalName) {
|
|
return {
|
|
toolReturn: `Tool not found: ${name}. Available tools: ${Array.from(activeRegistry.keys()).join(", ")}`,
|
|
status: "error",
|
|
};
|
|
}
|
|
|
|
const tool = activeRegistry.get(internalName);
|
|
if (!tool) {
|
|
return {
|
|
toolReturn: `Tool not found: ${name}. Available tools: ${Array.from(activeRegistry.keys()).join(", ")}`,
|
|
status: "error",
|
|
};
|
|
}
|
|
|
|
const startTime = Date.now();
|
|
|
|
// Run PreToolUse hooks - can block tool execution
|
|
const preHookResult = await runPreToolUseHooks(
|
|
internalName,
|
|
args as Record<string, unknown>,
|
|
options?.toolCallId,
|
|
);
|
|
if (preHookResult.blocked) {
|
|
const feedback = preHookResult.feedback.join("\n") || "Blocked by hook";
|
|
return {
|
|
toolReturn: `Error: Tool execution blocked by hook. ${feedback}`,
|
|
status: "error",
|
|
};
|
|
}
|
|
|
|
try {
|
|
// Inject options for tools that support them without altering schemas
|
|
let enhancedArgs = args;
|
|
|
|
if (STREAMING_SHELL_TOOLS.has(internalName)) {
|
|
if (options?.signal) {
|
|
enhancedArgs = { ...enhancedArgs, signal: options.signal };
|
|
}
|
|
if (options?.onOutput) {
|
|
enhancedArgs = { ...enhancedArgs, onOutput: options.onOutput };
|
|
}
|
|
}
|
|
|
|
// Inject toolCallId and abort signal for Task tool
|
|
if (internalName === "Task") {
|
|
if (options?.toolCallId) {
|
|
enhancedArgs = { ...enhancedArgs, toolCallId: options.toolCallId };
|
|
}
|
|
if (options?.signal) {
|
|
enhancedArgs = { ...enhancedArgs, signal: options.signal };
|
|
}
|
|
}
|
|
|
|
// Inject toolCallId for Skill tool (used for skill content registry)
|
|
if (internalName === "Skill" && options?.toolCallId) {
|
|
enhancedArgs = { ...enhancedArgs, toolCallId: options.toolCallId };
|
|
}
|
|
|
|
const result = await tool.fn(enhancedArgs);
|
|
const duration = Date.now() - startTime;
|
|
|
|
// Extract stdout/stderr if present (for bash tools)
|
|
const recordResult = isRecord(result) ? result : undefined;
|
|
const stdoutValue = recordResult?.stdout;
|
|
const stderrValue = recordResult?.stderr;
|
|
const stdout = isStringArray(stdoutValue) ? stdoutValue : undefined;
|
|
const stderr = isStringArray(stderrValue) ? stderrValue : undefined;
|
|
|
|
// Check if tool returned a status (e.g., Bash returns status: "error" on abort)
|
|
const toolStatus = recordResult?.status === "error" ? "error" : "success";
|
|
|
|
// Flatten the response to plain text
|
|
const flattenedResponse = flattenToolResponse(result);
|
|
|
|
// Track tool usage (calculate size for multimodal content)
|
|
const responseSize =
|
|
typeof flattenedResponse === "string"
|
|
? flattenedResponse.length
|
|
: JSON.stringify(flattenedResponse).length;
|
|
telemetry.trackToolUsage(
|
|
internalName,
|
|
toolStatus === "success",
|
|
duration,
|
|
responseSize,
|
|
toolStatus === "error" ? "tool_error" : undefined,
|
|
stderr ? stderr.join("\n") : undefined,
|
|
);
|
|
|
|
// Run PostToolUse hooks - exit 2 injects stderr into agent context
|
|
// Note: preceding_reasoning/assistant_message not available here - tracked in accumulator for server tools
|
|
let postToolUseFeedback: string[] = [];
|
|
try {
|
|
const postHookResult = await runPostToolUseHooks(
|
|
internalName,
|
|
args as Record<string, unknown>,
|
|
{
|
|
status: toolStatus,
|
|
output: getDisplayableToolReturn(flattenedResponse),
|
|
},
|
|
options?.toolCallId,
|
|
undefined, // workingDirectory
|
|
undefined, // agentId
|
|
undefined, // precedingReasoning - not available in tool manager context
|
|
undefined, // precedingAssistantMessage - not available in tool manager context
|
|
);
|
|
postToolUseFeedback = postHookResult.feedback;
|
|
} catch (error) {
|
|
debugLog("hooks", "PostToolUse hook error (success path)", error);
|
|
}
|
|
|
|
// Run PostToolUseFailure hooks when tool returns error status
|
|
let postToolUseFailureFeedback: string[] = [];
|
|
if (toolStatus === "error") {
|
|
const errorOutput =
|
|
typeof flattenedResponse === "string"
|
|
? flattenedResponse
|
|
: JSON.stringify(flattenedResponse);
|
|
try {
|
|
const failureHookResult = await runPostToolUseFailureHooks(
|
|
internalName,
|
|
args as Record<string, unknown>,
|
|
errorOutput,
|
|
"tool_error", // error type for returned errors
|
|
options?.toolCallId,
|
|
undefined, // workingDirectory
|
|
undefined, // agentId
|
|
undefined, // precedingReasoning - not available in tool manager context
|
|
undefined, // precedingAssistantMessage - not available in tool manager context
|
|
);
|
|
postToolUseFailureFeedback = failureHookResult.feedback;
|
|
} catch (error) {
|
|
debugLog(
|
|
"hooks",
|
|
"PostToolUseFailure hook error (tool returned error)",
|
|
error,
|
|
);
|
|
}
|
|
}
|
|
|
|
// Combine feedback from both hook types and inject into tool return
|
|
const allFeedback = [...postToolUseFeedback, ...postToolUseFailureFeedback];
|
|
if (allFeedback.length > 0) {
|
|
const feedbackMessage = `\n\n[Hook feedback]:\n${allFeedback.join("\n")}`;
|
|
let finalToolReturn: ToolReturnContent;
|
|
if (typeof flattenedResponse === "string") {
|
|
finalToolReturn = flattenedResponse + feedbackMessage;
|
|
} else if (Array.isArray(flattenedResponse)) {
|
|
// Append feedback as a new text content block
|
|
finalToolReturn = [
|
|
...flattenedResponse,
|
|
{ type: "text" as const, text: feedbackMessage },
|
|
];
|
|
} else {
|
|
finalToolReturn = flattenedResponse;
|
|
}
|
|
return {
|
|
toolReturn: finalToolReturn,
|
|
status: toolStatus,
|
|
...(stdout && { stdout }),
|
|
...(stderr && { stderr }),
|
|
};
|
|
}
|
|
|
|
// Return the full response (truncation happens in UI layer only)
|
|
return {
|
|
toolReturn: flattenedResponse,
|
|
status: toolStatus,
|
|
...(stdout && { stdout }),
|
|
...(stderr && { stderr }),
|
|
};
|
|
} catch (error) {
|
|
const duration = Date.now() - startTime;
|
|
const isAbort =
|
|
error instanceof Error &&
|
|
(error.name === "AbortError" ||
|
|
error.message === "The operation was aborted" ||
|
|
// node:child_process AbortError may include code/message variants
|
|
("code" in error && error.code === "ABORT_ERR"));
|
|
const errorType = isAbort
|
|
? "abort"
|
|
: error instanceof Error
|
|
? error.name
|
|
: "unknown";
|
|
const errorMessage = isAbort
|
|
? INTERRUPTED_BY_USER
|
|
: error instanceof Error
|
|
? error.message
|
|
: String(error);
|
|
|
|
// Track tool usage error
|
|
telemetry.trackToolUsage(
|
|
internalName,
|
|
false,
|
|
duration,
|
|
errorMessage.length,
|
|
errorType,
|
|
errorMessage,
|
|
);
|
|
|
|
// Run PostToolUse hooks for error case - exit 2 injects stderr
|
|
let postToolUseFeedback: string[] = [];
|
|
try {
|
|
const postHookResult = await runPostToolUseHooks(
|
|
internalName,
|
|
args as Record<string, unknown>,
|
|
{ status: "error", output: errorMessage },
|
|
options?.toolCallId,
|
|
undefined, // workingDirectory
|
|
undefined, // agentId
|
|
undefined, // precedingReasoning - not available in tool manager context
|
|
undefined, // precedingAssistantMessage - not available in tool manager context
|
|
);
|
|
postToolUseFeedback = postHookResult.feedback;
|
|
} catch (error) {
|
|
debugLog("hooks", "PostToolUse hook error (error path)", error);
|
|
}
|
|
|
|
// Run PostToolUseFailure hooks - exit 2 injects stderr
|
|
let postToolUseFailureFeedback: string[] = [];
|
|
try {
|
|
const failureHookResult = await runPostToolUseFailureHooks(
|
|
internalName,
|
|
args as Record<string, unknown>,
|
|
errorMessage,
|
|
errorType,
|
|
options?.toolCallId,
|
|
undefined, // workingDirectory
|
|
undefined, // agentId
|
|
undefined, // precedingReasoning - not available in tool manager context
|
|
undefined, // precedingAssistantMessage - not available in tool manager context
|
|
);
|
|
postToolUseFailureFeedback = failureHookResult.feedback;
|
|
} catch (error) {
|
|
debugLog(
|
|
"hooks",
|
|
"PostToolUseFailure hook error (exception path)",
|
|
error,
|
|
);
|
|
}
|
|
|
|
// Combine feedback from both hook types
|
|
const allFeedback = [...postToolUseFeedback, ...postToolUseFailureFeedback];
|
|
const finalErrorMessage =
|
|
allFeedback.length > 0
|
|
? `${errorMessage}\n\n[Hook feedback]:\n${allFeedback.join("\n")}`
|
|
: errorMessage;
|
|
|
|
// Don't console.error here - it pollutes the TUI
|
|
// The error message is already returned in toolReturn
|
|
return {
|
|
toolReturn: finalErrorMessage,
|
|
status: "error",
|
|
};
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Gets all loaded tool names (for passing to Letta agent creation).
|
|
*
|
|
* @returns Array of tool names
|
|
*/
|
|
export function getToolNames(): string[] {
|
|
return Array.from(toolRegistry.keys());
|
|
}
|
|
|
|
/**
|
|
* Returns all Letta Code tool names known to this build, regardless of what is currently loaded.
|
|
* Useful for unlinking/removing tools when switching providers/models.
|
|
*/
|
|
export function getAllLettaToolNames(): string[] {
|
|
return [...TOOL_NAMES];
|
|
}
|
|
|
|
/**
|
|
* Gets all loaded tool schemas (for inspection/debugging).
|
|
*
|
|
* @returns Array of tool schemas
|
|
*/
|
|
export function getToolSchemas(): ToolSchema[] {
|
|
return Array.from(toolRegistry.values()).map((tool) => tool.schema);
|
|
}
|
|
|
|
/**
|
|
* Gets a single tool's schema by name.
|
|
*
|
|
* @param name - The tool name
|
|
* @returns The tool schema or undefined if not found
|
|
*/
|
|
export function getToolSchema(name: string): ToolSchema | undefined {
|
|
const internalName = resolveInternalToolName(name);
|
|
if (!internalName) return undefined;
|
|
return toolRegistry.get(internalName)?.schema;
|
|
}
|
|
|
|
/**
|
|
* Clears the tool registry (useful for testing).
|
|
*/
|
|
export function clearTools(): void {
|
|
toolRegistry.clear();
|
|
}
|
|
|
|
/**
|
|
* Clears the tool registry with lock protection.
|
|
* Acquires the switch lock, clears the registry, then releases the lock.
|
|
* This ensures sendMessageStream() waits for the clear to complete.
|
|
*/
|
|
export function clearToolsWithLock(): void {
|
|
acquireSwitchLock();
|
|
try {
|
|
toolRegistry.clear();
|
|
} finally {
|
|
releaseSwitchLock();
|
|
}
|
|
}
|