UNPKG

@workflow-manager/runner

Version:

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

343 lines (342 loc) 14.7 kB
import { spawn } from "node:child_process"; import fs from "node:fs"; import os from "node:os"; import path from "node:path"; import { createContextMetricsBuilder } from "./contextMetrics.js"; import { resolveSkill } from "./skillResolver.js"; export const DEFAULT_PI_COMMAND = "pi"; function asRecord(value) { return value && typeof value === "object" && !Array.isArray(value) ? value : {}; } export function normalizeTimeout(value, fallbackMs = 600000) { const timeout = Number(value ?? fallbackMs); if (!Number.isFinite(timeout) || timeout <= 0) return fallbackMs; return Math.floor(timeout); } function makeResult(step, input, attempt, startedAt, status, reason, extra = {}, action = "PROCEED") { return { step_id: step.key, execution_status: status, qa_routing: { action, feedback_reason: reason }, mutated_payload: { stepKey: step.key, attempt, adapter: input.priming_configuration.adapter ?? "pi-agent", ...extra, }, metadata: { execution_time_ms: Date.now() - startedAt, external_intervention_required: false, }, }; } function resolveCommand(payload) { if (typeof payload.command === "string" && payload.command.trim()) return payload.command; if (process.env.WFM_PI_AGENT_COMMAND?.trim()) return process.env.WFM_PI_AGENT_COMMAND; return DEFAULT_PI_COMMAND; } function resolveArgs(payload) { return Array.isArray(payload.args) ? payload.args.map((arg) => String(arg)) : []; } function resolveRunDir(payload) { if (typeof payload.runDir === "string" && payload.runDir.trim()) { fs.mkdirSync(payload.runDir, { recursive: true }); return payload.runDir; } return fs.mkdtempSync(path.join(os.tmpdir(), "wfm-pi-agent-")); } function buildInputFile(step, input, attempt, runDir, workflow, workflowFilePath) { const resolvedSkills = []; if (workflow && workflowFilePath) { for (const name of input.priming_configuration.required_skills) { const resolved = resolveSkill(name, workflow, workflowFilePath); if (resolved) { resolvedSkills.push({ name, origin: resolved.origin, content: resolved.content }); } } } return { input_envelope: input, step, workflow: workflow ? { key: workflow.key, title: workflow.title, description: workflow.description, objectives: workflow.objectives, inputSchema: workflow.inputSchema, outputSchema: workflow.outputSchema, } : undefined, resolved_skills: resolvedSkills, run: { attempt, runDir }, }; } function sanitizeSkillName(name) { const safe = name.replace(/[^a-zA-Z0-9_.-]/g, "-"); return safe || "skill"; } function writeSkillFiles(runDir, skills) { const skillPaths = []; skills.forEach((skill, index) => { const skillDir = path.join(runDir, "skills", `${index}-${sanitizeSkillName(skill.name)}`); fs.mkdirSync(skillDir, { recursive: true }); const skillPath = path.join(skillDir, "SKILL.md"); fs.writeFileSync(skillPath, skill.content, "utf-8"); skillPaths.push(skillPath); }); return skillPaths; } function computeContextMetrics(input, resolvedSkills) { const metrics = createContextMetricsBuilder(); for (const systemPrompt of input.priming_configuration.system_prompts) { metrics.addSystemPrompts(systemPrompt); } for (const skill of resolvedSkills) { metrics.addSkill(skill.name, skill.content); } if (Object.keys(input.global_context.global_state).length > 0) { metrics.addGlobalState(JSON.stringify(input.global_context.global_state)); } if (Object.keys(input.step_context.previous_output).length > 0) { metrics.addPreviousOutput(JSON.stringify(input.step_context.previous_output)); } const context = input.priming_configuration.context; if (typeof context === "string" && context.trim()) { metrics.addContext(context); } else if (context && typeof context === "object" && Object.keys(context).length > 0) { metrics.addContext(JSON.stringify(context)); } metrics.addObjective(input.step_context.step_objective); return metrics.build(); } function describeContext(context) { if (typeof context === "string") { return context.trim() ? context : null; } if (context && Object.keys(context).length > 0) { return JSON.stringify(context, null, 2); } return null; } function buildPrompt(step, input, inputPath, outputPath) { const lines = ["You are executing a single step of an orchestrated workflow run."]; if (input.global_context.primary_objective.trim()) { lines.push("", `Workflow objective: ${input.global_context.primary_objective}`); } if (input.global_context.workflow_objectives.length > 0) { lines.push(`Workflow goals: ${input.global_context.workflow_objectives.join("; ")}`); } lines.push("", `Step key: ${step.key}`); const objective = step.objective ?? input.step_context.step_objective; if (objective.trim()) { lines.push(`Step objective: ${objective}`); } if (Object.keys(input.step_context.previous_output).length > 0) { lines.push("", "Previous step output:", JSON.stringify(input.step_context.previous_output, null, 2)); } const context = describeContext(input.priming_configuration.context); if (context) { lines.push("", "Additional context:", context); } if (input.priming_configuration.mcp_endpoints.length > 0) { lines.push("", `MCP endpoints declared for this step: ${input.priming_configuration.mcp_endpoints.join(", ")}`); } lines.push("", `The full structured step input is available at ${inputPath}.`, "", "Complete the step objective now.", `When you are done, write a JSON result envelope to ${outputPath} with this shape:`, `{"step_id":"${step.key}","execution_status":"SUCCESS"|"FAILED"|"QA_REJECTED","qa_routing":{"action":"PROCEED"|"RETRY_CURRENT"|"ROLLBACK_PREVIOUS"|"RESTART_ALL","feedback_reason":"<short reason>"},"mutated_payload":{<step results>},"metadata":{"execution_time_ms":0,"external_intervention_required":false}}`, "If you cannot write that file, simply finish with a clear final response; a successful exit is recorded as step success and your response text becomes the step output."); return lines.join("\n"); } function buildPiArgs(payload, input, skillPaths, prompt) { const args = resolveArgs(payload); args.push("--print", "--no-session"); if (input.priming_configuration.model) { args.push("--model", input.priming_configuration.model); } for (const systemPrompt of input.priming_configuration.system_prompts) { args.push("--append-system-prompt", systemPrompt); } for (const skillPath of skillPaths) { args.push("--skill", skillPath); } args.push(prompt); return args; } function normalizeOutputEnvelope(raw, step, input, attempt, startedAt, contextMetrics) { const record = asRecord(raw); const metadata = asRecord(record.metadata); const qaRouting = asRecord(record.qa_routing); const status = String(record.execution_status ?? "SUCCESS"); const action = String(qaRouting.action ?? "PROCEED"); return { step_id: typeof record.step_id === "string" && record.step_id.trim() ? record.step_id : step.key, execution_status: status, qa_routing: { action, feedback_reason: typeof qaRouting.feedback_reason === "string" ? qaRouting.feedback_reason : "", }, mutated_payload: { stepKey: step.key, attempt, adapter: input.priming_configuration.adapter ?? "pi-agent", contextMetrics, ...asRecord(record.mutated_payload), }, metadata: { execution_time_ms: typeof metadata.execution_time_ms === "number" ? metadata.execution_time_ms : Date.now() - startedAt, external_intervention_required: metadata.external_intervention_required === true, intervention_details: asRecord(metadata.intervention_details), }, }; } export function executePiAgentStep(step, input, attempt, workflow, workflowFilePath, hooks) { const startedAt = Date.now(); let payload; let timeoutMs; let runDir; let inputPath; let outputPath; let command; let args; let contextMetrics; try { payload = asRecord(step.taskSpec?.payload); timeoutMs = normalizeTimeout(payload.timeoutMs); runDir = resolveRunDir(payload); inputPath = path.join(runDir, "input.json"); outputPath = path.join(runDir, "output.json"); // A fixed runDir is reused across attempts; a leftover output.json would be // read as this attempt's result if the agent exits 0 without writing one. fs.rmSync(outputPath, { force: true }); command = resolveCommand(payload); const inputFile = buildInputFile(step, input, attempt, runDir, workflow, workflowFilePath); fs.writeFileSync(inputPath, JSON.stringify(inputFile, null, 2), "utf-8"); const skillPaths = writeSkillFiles(runDir, inputFile.resolved_skills); const prompt = buildPrompt(step, input, inputPath, outputPath); fs.writeFileSync(path.join(runDir, "prompt.txt"), prompt, "utf-8"); args = buildPiArgs(payload, input, skillPaths, prompt); contextMetrics = computeContextMetrics(input, inputFile.resolved_skills); } catch (err) { const result = makeResult(step, input, attempt, startedAt, "FAILED", `Pi setup failed: ${err.message}`); hooks?.onFinished?.({ executionStatus: result.execution_status }); return Promise.resolve(result); } return new Promise((resolve) => { let child; try { child = spawn(command, args, { stdio: ["ignore", "pipe", "pipe"], env: { ...process.env, WFM_PI_INPUT_FILE: inputPath, WFM_PI_OUTPUT_FILE: outputPath }, }); } catch (err) { resolve(makeResult(step, input, attempt, startedAt, "FAILED", err.message, { command, inputPath, outputPath, timeoutMs, contextMetrics, })); return; } hooks?.onStarted?.({ command, inputPath, outputPath, timeoutMs, contextMetrics }); const outChunks = []; const errChunks = []; child.stdout?.setEncoding("utf-8"); child.stderr?.setEncoding("utf-8"); child.stdout?.on("data", (chunk) => { outChunks.push(chunk); hooks?.onStdout?.(chunk); }); child.stderr?.on("data", (chunk) => { errChunks.push(chunk); hooks?.onStderr?.(chunk); }); let timedOut = false; const timer = setTimeout(() => { timedOut = true; const terminationSignal = "SIGTERM"; child.kill(terminationSignal); const result = makeResult(step, input, attempt, startedAt, "FAILED", `timed out after ${timeoutMs}ms`, { command, args, inputPath, outputPath, timeoutMs, timedOut: true, terminationSignal, stdout: outChunks.join(""), stderr: errChunks.join(""), contextMetrics, }); hooks?.onFinished?.({ executionStatus: result.execution_status, timedOut: true, terminationSignal }); resolve(result); }, timeoutMs); child.on("error", (err) => { clearTimeout(timer); if (timedOut) return; const result = makeResult(step, input, attempt, startedAt, "FAILED", err.message, { command, inputPath, outputPath, timeoutMs, stdout: outChunks.join(""), stderr: errChunks.join(""), contextMetrics, }); hooks?.onFinished?.({ executionStatus: result.execution_status }); resolve(result); }); child.on("close", (code) => { clearTimeout(timer); if (timedOut) return; const stdout = outChunks.join(""); const stderr = errChunks.join(""); const exitStatus = code ?? 1; if (exitStatus !== 0) { const result = makeResult(step, input, attempt, startedAt, "FAILED", `${command} exited with status ${exitStatus}`, { command, inputPath, outputPath, exitStatus, stdout, stderr, contextMetrics, }); hooks?.onFinished?.({ executionStatus: result.execution_status, exitStatus }); resolve(result); return; } if (!fs.existsSync(outputPath)) { const result = makeResult(step, input, attempt, startedAt, "SUCCESS", "pi completed without a result envelope; using response text", { command, inputPath, outputPath, exitStatus, response: stdout.trim(), contextMetrics }); hooks?.onFinished?.({ executionStatus: result.execution_status, exitStatus }); resolve(result); return; } try { const rawOutput = JSON.parse(fs.readFileSync(outputPath, "utf-8")); const result = normalizeOutputEnvelope(rawOutput, step, input, attempt, startedAt, contextMetrics); hooks?.onFinished?.({ executionStatus: result.execution_status, exitStatus }); resolve(result); } catch (err) { const result = makeResult(step, input, attempt, startedAt, "FAILED", `Invalid Pi result envelope: ${err.message}`, { command, inputPath, outputPath, exitStatus, stdout, stderr, contextMetrics, }); hooks?.onFinished?.({ executionStatus: result.execution_status, exitStatus }); resolve(result); } }); }); }