import { randomUUID } from "node:crypto"; import * as fs from "node:fs"; import type { AgentEvent, AgentTool, AgentToolContext, AgentToolResult, AgentToolUpdateCallback, } from "@f5-sales-demo/pi-agent-core"; import type { CursorMcpCall, CursorShellStreamCallbacks, CursorExecHandlers as ICursorExecHandlers, ToolResultMessage, } from "@f5-sales-demo/pi-ai"; import { resolveToCwd } from "./tools/path-utils"; interface CursorExecBridgeOptions { cwd: string; tools: Map; getToolContext?: () => AgentToolContext | undefined; emitEvent?: (event: AgentEvent) => void; } function createToolResultMessage( toolCallId: string, toolName: string, result: AgentToolResult, isError: boolean, ): ToolResultMessage { return { role: "toolResult", toolCallId, toolName, content: result.content, details: result.details, isError, timestamp: Date.now(), }; } function buildToolErrorResult(message: string): AgentToolResult { return { content: [{ type: "text", text: message }], }; } async function executeTool( options: CursorExecBridgeOptions, toolName: string, toolCallId: string, args: Record, ): Promise { const tool = options.tools.get(toolName); if (!tool) { const result = buildToolErrorResult(`Tool "${toolName}" not available`); return createToolResultMessage(toolCallId, toolName, result, true); } options.emitEvent?.({ type: "tool_execution_start", toolCallId, toolName, args }); let result: AgentToolResult; let isError = false; const onUpdate: AgentToolUpdateCallback | undefined = options.emitEvent ? partialResult => { options.emitEvent?.({ type: "tool_execution_update", toolCallId, toolName, args, partialResult, }); } : undefined; try { result = await tool.execute( toolCallId, args as Record, undefined, onUpdate, options.getToolContext?.(), ); } catch (error) { const message = error instanceof Error ? error.message : String(error); result = buildToolErrorResult(message); isError = true; } options.emitEvent?.({ type: "tool_execution_end", toolCallId, toolName, result, isError, isWarning: Boolean(result.isWarning), }); return createToolResultMessage(toolCallId, toolName, result, isError); } async function executeDelete(options: CursorExecBridgeOptions, pathArg: string, toolCallId: string) { const toolName = "delete"; options.emitEvent?.({ type: "tool_execution_start", toolCallId, toolName, args: { path: pathArg } }); const absolutePath = resolveToCwd(pathArg, options.cwd); let isError = false; let result: AgentToolResult; try { let fileStat: fs.Stats | undefined; try { fileStat = fs.statSync(absolutePath); } catch { throw new Error(`File not found: ${pathArg}`); } if (!fileStat.isFile()) { throw new Error(`Path is not a file: ${pathArg}`); } fs.rmSync(absolutePath); const sizeText = fileStat.size ? ` (${fileStat.size} bytes)` : ""; const message = `Deleted ${pathArg}${sizeText}`; result = { content: [{ type: "text", text: message }], details: {} }; } catch (error) { const message = error instanceof Error ? error.message : String(error); result = buildToolErrorResult(message); isError = true; } options.emitEvent?.({ type: "tool_execution_end", toolCallId, toolName, result, isError, isWarning: Boolean(result.isWarning), }); return createToolResultMessage(toolCallId, toolName, result, isError); } function decodeToolCallId(toolCallId?: string): string { return toolCallId && toolCallId.length > 0 ? toolCallId : randomUUID(); } function decodeMcpArgs(rawArgs: Record): Record { const decoded: Record = {}; for (const [key, value] of Object.entries(rawArgs)) { const text = new TextDecoder().decode(value); try { decoded[key] = JSON.parse(text); } catch { decoded[key] = text; } } return decoded; } function formatMcpToolErrorMessage(toolName: string, availableTools: string[]): string { const list = availableTools.length > 0 ? availableTools.join(", ") : "none"; return `MCP tool "${toolName}" not found. Available tools: ${list}`; } export class CursorExecHandlers implements ICursorExecHandlers { constructor(private options: CursorExecBridgeOptions) {} async read(args: Parameters>[0]) { const toolCallId = decodeToolCallId(args.toolCallId); const toolResultMessage = await executeTool(this.options, "read", toolCallId, { path: args.path }); return toolResultMessage; } async ls(args: Parameters>[0]) { const toolCallId = decodeToolCallId(args.toolCallId); // Redirect ls to read tool, which handles directories const toolResultMessage = await executeTool(this.options, "read", toolCallId, { path: args.path }); return toolResultMessage; } async grep(args: Parameters>[0]) { const toolCallId = decodeToolCallId(args.toolCallId); const toolResultMessage = await executeTool(this.options, "grep", toolCallId, { pattern: args.pattern, path: args.path || undefined, glob: args.glob || undefined, mode: args.outputMode || undefined, context: args.context ?? args.contextBefore ?? args.contextAfter ?? undefined, ignore_case: args.caseInsensitive || undefined, type: args.type || undefined, limit: args.headLimit ?? undefined, multiline: args.multiline || undefined, }); return toolResultMessage; } async write(args: Parameters>[0]) { const toolCallId = decodeToolCallId(args.toolCallId); const content = args.fileText ?? new TextDecoder().decode(args.fileBytes ?? new Uint8Array()); const toolResultMessage = await executeTool(this.options, "write", toolCallId, { path: args.path, content, }); return toolResultMessage; } async delete(args: Parameters>[0]) { const toolCallId = decodeToolCallId(args.toolCallId); const toolResultMessage = await executeDelete(this.options, args.path, toolCallId); return toolResultMessage; } async shell(args: Parameters>[0]) { const toolCallId = decodeToolCallId(args.toolCallId); const timeoutSeconds = args.timeout && args.timeout > 0 ? args.timeout : undefined; const toolResultMessage = await executeTool(this.options, "bash", toolCallId, { command: args.command, cwd: args.workingDirectory || undefined, timeout: timeoutSeconds, }); return toolResultMessage; } async shellStream( args: Parameters>[0], callbacks: CursorShellStreamCallbacks, ) { const toolCallId = decodeToolCallId(args.toolCallId); const toolName = "bash"; const tool = this.options.tools.get(toolName); if (!tool) { const result = buildToolErrorResult(`Tool "${toolName}" not available`); return createToolResultMessage(toolCallId, toolName, result, true); } const timeoutSeconds = args.timeout && args.timeout > 0 ? args.timeout : undefined; const toolArgs: Record = { command: args.command, cwd: args.workingDirectory || undefined, timeout: timeoutSeconds, }; this.options.emitEvent?.({ type: "tool_execution_start", toolCallId, toolName, args: toolArgs }); let result: AgentToolResult; let isError = false; // Track previously streamed text so we only forward deltas. let streamedLen = 0; const onUpdate: AgentToolUpdateCallback = partialResult => { this.options.emitEvent?.({ type: "tool_execution_update", toolCallId, toolName, args: toolArgs, partialResult, }); const text = partialResult.content.map(c => (c.type === "text" ? c.text : "")).join(""); if (text.length > streamedLen) { callbacks.onStdout(text.slice(streamedLen)); streamedLen = text.length; } }; try { result = await tool.execute(toolCallId, toolArgs, undefined, onUpdate, this.options.getToolContext?.()); } catch (error) { const message = error instanceof Error ? error.message : String(error); result = buildToolErrorResult(message); isError = true; } // onUpdate may not fire for every chunk — flush any remaining output // from the final result that wasn't already streamed. const finalText = result.content.map(c => (c.type === "text" ? c.text : "")).join(""); if (finalText.length > streamedLen) { callbacks.onStdout(finalText.slice(streamedLen)); } this.options.emitEvent?.({ type: "tool_execution_end", toolCallId, toolName, result, isError, isWarning: Boolean(result.isWarning), }); return createToolResultMessage(toolCallId, toolName, result, isError); } async diagnostics(args: Parameters>[0]) { const toolCallId = decodeToolCallId(args.toolCallId); const toolResultMessage = await executeTool(this.options, "lsp", toolCallId, { action: "diagnostics", file: args.path, }); return toolResultMessage; } async mcp(call: CursorMcpCall) { const toolName = call.toolName || call.name; const toolCallId = decodeToolCallId(call.toolCallId); const tool = this.options.tools.get(toolName); if (!tool) { const availableTools = Array.from(this.options.tools.keys()).filter(name => name.startsWith("mcp_")); const message = formatMcpToolErrorMessage(toolName, availableTools); const toolResult: ToolResultMessage = { role: "toolResult", toolCallId, toolName, content: [{ type: "text", text: message }], isError: true, timestamp: Date.now(), }; return toolResult; } const args = Object.keys(call.args ?? {}).length > 0 ? call.args : decodeMcpArgs(call.rawArgs ?? {}); const toolResultMessage = await executeTool(this.options, toolName, toolCallId, args); return toolResultMessage; } }