import { type AgentToolResult, highlightCode } from "@earendil-works/pi-coding-agent"; import { Context, Effect, Layer, Option, Schema } from "effect"; import { Origin, ToolId, WebMcpTool } from "../schemas/WebMcpTool"; import { agentConnectInstruction } from "../utils/copy"; import { BrowserClient, type CdpClient } from "./BrowserClient"; import { PiContext } from "./PiApi"; import { PiWebMcpToolStateService } from "./PiWebMcpToolStateService"; export type PiWebMcpExecuteParams = { readonly tool: string; readonly origin: string; readonly args?: string; }; export type PiWebMcpExecuteDetails = { readonly connected?: boolean; readonly id?: ToolId; readonly origin?: Origin; readonly input?: Record; readonly result?: unknown; readonly error?: string; }; export class PiWebMcpExecuteError extends Schema.TaggedErrorClass()("PiWebMcpExecuteError", { operation: Schema.Union([Schema.Literal("parseInput"), Schema.Literal("invokeTool")]), cause: Schema.Unknown, }) {} function listToolsText(tools: WebMcpTool[]) { if (tools.length === 0) return "No WebMCP tools found. Ask the user to run `/webmcp` first."; return tools .sort((a, b) => a.name.localeCompare(b.name)) .map((tool) => { const id = tool.id; const name = id === tool.name ? id : `${id} (${tool.name})`; const description = tool.description ? `\n ${tool.description}` : ""; return ` - ${name} @ ${tool.origin}${description}`; }) .join("\n"); } function resolveTool(tools: WebMcpTool[], id: ToolId, origin?: Origin) { const candidates = tools.filter((tool) => (tool.id === id || tool.name === id) && (!origin || tool.origin === origin)); return candidates.length === 1 ? candidates[0] : { candidates }; } function parseInput(args: string | undefined) { return Effect.try({ try: () => { if (!args) return {}; const input = JSON.parse(args) as unknown; if (typeof input !== "object" || input === null || Array.isArray(input)) { throw new Error("args must be a JSON object string"); } return input as Record; }, catch: (cause) => new PiWebMcpExecuteError({ operation: "parseInput", cause }), }); } function invokeWebMcpTool(cdp: CdpClient, tool: WebMcpTool, input: Record) { return Effect.tryPromise({ try: async () => { if (!tool.sessionId) throw new Error("WebMCP tool is missing its CDP session id. Re-run `/webmcp` and try again."); let invocationId: string | undefined; const responsePromise = new Promise((resolve, reject) => { const timer = setTimeout(() => { reject( new Error( "Timed out waiting for WebMCP.toolResponded. The page accepted the invocation but did not respond; declarative form tools may require the page/form to opt into toolautosubmit or otherwise call event.respondWith(...).", ), ); }, 60_000); cdp.on("WebMCP.toolResponded", (ev: any, evSessionId?: string) => { if (evSessionId !== tool.sessionId) return; if (!invocationId || ev.invocationId === invocationId) { clearTimeout(timer); resolve(ev); } }); }); const invokeResult = await cdp.send("WebMCP.invokeTool", { frameId: tool.frameId, toolName: tool.name, input, }, tool.sessionId); invocationId = invokeResult.invocationId; const response = await responsePromise; return { invokeResult, response }; }, catch: (cause) => new PiWebMcpExecuteError({ operation: "invokeTool", cause }), }); } function textResult(text: string, details: PiWebMcpExecuteDetails): AgentToolResult { return { content: [{ type: "text", text }], details }; } export class PiWebMcpExecuteService extends Context.Service Effect.Effect, never, PiContext>; }>()("pi-webmcp/PiWebMcpExecuteService") { static readonly live = Layer.effect( PiWebMcpExecuteService, Effect.gen(function*() { const browser = yield* BrowserClient; const toolState = yield* PiWebMcpToolStateService; return PiWebMcpExecuteService.of({ execute: Effect.fn("PiWebMcpExecuteService.execute")( function*(params: PiWebMcpExecuteParams) { const cdpOption = yield* browser.get; if (Option.isNone(cdpOption)) { return textResult(agentConnectInstruction, { connected: false }); } const activeTools = [...yield* toolState.committed, ...yield* toolState.staged]; const origin = Schema.decodeUnknownSync(Origin)(params.origin); const toolId = Schema.decodeUnknownSync(ToolId)(params.tool); const resolved = resolveTool(activeTools, toolId, origin); if ("candidates" in resolved) { return textResult( resolved.candidates.length > 0 ? `Ambiguous tool. Retry with origin.\n\n${listToolsText(resolved.candidates)}` : `Tool not found: ${params.tool}. Try /webmcp first.`, { error: "tool_not_found_or_ambiguous" }, ); } const input = yield* parseInput(params.args); const result = yield* invokeWebMcpTool(cdpOption.value, resolved, input); const inputJson = highlightCode(JSON.stringify(input, null, 2), "json").join("\n"); const responseJson = highlightCode(JSON.stringify((result as any).response, null, 2), "json").join("\n"); const text = `\n→\n\n${inputJson}\n\n←\n\n${responseJson}`; return textResult(text, { id: resolved.id, origin: resolved.origin, input, result, }); }, (effect) => effect.pipe(Effect.catch((cause: unknown) => Effect.succeed(textResult(String(cause instanceof Error ? cause.message : cause), { error: "execute_failed" })))), ), }); }), ); }