import { randomUUID } from "node:crypto"; import type { AgentToolResult, AgentToolUpdateCallback, ExtensionContext, ToolDefinition, } from "@earendil-works/pi-coding-agent"; import type { TSchema } from "typebox"; import type { PeerToolClient } from "./peer/client.ts"; import { Check } from "typebox/value"; import { type ApprovalPolicy, type ApprovalRisk, classifyToolRisk, runEvalApproval, } from "../bridges/approval-bridge.ts"; /** * ToolExecutionShim (see TECHNICAL.md, Host bridges): pi 0.83.0 exposes no * `pi.executeTool`. * * senpi's `executeTool(toolName, params, options)` goes through validation and * the tool_call/tool_result hooks and can execute any registered tool. pi can * only hand us `getAllTools()`/`getActiveTools()` metadata — never a tool's * `execute` function. So this shim: * * 1. Executes tools THIS package registered (eval, and the exec hint stub) * natively: `prepareArguments` → `Check(parameters)` → `execute`, * wrapping thrown errors into the same `{ content: [{type:"text",...}], * details: { isError: true } }` result shape senpi's executeTool produces. * 2. For every other name, throws an error object carrying senpi's * `ExecuteToolError` contract (`code: "unknown_tool" | "inactive_tool"` * plus `toolName`, `activeTools`) — `agent-bridge.ts` and `output-bridge.ts` * already pattern-match these codes (`isUnavailableToolError`), so * `agent()`/`output()` availability errors behave exactly like senpi. * 3. Availability (`isToolAvailable`) stays name-based: * `pi.getActiveTools().includes(name)`. * * Upstream note: if pi later exposes `executeTool` on `ExtensionAPI`, replace * the external-tool branch with the native call and delete this shim — the * internal error-code contract is designed to be drop-in. */ export type ExecuteToolErrorCode = | "unknown_tool" | "inactive_tool" | "invalid_params"; /** * Assert the host's active-tool listing has no duplicate names. pi's * setActiveToolsByName path currently forwards duplicates into provider * requests, so silently accepting them here would hide a host regression. */ export function assertActiveToolsUnique(active: readonly string[]): void { const seen = new Set(); for (const name of active) { if (seen.has(name)) { throw new Error( `Duplicate active tool name ${name}; pi agent-session setActiveToolsByName currently permits duplicate names` ); } seen.add(name); } } export class ExecuteToolError extends Error { readonly code: ExecuteToolErrorCode; readonly toolName: string; readonly activeTools: readonly string[]; constructor( code: ExecuteToolErrorCode, toolName: string, message: string, activeTools: readonly string[] ) { super(message); this.name = "ExecuteToolError"; this.code = code; this.toolName = toolName; this.activeTools = activeTools; } } export interface ToolExecutionShimOptions { /** * Active tool names in the host session (from pi.getActiveTools()). This is * the host listing, not the set of definitions executable by this shim. */ readonly getActiveTools: () => readonly string[]; /** The extension's active ExtensionContext, or undefined before session_start. */ readonly getContext: () => ExtensionContext | undefined; /** All tools known to the host session (from pi.getAllTools()). */ readonly listTools: () => readonly { readonly name: string }[]; /** * Approval gate for cell tool calls. When present, every tool executed * through the shim is checked before it runs: the call's risk class is * resolved (default classifyToolRisk, or approvals.classifyRisk), then * runEvalApproval applies the policy (defaults: read allow, write/execute * ask). An `ask` verdict without an interactive UI fails the call. */ readonly approvals?: { readonly policy?: Partial>; readonly classifyRisk?: (toolName: string) => ApprovalRisk; /** Risk classes granted for the session (the "Allow this session" choice). */ readonly sessionApprovals?: Set; }; /** * Cooperative cross-extension dispatch (Plan 003): tools bound to an * opt-in peer are executed by that peer instead of refused. Unbound tools * keep the ordinary availability error unchanged. */ readonly peerTools?: PeerToolClient; } /** * Type-erased view of a registered tool. ToolDefinition's generics cannot form * a common supertype under strict function contravariance (TDetails appears in * the execute/onUpdate parameters), so registration adapts each definition to * this erased shape; the runtime values are validated with Check() before * crossing back into the concrete generic boundary. */ interface RegisteredTool { readonly execute: ( toolCallId: string, params: unknown, signal: AbortSignal | undefined, onUpdate: AgentToolUpdateCallback | undefined, ctx: ExtensionContext ) => Promise>; readonly name: string; } function isAgentToolResult(value: unknown): value is AgentToolResult { return ( typeof value === "object" && value !== null && !Array.isArray(value) && Array.isArray((value as { content?: unknown }).content) ); } export class ToolExecutionShim { readonly #tools = new Map(); readonly #options: ToolExecutionShimOptions; constructor(options: ToolExecutionShimOptions) { this.#options = options; } /** * Register a tool this package registered so cells can execute it natively. * Validation (prepareArguments + Check) happens inside the adapter closure so * the erased registry never needs to cross the generic boundary with an * unchecked value: Check's type guard narrows the prepared params to * Static before the concrete execute is invoked. */ registerTool( tool: ToolDefinition ): void { const registered: RegisteredTool = { name: tool.name, execute: async (toolCallId, params, signal, onUpdate, ctx) => { let prepared: unknown = params; if (tool.prepareArguments !== undefined) { prepared = tool.prepareArguments(params); } if (!Check(tool.parameters, prepared)) { const activeTools = this.#activeTools(); throw new ExecuteToolError( "invalid_params", tool.name, `Invalid arguments for tool ${tool.name}`, activeTools ); } return await tool.execute(toolCallId, prepared, signal, onUpdate, ctx); }, }; this.#tools.set(tool.name, registered); } isToolAvailable(name: string): boolean { return this.#activeTools().includes(name); } async executeTool( toolName: string, params: unknown, options?: { signal?: AbortSignal; onUpdate?: AgentToolUpdateCallback; activateInactiveTool?: boolean; } ): Promise> { // Recursive eval is forbidden at this single choke point so the guard also // covers subprocess kernels (py/rb), which reach the host only through the // HTTP bridge (session-manager.#call -> executeTool). The CellHandler check // in tool/cell-handler.ts remains as defense-in-depth for the JS kernel path. if (toolName === "eval") { throw new ExecuteToolError( "invalid_params", toolName, "recursive eval is not allowed", this.#activeTools() ); } const approvals = this.#options.approvals; if (approvals !== undefined) { await this.#checkApproval(toolName, params, approvals, options); } const definition = this.#tools.get(toolName); if (definition !== undefined) { return await this.#executeLocal(definition, toolName, params, options); } const peerTools = this.#options.peerTools; if (peerTools !== undefined) { const peerResult = await peerTools.callIfBound(toolName, params, { signal: options?.signal, }); if (peerResult !== undefined) { if (peerResult.ok && isAgentToolResult(peerResult.value)) { return peerResult.value; } return { content: [ { type: "text", text: peerResult.ok ? `Peer tool ${toolName} returned a malformed result` : peerResult.message, }, ], details: { isError: true }, }; } } return this.#throwUnavailable(toolName); } /** * The approval gate: the dispatch calls the bridge before a tool executes. * A denial throws ApprovalDeniedError; an `ask` verdict with no interactive * UI to resolve it fails the call the same way pi-fabric does. */ async #checkApproval( toolName: string, params: unknown, approvals: NonNullable, options: { signal?: AbortSignal } | undefined ): Promise { const classifyRisk = approvals.classifyRisk ?? classifyToolRisk; const risk = classifyRisk(toolName); const result = await runEvalApproval( { op: "check", toolName, args: params, risk }, { policy: approvals.policy, sessionApprovals: approvals.sessionApprovals, context: this.#options.getContext(), ...(options?.signal === undefined ? {} : { signal: options.signal }), } ); if (result.verdict !== "allow") { throw new Error( `${toolName} requires approval for ${risk} access, but no interactive UI is available` ); } } async #executeLocal( definition: RegisteredTool, toolName: string, params: unknown, options: | { signal?: AbortSignal; onUpdate?: AgentToolUpdateCallback } | undefined ): Promise> { const ctx = this.#options.getContext(); if (ctx === undefined) { return { content: [ { type: "text", text: `${toolName} is not ready: no active session context`, }, ], details: { isError: true }, }; } try { return await definition.execute( `codemode-${randomUUID()}`, params, options?.signal, options?.onUpdate, ctx ); } catch (error) { // invalid_params propagates so cell-handler appends the schema hint, // mirroring senpi's executeTool contract; other failures become the // standard isError result shape. if (error instanceof ExecuteToolError) { throw error; } return { content: [ { type: "text", text: error instanceof Error ? error.message : String(error), }, ], details: { isError: true }, }; } } #activeTools(): readonly string[] { const activeTools = this.#options.getActiveTools(); assertActiveToolsUnique(activeTools); return activeTools; } #throwUnavailable(toolName: string): never { const activeTools = [...this.#activeTools()]; const activeList = activeTools.length > 0 ? activeTools.join(", ") : "(none)"; const known = this.#options .listTools() .some((tool) => tool.name === toolName); if (!known) { throw new ExecuteToolError( "unknown_tool", toolName, `Unknown tool ${toolName}. Active tools: ${activeList}`, activeTools ); } if (!activeTools.includes(toolName)) { throw new ExecuteToolError( "inactive_tool", toolName, `Tool ${toolName} is registered but inactive. Active tools: ${activeList}`, activeTools ); } // Registered and active, but registered by ANOTHER extension: pi 0.83.0 // exposes no executeTool, so this package cannot run it. Surface the same // availability error agent-bridge/output-bridge map to "tool unavailable" // (documented delta D1). throw new ExecuteToolError( "unknown_tool", toolName, `Tool ${toolName} is registered by another extension and cannot be executed through pi-codemode on this host. Active host tools: ${activeList} (the host listing is not the set callable from a cell)`, activeTools ); } }