// ABOUTME: Tool Caller — meta-tool that lets the agent invoke other tools programmatically by name. // ABOUTME: Enables dynamic tool composition, pipelines, and conditional tool usage. import type { ExtensionAPI } from "@mariozechner/pi-coding-agent"; import { Type } from "@sinclair/typebox"; import { Text } from "@mariozechner/pi-tui"; import { getToolRegistry } from "./tool-registry.ts"; import { applyExtensionDefaults } from "./lib/themeMap.ts"; // ── Tool Parameters ──────────────────────────────────────────────────── const CallToolParams = Type.Object({ tool_name: Type.String({ description: "Name of the tool to invoke (e.g. 'read', 'commander_task', 'web_remote')" }), arguments: Type.Record(Type.String(), Type.Unknown(), { description: "Arguments to pass to the tool — must match the tool's parameter schema" }), reason: Type.Optional(Type.String({ description: "Brief description of why this tool is being called (for audit trail)" })), }); // ── Self-reference prevention ────────────────────────────────────────── const BLOCKED_TOOLS = new Set(["call_tool", "tool_search"]); // ── Extension ────────────────────────────────────────────────────────── export default function (pi: ExtensionAPI) { const registry = getToolRegistry(); // Cache of tool execute functions — built lazily const toolExecutors: Map = new Map(); // We need access to the raw tool definitions for execute functions // pi.getAllTools() only gives name+description, but we need the execute function // We'll use a different approach: register tools that proxy through pi's internal tool system pi.registerTool({ name: "call_tool", label: "Call Tool", description: "Invoke any registered tool programmatically by name. " + "Use tool_search first to discover available tools and their parameters. " + "This enables dynamic tool composition — call tools based on runtime conditions.\n\n" + "Parameters:\n" + "- tool_name: The exact name of the tool to call (e.g. 'read', 'bash', 'commander_task')\n" + "- arguments: Object with the tool's expected parameters\n" + "- reason: (optional) Why this tool is being called\n\n" + "Examples:\n" + '{ "tool_name": "read", "arguments": { "path": "package.json" }, "reason": "Check project dependencies" }\n' + '{ "tool_name": "bash", "arguments": { "command": "git status" }, "reason": "Check repo state" }\n' + '{ "tool_name": "commander_task", "arguments": { "operation": "list" }, "reason": "List current tasks" }\n\n' + "Note: Cannot call 'call_tool' or 'tool_search' recursively. " + "All security restrictions still apply — blocked operations remain blocked.", parameters: CallToolParams, async execute(toolCallId, params, signal, onUpdate, ctx) { const { tool_name, arguments: toolArgs, reason } = params; // Prevent self-referential calls if (BLOCKED_TOOLS.has(tool_name)) { return { content: [{ type: "text" as const, text: `Error: Cannot call '${tool_name}' through call_tool — use it directly.` }], details: { tool_name, error: "blocked_self_reference", reason }, }; } // Verify tool exists in registry const entry = registry.getByName(tool_name); if (!entry) { const similar = registry.search(tool_name).slice(0, 3); const suggestion = similar.length > 0 ? ` Did you mean: ${similar.map((s) => s.name).join(", ")}?` : ""; return { content: [{ type: "text" as const, text: `Error: Tool "${tool_name}" not found.${suggestion}` }], details: { tool_name, error: "not_found", reason }, }; } // Verify tool is in the full tools list (getAllTools returns registered tools) const allTools = pi.getAllTools(); const toolDef = allTools.find((t: any) => t.name === tool_name); if (!toolDef) { return { content: [{ type: "text" as const, text: `Error: Tool "${tool_name}" is indexed but not currently registered. It may have been unloaded.` }], details: { tool_name, error: "not_registered", reason }, }; } // Execute via pi's internal tool calling mechanism // We use sendMessage to inject a tool call that the agent loop will handle // But that's not programmatic — we need direct execution. // // The approach: we call the tool's execute function directly if available. // pi.getAllTools() doesn't expose execute, but we can access registered tools // through the global tool registry that Pi maintains internally. // // Alternative: use Bash to call `pi --mode json --tools -p ""` // But that's heavy. Instead, we leverage the fact that custom tools registered // via pi.registerTool share the same runtime — we can store references. try { // Access the tool execution system through Pi's internal mechanisms // We use the __piToolExecutors map that we build during session_start const executor = toolExecutors.get(tool_name); if (executor) { const result = await executor( `${toolCallId}-proxy-${tool_name}`, toolArgs, signal, onUpdate, ctx, ); return { content: result.content || [{ type: "text" as const, text: "Tool returned no content" }], details: { tool_name, reason, proxied: true, originalDetails: result.details, }, }; } // Fallback: the tool is a built-in or we don't have direct access to its executor // In this case, we can use pi.exec to run a sub-process pi call // But for built-in tools, we can import and call them directly const builtinResult = await executeBuiltinTool(tool_name, toolArgs, ctx, signal, pi); if (builtinResult) { return { content: builtinResult.content || [{ type: "text" as const, text: "Tool returned no content" }], details: { tool_name, reason, proxied: true, executionMethod: "builtin", originalDetails: builtinResult.details, }, }; } // Last resort: report that programmatic execution isn't available for this tool return { content: [{ type: "text" as const, text: `Tool "${tool_name}" exists but programmatic execution is not available. ` + `Call it directly instead of through call_tool.`, }], details: { tool_name, reason, error: "no_executor" }, }; } catch (err: any) { return { content: [{ type: "text" as const, text: `Error executing "${tool_name}": ${err.message}`, }], details: { tool_name, reason, error: "execution_error", message: err.message }, }; } }, renderCall(args, theme) { let text = theme.fg("toolTitle", theme.bold("call_tool ")); text += theme.fg("accent", args.tool_name || "?"); if (args.reason) { text += theme.fg("dim", ` — ${args.reason}`); } return new Text(text, 0, 0); }, renderResult(result, { expanded }, theme) { const details = result.details as any; if (!details) { const text = result.content[0]; return new Text(text?.type === "text" ? text.text : "", 0, 0); } if (details.error) { const errMsg = details.error === "not_found" ? `✗ Tool not found: ${details.tool_name}` : details.error === "blocked_self_reference" ? `✗ Cannot call ${details.tool_name} recursively` : `✗ Error: ${details.message || details.error}`; return new Text(theme.fg("error", errMsg), 0, 0); } if (details.proxied) { let summary = theme.fg("success", `✓ ${details.tool_name}`); if (details.reason) summary += theme.fg("dim", ` — ${details.reason}`); if (expanded) { const text = result.content[0]; const body = text?.type === "text" ? text.text : ""; const truncated = body.length > 500 ? body.slice(0, 500) + "..." : body; return new Text(summary + "\n" + theme.fg("muted", truncated), 0, 0); } return new Text(summary, 0, 0); } return new Text(theme.fg("dim", "call_tool completed"), 0, 0); }, }); // Hook into session_start to capture tool executors from other extensions pi.on("session_start", async (_event, _ctx) => { // Store references to tool executors that we can access // This is populated by other extensions that register tools via pi.registerTool // We access them through the global __piToolRegistry pattern const g = globalThis as any; // Build executor cache from any tools that expose their execute functions // via the global registry pattern if (g.__piRegisteredToolExecutors) { for (const [name, executor] of Object.entries(g.__piRegisteredToolExecutors)) { toolExecutors.set(name, executor); } } }); pi.on("session_start", async (_event, ctx) => { applyExtensionDefaults(import.meta.url, ctx); }); } // ── Built-in Tool Execution ──────────────────────────────────────────── async function executeBuiltinTool( name: string, args: Record, ctx: any, signal: AbortSignal | undefined, pi: ExtensionAPI, ): Promise<{ content: any[]; details?: any } | null> { const cwd = ctx.cwd || process.cwd(); switch (name) { case "bash": { const command = args.command as string; if (!command) return { content: [{ type: "text", text: "Error: 'command' parameter required" }] }; const timeout = (args.timeout as number) || undefined; try { // pi.exec takes (binary, args[], options) like child_process.spawn // For shell commands, we need to invoke bash -c "command" const result = await pi.exec("bash", ["-c", command], { signal, timeout: timeout ? timeout * 1000 : undefined, cwd, }); const output = result.stdout + (result.stderr ? `\nSTDERR: ${result.stderr}` : ""); return { content: [{ type: "text", text: output || "(no output)" }], details: { exitCode: result.code, command }, }; } catch (err: any) { return { content: [{ type: "text", text: `Bash error: ${err.message}` }], details: { error: true, command }, }; } } case "read": { const { readFileSync } = await import("node:fs"); const { resolve } = await import("node:path"); const path = (args.path as string) || ""; if (!path) return { content: [{ type: "text", text: "Error: 'path' parameter required" }] }; try { const fullPath = resolve(cwd, path); const content = readFileSync(fullPath, "utf-8"); const offset = (args.offset as number) || 1; const limit = (args.limit as number) || 2000; const lines = content.split("\n"); const sliced = lines.slice(offset - 1, offset - 1 + limit); return { content: [{ type: "text", text: sliced.join("\n") }], details: { path: fullPath, totalLines: lines.length }, }; } catch (err: any) { return { content: [{ type: "text", text: `Read error: ${err.message}` }], details: { error: true, path }, }; } } case "write": { const { writeFileSync, mkdirSync } = await import("node:fs"); const { resolve, dirname } = await import("node:path"); const path = (args.path as string) || ""; const content = (args.content as string) || ""; if (!path) return { content: [{ type: "text", text: "Error: 'path' parameter required" }] }; try { const fullPath = resolve(cwd, path); mkdirSync(dirname(fullPath), { recursive: true }); writeFileSync(fullPath, content, "utf-8"); return { content: [{ type: "text", text: `Successfully wrote ${content.length} bytes to ${path}` }], details: { path: fullPath, bytes: content.length }, }; } catch (err: any) { return { content: [{ type: "text", text: `Write error: ${err.message}` }], details: { error: true, path }, }; } } default: return null; } }