From 8d1414ad4636a9af0836463f14408403512d2108 Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Fri, 1 May 2026 02:42:13 +0000 Subject: [PATCH 1/2] Refactor agent engines to leverage ACP protocol and `parseAcpMessage` Standardize the way CLI agents are integrated into the application by configuring them to use the `acp` subcommand. Removed legacy custom output parsing logic specific to each agent, replacing it uniformly with the centralized `parseAcpMessage` utility. This minimizes maintenance effort and standardizes the tool invocation events parsed from standard outputs. --- packages/server/src/agents/engines/aider.ts | 11 +- .../server/src/agents/engines/claude-code.ts | 31 +-- packages/server/src/agents/engines/copilot.ts | 118 +------- .../server/src/agents/engines/cursor-agent.ts | 11 +- packages/server/src/agents/engines/gemini.ts | 263 +----------------- .../server/src/agents/engines/openclaw.ts | 34 +-- 6 files changed, 18 insertions(+), 450 deletions(-) diff --git a/packages/server/src/agents/engines/aider.ts b/packages/server/src/agents/engines/aider.ts index 9655704..43c214a 100644 --- a/packages/server/src/agents/engines/aider.ts +++ b/packages/server/src/agents/engines/aider.ts @@ -1,5 +1,6 @@ import { unlink, writeFile } from "node:fs/promises"; import type { Subprocess } from "bun"; +import { parseAcpMessage } from "../acp-parser"; import type { AgentEngine, AgentEvent, EngineOptions } from "../engine"; import { getLiteLLMBaseUrl, listLiteLLMModels } from "../litellm-client"; import { streamProcess } from "../stream-process"; @@ -47,7 +48,7 @@ export class AiderEngine implements AgentEngine { const promptFile = `/tmp/vibe-aider-prompt-${options.runId ?? Date.now()}.txt`; await writeFile(promptFile, prompt, "utf8"); - const args = ["aider", "--yes-always", "--no-auto-commits"]; + const args = ["aider", "acp"]; if (options.model) args.push("--model", options.model); args.push("--message", `@${promptFile}`); @@ -76,13 +77,7 @@ export class AiderEngine implements AgentEngine { if (options.runId) this.processes.set(options.runId, proc); yield* withHeartbeat( - streamProcess( - proc, - (line) => { - return [{ type: "log", stream: "stdout", content: line }]; - }, - options.signal - ), + streamProcess(proc, (line) => parseAcpMessage(line), options.signal), getHeartbeatIntervalMs(), options.signal ); diff --git a/packages/server/src/agents/engines/claude-code.ts b/packages/server/src/agents/engines/claude-code.ts index e4db708..01c2fc0 100644 --- a/packages/server/src/agents/engines/claude-code.ts +++ b/packages/server/src/agents/engines/claude-code.ts @@ -1,5 +1,6 @@ import { unlink, writeFile } from "node:fs/promises"; import type { Subprocess } from "bun"; +import { parseAcpMessage } from "../acp-parser"; import type { AgentEngine, AgentEvent, EngineOptions } from "../engine"; import { getLiteLLMBaseUrl, listLiteLLMModels } from "../litellm-client"; import { streamProcess } from "../stream-process"; @@ -47,7 +48,7 @@ export class ClaudeCodeEngine implements AgentEngine { const promptFile = `/tmp/vibe-claude-code-prompt-${options.runId ?? Date.now()}.txt`; await writeFile(promptFile, prompt, "utf8"); - const args = ["claude", "--print", "--verbose", "--output-format", "stream-json"]; + const args = ["claude", "acp"]; if (options.model) args.push("--model", options.model); args.push("-p", `@${promptFile}`); @@ -70,33 +71,7 @@ export class ClaudeCodeEngine implements AgentEngine { if (options.runId) this.processes.set(options.runId, proc); yield* withHeartbeat( - streamProcess( - proc, - (line) => { - try { - const parsed = JSON.parse(line); - if (parsed.type === "assistant" && parsed.content) { - const events: AgentEvent[] = []; - for (const block of parsed.content) { - if (block.type === "text") { - events.push({ type: "log", stream: "stdout", content: block.text }); - } else if (block.type === "tool_use") { - events.push({ - type: "log", - stream: "system", - content: `[tool] ${block.name}: ${JSON.stringify(block.input).slice(0, 200)}`, - }); - } - } - return events; - } - return []; - } catch { - return [{ type: "log", stream: "stdout", content: line }]; - } - }, - options.signal - ), + streamProcess(proc, (line) => parseAcpMessage(line), options.signal), getHeartbeatIntervalMs(), options.signal ); diff --git a/packages/server/src/agents/engines/copilot.ts b/packages/server/src/agents/engines/copilot.ts index 843f852..cf06dc5 100644 --- a/packages/server/src/agents/engines/copilot.ts +++ b/packages/server/src/agents/engines/copilot.ts @@ -1,6 +1,7 @@ import { execSync } from "node:child_process"; import { join } from "node:path"; import type { Subprocess } from "bun"; +import { parseAcpMessage } from "../acp-parser"; import type { AgentEngine, AgentEvent, EngineOptions } from "../engine"; import { streamProcess } from "../stream-process"; import { getHeartbeatIntervalMs, withHeartbeat } from "./heartbeat"; @@ -51,115 +52,6 @@ export class CopilotEngine implements AgentEngine { return env; } - private parseLine(line: string): AgentEvent[] { - // Try to parse stream-json / JSONL output - let parsed: unknown; - try { - parsed = JSON.parse(line); - } catch { - // Not JSON — emit as raw log - return [{ type: "log", stream: "stdout", content: line }]; - } - - if (!parsed || typeof parsed !== "object") { - return [{ type: "log", stream: "stdout", content: line }]; - } - - const obj = parsed as Record; - - // Error event - if (obj.error || obj.type === "error") { - const errMsg = - typeof obj.error === "string" - ? obj.error - : typeof obj.message === "string" - ? obj.message - : JSON.stringify(obj.error ?? obj); - return [ - { type: "log", stream: "stderr", content: `[copilot] ${errMsg}` }, - { type: "error", content: errMsg }, - ]; - } - - // Tool use - if (obj.type === "tool_use" || obj.type === "tool_call") { - const name: string = - typeof obj.name === "string" - ? obj.name - : typeof obj.function === "object" && obj.function !== null - ? String((obj.function as Record).name ?? "tool") - : "tool"; - const toolId: string = - typeof obj.id === "string" ? obj.id : typeof obj.call_id === "string" ? obj.call_id : ""; - const parameters = (obj.parameters ?? obj.input ?? obj.args) as - | Record - | undefined; - return [ - { - type: "tool_use", - toolUse: { toolId, toolName: name, parameters: parameters ?? undefined }, - }, - { - type: "log", - stream: "system", - content: `[tool] ${name}${toolId ? ` (${toolId})` : ""}`, - }, - ]; - } - - // Tool result - if (obj.type === "tool_result" || obj.type === "tool_execution_complete") { - const toolId = - typeof obj.tool_call_id === "string" - ? obj.tool_call_id - : typeof obj.call_id === "string" - ? obj.call_id - : typeof obj.id === "string" - ? obj.id - : ""; - const output = - typeof obj.output === "string" - ? obj.output - : typeof obj.result === "string" - ? obj.result - : JSON.stringify(obj.output ?? obj.result ?? ""); - const status = obj.error ? "error" : "success"; - return [ - { - type: "tool_result", - toolResult: { toolId, output, status }, - }, - { - type: "log", - stream: "system", - content: `[tool result] ${status}${toolId ? ` (${toolId})` : ""}`, - }, - ]; - } - - // Text / message content - const text = - typeof obj.text === "string" - ? obj.text - : typeof obj.content === "string" - ? obj.content - : Array.isArray(obj.content) - ? obj.content - .filter((b: unknown) => typeof (b as Record)?.text === "string") - .map((b: unknown) => (b as Record).text) - .join("") - : typeof obj.message === "string" - ? obj.message - : null; - - if (text) { - return [{ type: "log", stream: "stdout", content: text }]; - } - - // Generic debug log for unrecognised events - return [{ type: "log", stream: "system", content: line }]; - } - async isAvailable(): Promise { try { const bin = this.getCopilotBinPath(); @@ -218,11 +110,7 @@ export class CopilotEngine implements AgentEngine { const args = [ bin, - "--allow-all", - "--output-format", - "json", - "--add-dir", - workdir, + "acp", "-p", prompt, ]; @@ -240,7 +128,7 @@ export class CopilotEngine implements AgentEngine { if (options.runId) this.processes.set(options.runId, proc); yield* withHeartbeat( - streamProcess(proc, (line) => this.parseLine(line), options.signal), + streamProcess(proc, (line) => parseAcpMessage(line), options.signal), getHeartbeatIntervalMs(), options.signal ); diff --git a/packages/server/src/agents/engines/cursor-agent.ts b/packages/server/src/agents/engines/cursor-agent.ts index 21b88fe..0264fb5 100644 --- a/packages/server/src/agents/engines/cursor-agent.ts +++ b/packages/server/src/agents/engines/cursor-agent.ts @@ -1,5 +1,6 @@ import { unlink, writeFile } from "node:fs/promises"; import type { Subprocess } from "bun"; +import { parseAcpMessage } from "../acp-parser"; import type { AgentEngine, AgentEvent, EngineOptions } from "../engine"; import { getLiteLLMBaseUrl, listLiteLLMModels } from "../litellm-client"; import { streamProcess } from "../stream-process"; @@ -46,7 +47,7 @@ export class CursorAgentEngine implements AgentEngine { const promptFile = `/tmp/vibe-cursor-prompt-${options.runId ?? Date.now()}.txt`; await writeFile(promptFile, prompt, "utf8"); - const args = ["cursor-agent"]; + const args = ["cursor-agent", "acp"]; if (options.model) args.push("--model", options.model); args.push("--message", `@${promptFile}`); @@ -73,13 +74,7 @@ export class CursorAgentEngine implements AgentEngine { if (options.runId) this.processes.set(options.runId, proc); yield* withHeartbeat( - streamProcess( - proc, - (line) => { - return [{ type: "log", stream: "stdout", content: line }]; - }, - options.signal - ), + streamProcess(proc, (line) => parseAcpMessage(line), options.signal), getHeartbeatIntervalMs(), options.signal ); diff --git a/packages/server/src/agents/engines/gemini.ts b/packages/server/src/agents/engines/gemini.ts index 16a1edd..0f71c79 100644 --- a/packages/server/src/agents/engines/gemini.ts +++ b/packages/server/src/agents/engines/gemini.ts @@ -2,76 +2,12 @@ import { access, unlink } from "node:fs/promises"; import { homedir } from "node:os"; import { delimiter, dirname, join } from "node:path"; import type { Subprocess } from "bun"; +import { parseAcpMessage } from "../acp-parser"; import type { AgentEngine, AgentEvent, EngineOptions } from "../engine"; import { getLiteLLMBaseUrl, listLiteLLMModels } from "../litellm-client"; import { streamProcess } from "../stream-process"; import { getHeartbeatIntervalMs, withHeartbeat } from "./heartbeat"; -function str(v: unknown): string { - return v != null ? String(v) : ""; -} - -function humanizeToolCall(tool: string, input: Record): string { - const t = tool.toLowerCase(); - const path = str(input.path ?? input.file_path ?? input.file ?? input.filename); - const cmd = str(input.command ?? input.cmd); - const query = str(input.query ?? input.pattern ?? input.glob ?? input.search); - const url = str(input.url); - - if (t.includes("read") || t === "view_file" || t === "cat") return `Reading ${path || "file"}`; - if (t.includes("write") || t.includes("create_file") || t === "touch") - return `Writing ${path || "file"}`; - if (t.includes("edit") || t.includes("str_replace") || t.includes("patch")) - return `Editing ${path || "file"}`; - if (t.includes("delete") || t.includes("remove_file")) return `Deleting ${path || "file"}`; - if (t.includes("move") || t.includes("rename")) return `Moving ${path || "file"}`; - if (t === "bash" || t.includes("run_command") || t.includes("execute") || t.includes("shell")) - return `Running: ${cmd || "(command)"}`; - if (t.includes("list") || t.includes("ls") || t.includes("directory")) - return `Listing ${path || "directory"}`; - if (t.includes("grep") || t.includes("search") || t.includes("find") || t.includes("glob")) - return `Searching ${query ? `"${query}"` : ""}${path ? ` in ${path}` : ""}`; - if (t.includes("web") || t.includes("browser") || t.includes("fetch")) - return `Fetching ${url || "URL"}`; - if (t.includes("git")) return `Git: ${cmd || t}`; - - const readable = tool.replace(/_/g, " ").replace(/\b\w/g, (c) => c.toUpperCase()); - const detail = path || cmd || query || url; - return `${readable}${detail ? `: ${detail}` : ""}`; -} - -function humanizeToolResult(tool: string, output: unknown): string | null { - if (output == null) return null; - const t = tool.toLowerCase(); - const text = typeof output === "string" ? output : JSON.stringify(output); - const lines = text.split("\n").filter((l) => l.trim()).length; - const preview = text.slice(0, 120).replace(/\n/g, " ").trim(); - - if (t === "bash" || t.includes("run_command") || t.includes("execute")) { - if (!text.trim()) return "Done (no output)"; - return `${preview}${text.length > 120 ? ` … (${lines} lines)` : ""}`; - } - if (t.includes("read") || t === "view_file") { - return `${lines} line${lines !== 1 ? "s" : ""} read`; - } - if (t.includes("search") || t.includes("grep") || t.includes("glob")) { - return `${lines} match${lines !== 1 ? "es" : ""}`; - } - if ( - t.includes("write") || - t.includes("edit") || - t.includes("create") || - t.includes("str_replace") - ) { - return "Saved"; - } - if (t.includes("web") || t.includes("fetch")) { - return `${lines} line${lines !== 1 ? "s" : ""} fetched`; - } - if (text.length <= 80) return preview; - return null; -} - export class GeminiEngine implements AgentEngine { name = "gemini"; displayName = "Gemini CLI"; @@ -205,7 +141,7 @@ export class GeminiEngine implements AgentEngine { const promptFile = `/tmp/vibe-gemini-prompt-${options.runId ?? Date.now()}.txt`; await Bun.write(promptFile, prompt); - const args = [command, "--yolo", "--output-format", "stream-json"]; + const args = [command, "acp"]; if (options.model) args.push("-m", options.model); if (options.resumeSessionId) args.push("-r", options.resumeSessionId); args.push("-p", `@${promptFile}`); @@ -253,200 +189,7 @@ export class GeminiEngine implements AgentEngine { if (options.runId) this.processes.set(options.runId, proc); yield* withHeartbeat( - streamProcess( - proc, - (line) => { - // Try to parse as JSON (stream-json format emits JSONL) - let parsed: unknown; - try { - parsed = JSON.parse(line); - } catch { - parsed = null; - } - - if (parsed && typeof parsed === "object") { - const obj = parsed as Record; - const sessionEvent = - typeof obj.session_id === "string" - ? [{ type: "session" as const, sessionId: obj.session_id }] - : []; - - // Final result: { session_id, result } - if (obj.session_id && obj.result) { - const result = obj.result as Record; - const text = typeof result.text === "string" ? result.text : null; - if (text) return [...sessionEvent, { type: "log", stream: "stdout", content: text }]; - return sessionEvent; - } - - // Error: { session_id, error } - if (obj.session_id && obj.error) { - const err = obj.error as Record; - const msg = typeof err.message === "string" ? err.message : JSON.stringify(err); - const events: AgentEvent[] = [ - ...sessionEvent, - { type: "log", stream: "stderr", content: `[gemini] ${msg}` }, - ]; - if (msg.includes("GEMINI_API_KEY")) { - events.push({ - type: "log", - stream: "system", - content: options.litellmKey - ? "[gemini] LiteLLM proxy key rejected. Check LITELLM_BASE_URL and the virtual key." - : "[gemini] GEMINI_API_KEY not found. Add it in Settings → API Keys.", - }); - } - return events; - } - - // Text content events - const text = - typeof obj.text === "string" - ? obj.text - : typeof obj.content === "string" - ? obj.content - : null; - if (text) { - return [{ type: "log", stream: "stdout", content: text }]; - } - - // Tool call notification — emit structured tool_use event - if (obj.type === "tool_use" || obj.type === "tool_call") { - const toolId = - typeof obj.tool_id === "string" - ? obj.tool_id - : typeof obj.id === "string" - ? obj.id - : ""; - const toolName = - typeof obj.name === "string" - ? obj.name - : typeof obj.tool_name === "string" - ? obj.tool_name - : "tool"; - let parameters: Record | undefined; - if (obj.parameters && typeof obj.parameters === "object") { - parameters = obj.parameters as Record; - } else if (obj.args && typeof obj.args === "object") { - parameters = obj.args as Record; - } - return [ - ...sessionEvent, - { - type: "tool_use", - toolUse: { toolId, toolName, parameters }, - }, - { - type: "log", - stream: "stdout", - content: humanizeToolCall(toolName, parameters ?? {}), - }, - ]; - } - - // Tool result from a previous tool_use - if (obj.type === "tool_result" || obj.type === "tool_result_legacy") { - const toolId = - typeof obj.tool_id === "string" - ? obj.tool_id - : typeof obj.call_id === "string" - ? obj.call_id - : ""; - const toolName = - typeof obj.tool_name === "string" - ? obj.tool_name - : typeof obj.name === "string" - ? obj.name - : "tool"; - const output = - typeof obj.output === "string" - ? obj.output - : typeof obj.result === "string" - ? obj.result - : JSON.stringify(obj.output ?? obj.result ?? ""); - const status = obj.status === "error" ? "error" : "success"; - const label = humanizeToolResult(toolName, output); - const baseEvents: AgentEvent[] = [ - ...sessionEvent, - { - type: "tool_result", - toolResult: { toolId, output, status }, - }, - ]; - if (label) { - baseEvents.push({ type: "log", stream: "stdout", content: label }); - } - return baseEvents; - } - - // Cost/usage stats event from provider - if (obj.type === "result" && obj.status === "success" && obj.stats) { - const stats = obj.stats as Record; - const rawModels = stats.models as Record> | undefined; - const models: - | Record< - string, - { - total_tokens: number; - input_tokens: number; - output_tokens: number; - cached?: number; - input?: number; - } - > - | undefined = rawModels ? {} : undefined; - if (rawModels) { - for (const [key, val] of Object.entries(rawModels)) { - if (models) { - models[key] = { - total_tokens: typeof val.total_tokens === "number" ? val.total_tokens : 0, - input_tokens: typeof val.input_tokens === "number" ? val.input_tokens : 0, - output_tokens: typeof val.output_tokens === "number" ? val.output_tokens : 0, - cached: typeof val.cached === "number" ? val.cached : undefined, - input: typeof val.input === "number" ? val.input : undefined, - }; - } - } - } - return [ - ...sessionEvent, - { - type: "cost", - costStats: { - total_tokens: typeof stats.total_tokens === "number" ? stats.total_tokens : 0, - input_tokens: typeof stats.input_tokens === "number" ? stats.input_tokens : 0, - output_tokens: - typeof stats.output_tokens === "number" ? stats.output_tokens : 0, - cached: typeof stats.cached === "number" ? stats.cached : undefined, - input: typeof stats.input === "number" ? stats.input : undefined, - duration_ms: - typeof stats.duration_ms === "number" ? stats.duration_ms : undefined, - tool_calls: typeof stats.tool_calls === "number" ? stats.tool_calls : undefined, - models, - }, - }, - ]; - } - - // Unknown JSON event — emit as system log - return [...sessionEvent, { type: "log", stream: "system", content: line }]; - } - - const events: AgentEvent[] = [{ type: "log", stream: "stdout", content: line }]; - - if (line.includes("you must specify the GEMINI_API_KEY environment variable")) { - events.push({ - type: "log", - stream: "system", - content: options.litellmKey - ? "[gemini] LiteLLM proxy key rejected. Check LITELLM_BASE_URL and the virtual key." - : "[gemini] GEMINI_API_KEY not found. Add it in Settings → API Keys.", - }); - } - return events; - }, - options.signal - ), + streamProcess(proc, (line) => parseAcpMessage(line), options.signal), getHeartbeatIntervalMs(), options.signal ); diff --git a/packages/server/src/agents/engines/openclaw.ts b/packages/server/src/agents/engines/openclaw.ts index ab70f0e..f4d0145 100644 --- a/packages/server/src/agents/engines/openclaw.ts +++ b/packages/server/src/agents/engines/openclaw.ts @@ -1,4 +1,5 @@ import type { Subprocess } from "bun"; +import { parseAcpMessage } from "../acp-parser"; import type { AgentEngine, AgentEvent, EngineOptions } from "../engine"; import { getLiteLLMBaseUrl, listLiteLLMModels } from "../litellm-client"; import { streamProcess } from "../stream-process"; @@ -42,7 +43,7 @@ export class OpenClawEngine implements AgentEngine { ): AsyncGenerator { yield { type: "log", stream: "system", content: `[openclaw] Starting in ${workdir}` }; - const args = ["openclaw", "agent", "--local", "--json", "--message", prompt]; + const args = ["openclaw", "acp", "--message", prompt]; if (options.model) args.push("--model", options.model); const env: NodeJS.ProcessEnv = { ...process.env }; @@ -68,36 +69,7 @@ export class OpenClawEngine implements AgentEngine { if (options.runId) this.processes.set(options.runId, proc); yield* withHeartbeat( - streamProcess( - proc, - (line) => { - try { - const parsed = JSON.parse(line); - const events: AgentEvent[] = []; - if (parsed.type === "text" && parsed.text) { - events.push({ type: "log", stream: "stdout", content: parsed.text }); - } else if (parsed.type === "tool_use") { - events.push({ - type: "log", - stream: "system", - content: `[tool] ${parsed.tool}: ${JSON.stringify(parsed.input || {}).slice(0, 200)}`, - }); - } else if (parsed.type === "error") { - const msg = parsed.error?.message || parsed.message || "Unknown error"; - events.push({ type: "log", stream: "stderr", content: `[error] ${msg}` }); - } else if (parsed.type === "step_start") { - events.push({ type: "log", stream: "system", content: `[openclaw] Step started...` }); - } - if (events.length > 0) return events; - - if (parsed.text) return [{ type: "log", stream: "stdout", content: parsed.text }]; - return []; - } catch { - return [{ type: "log", stream: "stdout", content: line }]; - } - }, - options.signal - ), + streamProcess(proc, (line) => parseAcpMessage(line), options.signal), getHeartbeatIntervalMs(), options.signal ); From 5dc0e2fb0a0e3eb2ff30c91bca0b42a54d151593 Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Fri, 1 May 2026 02:45:53 +0000 Subject: [PATCH 2/2] Refactor agent engines to leverage ACP protocol and `parseAcpMessage` Standardize the way CLI agents are integrated into the application by configuring them to use the `acp` subcommand. Removed legacy custom output parsing logic specific to each agent, replacing it uniformly with the centralized `parseAcpMessage` utility. This minimizes maintenance effort and standardizes the tool invocation events parsed from standard outputs. --- packages/server/src/agents/engines/copilot.ts | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/packages/server/src/agents/engines/copilot.ts b/packages/server/src/agents/engines/copilot.ts index cf06dc5..519c07d 100644 --- a/packages/server/src/agents/engines/copilot.ts +++ b/packages/server/src/agents/engines/copilot.ts @@ -108,12 +108,7 @@ export class CopilotEngine implements AgentEngine { content: `[copilot] bin=${bin} model=${options.model ?? "default"}`, }; - const args = [ - bin, - "acp", - "-p", - prompt, - ]; + const args = [bin, "acp", "-p", prompt]; if (options.model) args.push("--model", options.model); if (options.resumeSessionId) args.push("--resume", options.resumeSessionId);