UNPKG

@workflow-manager/runner

Version:

CLI runner for in-memory and markdown workflow orchestration using ATEP-like envelopes

362 lines (361 loc) 15.7 kB
import { spawn } from "node:child_process"; import fs from "node:fs"; import path from "node:path"; import { ClientSideConnection, PROTOCOL_VERSION, ndJsonStream, } from "@agentclientprotocol/sdk"; import { resolveTaskAdapter } from "./adapters.js"; import { createContextMetricsBuilder } from "./contextMetrics.js"; import { previousOutputTextSections } from "./promptSections.js"; import { resolveSkill } from "./skillResolver.js"; // Presets so claude-code / opencode / codex / kimi / gemini / qwen / a named agent // route through ACP without an explicit command. claude-code-acp is the // @zed-industries bridge — `claude` itself has no native ACP; codex-acp is the // @agentclientprotocol bridge — `codex` itself has no native ACP, handshake confirmed // against codex-acp 1.1. kimi/opencode/gemini/qwen need no bridge — each CLI speaks // ACP natively: kimi via `kimi acp`, opencode via `opencode acp`, Gemini CLI (the // official Google agent, npm package @google/gemini-cli) via `gemini --acp`, and Qwen // Code (the official Qwen agent, npm package @qwen-code/qwen-code) via // `qwen --acp --experimental-skills` — all per the agentclientprotocol.com registry. // All overridable via payload.acpCommand / acpArgs or WFM_ACP_COMMAND. const ACP_COMMAND_PRESETS = { "claude-code": { command: "claude-code-acp", args: [] }, opencode: { command: "opencode", args: ["acp"] }, gemini: { command: "gemini", args: ["--acp"] }, codex: { command: "codex-acp", args: [] }, kimi: { command: "kimi", args: ["acp"] }, qwen: { command: "qwen", args: ["--acp", "--experimental-skills"] }, }; const READ_ONLY_TOOL_KINDS = new Set(["read", "search", "fetch", "think"]); function asRecord(value) { return value && typeof value === "object" && !Array.isArray(value) ? value : {}; } // node:stream's Writable.toWeb / Readable.toWeb are not implemented in Bun, so we // bridge the child process stdio to Web streams ourselves (portable across runtimes). function writableToWeb(writable) { return new WritableStream({ write(chunk) { return new Promise((resolve, reject) => { writable.write(chunk, (err) => (err ? reject(err) : resolve())); }); }, close() { return new Promise((resolve) => writable.end(() => resolve())); }, abort() { writable.destroy(); }, }); } function readableToWeb(readable) { return new ReadableStream({ start(controller) { readable.on("data", (chunk) => { controller.enqueue(typeof chunk === "string" ? new TextEncoder().encode(chunk) : Uint8Array.from(chunk)); }); readable.on("end", () => { try { controller.close(); } catch { // already closed } }); readable.on("error", (err) => controller.error(err)); }, cancel() { readable.destroy(); }, }); } export function normalizeTimeout(value, fallbackMs = 600000) { const timeout = Number(value ?? fallbackMs); if (!Number.isFinite(timeout) || timeout <= 0) return fallbackMs; return Math.floor(timeout); } function stringArg(value) { return typeof value === "string" && value.trim() ? value.trim() : undefined; } /** * Resolves which ACP agent command to launch for a step. Precedence: * payload.acpCommand → WFM_ACP_COMMAND env → preset for payload.acpAgent → preset * for the adapter key (claude-code / opencode / codex). Returns null when nothing * resolves (e.g. bare `acp` without configuration), which keeps the step on mock. */ export function resolveAcpCommand(step, env) { const payload = asRecord(step.taskSpec?.payload); const payloadArgs = Array.isArray(payload.acpArgs) ? payload.acpArgs.map((arg) => String(arg)) : []; const explicit = stringArg(payload.acpCommand) ?? stringArg(env.WFM_ACP_COMMAND); if (explicit) { return { command: explicit, args: payloadArgs }; } const presetKey = stringArg(payload.acpAgent) ?? resolveTaskAdapter(step.taskSpec?.adapterKey); const preset = ACP_COMMAND_PRESETS[presetKey]; if (preset) { return { command: preset.command, args: payloadArgs.length > 0 ? payloadArgs : [...preset.args] }; } return null; } // Adapters that run real through ACP by default. Everything else opts in with // payload.useRealAdapter: true; these opt OUT with payload.useRealAdapter: false. const REAL_BY_DEFAULT_ACP_ADAPTERS = new Set(["opencode"]); export function isRealByDefaultAcpAdapter(adapter) { return REAL_BY_DEFAULT_ACP_ADAPTERS.has(adapter); } export function shouldUseRealAcp(step) { const payload = asRecord(step.taskSpec?.payload); const adapter = resolveTaskAdapter(step.taskSpec?.adapterKey); const realEnabled = isRealByDefaultAcpAdapter(adapter) ? payload.useRealAdapter !== false : payload.useRealAdapter === true; return realEnabled && resolveAcpCommand(step, process.env) !== null; } function permissionPolicy(step) { const value = asRecord(step.taskSpec?.payload).acpPermissions; return value === "deny" || value === "reads-only" ? value : "allow"; } function decidePermission(req, policy) { const allow = req.options.find((option) => option.kind === "allow_once") ?? req.options.find((option) => option.kind === "allow_always"); const reject = req.options.find((option) => option.kind === "reject_once") ?? req.options.find((option) => option.kind === "reject_always"); let chooseAllow; if (policy === "allow") { chooseAllow = true; } else if (policy === "deny") { chooseAllow = false; } else { const kind = req.toolCall.kind ?? undefined; chooseAllow = kind ? READ_ONLY_TOOL_KINDS.has(kind) : false; } const chosen = chooseAllow ? (allow ?? req.options[0]) : reject; if (!chosen) { return { outcome: { outcome: "cancelled" } }; } return { outcome: { outcome: "selected", optionId: chosen.optionId } }; } function buildMcpServers(endpoints) { const servers = []; const skipped = []; for (const endpoint of endpoints) { if (/^https?:\/\//i.test(endpoint)) { servers.push({ type: "http", name: endpoint, url: endpoint, headers: [] }); } else { skipped.push(endpoint); } } return { servers, skipped }; } function composePrompt(step, input, workflow, workflowFilePath) { const payload = asRecord(step.taskSpec?.payload); const metrics = createContextMetricsBuilder(); if (typeof payload.prompt === "string" && payload.prompt.trim()) { metrics.addContext(payload.prompt); return { prompt: payload.prompt, metrics: metrics.build() }; } const parts = []; const systemPrompts = input.priming_configuration.system_prompts; if (systemPrompts.length > 0) { const joined = systemPrompts.join("\n"); parts.push(joined); metrics.addSystemPrompts(joined); } const skills = input.priming_configuration.required_skills; if (skills.length > 0) { const unresolved = []; for (const name of skills) { const resolved = workflow && workflowFilePath ? resolveSkill(name, workflow, workflowFilePath) : null; if (resolved) { parts.push(resolved.content); metrics.addSkill(name, resolved.content); } else { unresolved.push(name); } } if (unresolved.length > 0) { parts.push(`Apply the following skills: ${unresolved.join(", ")}`); } } const globalState = input.global_context.global_state; const inputLines = Object.entries(globalState) .filter(([, value]) => typeof value === "string" || typeof value === "number") .map(([key, value]) => `${key}: ${String(value).replace(/[\n\r]/g, " ")}`); if (inputLines.length > 0) { const block = `Input:\n${inputLines.join("\n")}`; parts.push(block); metrics.addGlobalState(block); } parts.push(input.step_context.step_objective); metrics.addObjective(input.step_context.step_objective); const previous = input.step_context.previous_output; for (const [key, val] of Object.entries(previous)) { const sections = previousOutputTextSections(val); if (sections.length > 0) { const block = `Output from ${key}:\n${sections.join("\n\n")}`; parts.push(block); metrics.addPreviousOutput(block); } } const context = input.priming_configuration.context; if (typeof context === "string" && context.trim()) { const block = `Context:\n${context}`; parts.push(block); metrics.addContext(block); } else if (context && typeof context === "object") { const serialized = JSON.stringify(context, null, 2); if (serialized !== "{}") { const block = `Context:\n${serialized}`; parts.push(block); metrics.addContext(block); } } return { prompt: parts.join("\n\n"), metrics: metrics.build() }; } function mapStopReason(stopReason) { switch (stopReason) { case "end_turn": return { status: "SUCCESS", action: "PROCEED", reason: "" }; case "refusal": return { status: "QA_REJECTED", action: "RETRY_CURRENT", reason: "agent refused the turn" }; case "max_tokens": return { status: "FAILED", action: "RETRY_CURRENT", reason: "agent stopped at max tokens" }; case "max_turn_requests": return { status: "FAILED", action: "RETRY_CURRENT", reason: "agent stopped at max turn requests" }; case "cancelled": return { status: "FAILED", action: "PROCEED", reason: "turn cancelled" }; default: return { status: "FAILED", action: "PROCEED", reason: `unknown stop reason: ${stopReason}` }; } } export function executeAcpStep(step, input, attempt, workflow, workflowFilePath, hooks) { const startedAt = Date.now(); const adapter = resolveTaskAdapter(step.taskSpec?.adapterKey); const payload = asRecord(step.taskSpec?.payload); const timeoutMs = normalizeTimeout(payload.timeoutMs); const resolved = resolveAcpCommand(step, process.env); const makeResult = (status, reason, extra = {}, action = "PROCEED") => ({ step_id: step.key, execution_status: status, qa_routing: { action, feedback_reason: reason }, mutated_payload: { stepKey: step.key, attempt, adapter, command: resolved?.command, args: resolved?.args, ...extra, }, metadata: { execution_time_ms: Date.now() - startedAt, external_intervention_required: false, }, }); if (!resolved) { return Promise.resolve(makeResult("FAILED", "no ACP agent command could be resolved for this step")); } const cwd = stringArg(payload.cwd) ?? (workflowFilePath ? path.dirname(path.resolve(workflowFilePath)) : process.cwd()); const policy = permissionPolicy(step); const { servers: mcpServers, skipped } = buildMcpServers(input.priming_configuration.mcp_endpoints); if (skipped.length > 0) { hooks?.onStderr?.(`[acp] ignoring non-http MCP endpoints (need structured config): ${skipped.join(", ")}\n`); } const { prompt: promptText, metrics: contextMetrics } = composePrompt(step, input, workflow, workflowFilePath); let child; try { child = spawn(resolved.command, resolved.args, { stdio: ["pipe", "pipe", "pipe"], cwd, env: process.env }); } catch (err) { return Promise.resolve(makeResult("FAILED", `failed to spawn ${resolved.command}: ${err.message}`)); } hooks?.onStarted?.({ command: resolved.command, args: resolved.args, cwd, timeoutMs, contextMetrics }); child.stderr?.setEncoding("utf-8"); child.stderr?.on("data", (chunk) => hooks?.onStderr?.(chunk)); // A ChildProcess "error" (e.g. ENOENT for a missing agent binary) is emitted // asynchronously and would crash the process if unhandled. const childError = new Promise((_resolve, reject) => { child.on("error", (err) => reject(new Error(`agent process error: ${err.message}`))); }); let agentText = ""; let sessionId; let timer; const client = { async sessionUpdate(params) { const update = params.update; if (update.sessionUpdate === "agent_message_chunk" && update.content.type === "text") { agentText += update.content.text; hooks?.onStdout?.(update.content.text); } else if (update.sessionUpdate === "tool_call") { hooks?.onStdout?.(`\n[acp tool] ${update.title}\n`); } }, async requestPermission(params) { return decidePermission(params, policy); }, async readTextFile(params) { return { content: fs.readFileSync(params.path, "utf-8") }; }, async writeTextFile(params) { fs.mkdirSync(path.dirname(params.path), { recursive: true }); fs.writeFileSync(params.path, params.content, "utf-8"); return {}; }, }; let conn; try { const stream = ndJsonStream(writableToWeb(child.stdin), readableToWeb(child.stdout)); conn = new ClientSideConnection(() => client, stream); } catch (err) { child.kill("SIGKILL"); return Promise.resolve(makeResult("FAILED", `failed to open ACP connection: ${err.message}`)); } const runTurn = async () => { await conn.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: { fs: { readTextFile: true, writeTextFile: true }, terminal: false }, }); const authMethod = stringArg(payload.acpAuthMethod) ?? stringArg(process.env.WFM_ACP_AUTH_METHOD); if (authMethod) { await conn.authenticate({ methodId: authMethod }); } const session = await conn.newSession({ cwd, mcpServers }); sessionId = session.sessionId; const prompt = [{ type: "text", text: promptText }]; const response = await conn.prompt({ sessionId: session.sessionId, prompt }); const mapped = mapStopReason(response.stopReason); return makeResult(mapped.status, mapped.reason, { stopReason: response.stopReason, output: agentText.trim(), prompt: promptText, contextMetrics }, mapped.action); }; const timeout = new Promise((resolve) => { timer = setTimeout(() => { if (sessionId) { conn.cancel({ sessionId }).catch(() => undefined); } resolve(makeResult("FAILED", `timed out after ${timeoutMs}ms`, { stopReason: "timeout", output: agentText.trim(), prompt: promptText, contextMetrics, })); }, timeoutMs); }); return Promise.race([runTurn(), timeout, childError]) .catch((err) => makeResult("FAILED", `ACP turn failed: ${err.message}`, { output: agentText.trim(), prompt: promptText, contextMetrics, })) .then((result) => { hooks?.onFinished?.({ executionStatus: result.execution_status }); return result; }) .finally(() => { if (timer) clearTimeout(timer); child.kill("SIGTERM"); }); }