From b3fd68782a869b28e7f9c8e226eff6336f0702e2 Mon Sep 17 00:00:00 2001 From: artale Date: Sun, 28 Jun 2026 17:09:24 +0200 Subject: [PATCH] feat: add receipt-backed chain workers --- COMMANDS.md | 3 + src/fable5/agent-chains.test.ts | 35 +++++++++ src/fable5/agent-chains.ts | 122 ++++++++++++++++++++++++++++---- src/fable5/index.ts | 4 +- src/index.ts | 17 +++-- 5 files changed, 161 insertions(+), 20 deletions(-) create mode 100644 src/fable5/agent-chains.test.ts diff --git a/COMMANDS.md b/COMMANDS.md index f0d9c01..464d43f 100644 --- a/COMMANDS.md +++ b/COMMANDS.md @@ -363,6 +363,9 @@ fable-agent plinius godmode "improve explanation quality" - `--skill ` - `fable5 chain ` - `--stages ` + - `--worker-cmd ` + - `--worker-args ` + - `--receipt-dir ` - `fable5 meta ` - `--stages ` - `fable5 teams ` diff --git a/src/fable5/agent-chains.test.ts b/src/fable5/agent-chains.test.ts new file mode 100644 index 0000000..9683a69 --- /dev/null +++ b/src/fable5/agent-chains.test.ts @@ -0,0 +1,35 @@ +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; +import { describe, expect, it } from "vitest"; +import { AgentChain, createLocalCommandStageExecutor } from "./agent-chains.js"; + +describe("agent chain", () => { + it("records simulated stage receipts", async () => { + const chain = new AgentChain(); + const result = await chain.run("map current repo", ["scout"]); + + expect(result.artifacts).toHaveLength(1); + expect(result.artifacts[0].receipt.schema).toBe("fable.agent_chain.stage.v1"); + expect(result.artifacts[0].receipt.mode).toBe("simulated"); + expect(result.artifacts[0].receipt.deployAttempted).toBe(false); + expect(result.artifacts[0].receipt.promptHash).toMatch(/^[a-f0-9]{64}$/); + }); + + it("can run a local command worker and write receipts", async () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "agent-chain-")); + const script = path.join(dir, "worker.cjs"); + fs.writeFileSync(script, "process.stdin.on('data', d => process.stdout.write('worker saw ' + d.toString().split('\\n')[0]))"); + + const chain = new AgentChain(undefined, undefined, { + executor: createLocalCommandStageExecutor(process.execPath, [script]), + receiptDir: dir, + }); + const result = await chain.run("ship safe worker", ["scout"]); + + expect(result.artifacts[0].output).toContain("worker saw # Agent Chain: SCOUT"); + expect(result.artifacts[0].receipt.mode).toBe("local-command"); + expect(result.artifacts[0].receiptPath).toBeTruthy(); + expect(fs.readFileSync(result.artifacts[0].receiptPath!, "utf-8")).toContain("fable.agent_chain.stage.v1"); + }); +}); diff --git a/src/fable5/agent-chains.ts b/src/fable5/agent-chains.ts index 0a39d46..23573fc 100644 --- a/src/fable5/agent-chains.ts +++ b/src/fable5/agent-chains.ts @@ -1,3 +1,7 @@ +import * as crypto from "node:crypto"; +import * as fs from "node:fs"; +import * as path from "node:path"; +import { spawn } from "node:child_process"; import { ModelRouter, type TaskComplexity, type RoutingResponse } from "./model-router.js"; import { IndependentVerifier, type VerificationResult } from "./independent-verifier.js"; @@ -9,6 +13,18 @@ export interface ChainConfig { complexity: TaskComplexity; } +export interface StageExecutionReceipt { + schema: "fable.agent_chain.stage.v1"; + stage: ChainStage; + worker: string; + mode: "simulated" | "local-command"; + promptHash: string; + outputHash: string; + command?: string[]; + deployAttempted: false; + timestamp: string; +} + export interface StageArtifact { stage: ChainStage; output: string; @@ -16,6 +32,8 @@ export interface StageArtifact { modelName: string; durationMs: number; timestamp: string; + receipt: StageExecutionReceipt; + receiptPath?: string; } export interface ChainResult { @@ -26,6 +44,28 @@ export interface ChainResult { totalDurationMs: number; } +export interface StageExecutorInput { + stage: ChainStage; + task: string; + prompt: string; + route: RoutingResponse; + previous: StageArtifact[]; +} + +export interface StageExecutorResult { + output: string; + worker?: string; + mode?: StageExecutionReceipt["mode"]; + command?: string[]; +} + +export type StageExecutor = (input: StageExecutorInput) => Promise; + +export interface AgentChainOptions { + executor?: StageExecutor; + receiptDir?: string; +} + const STAGE_ROLES: Record = { scout: "Explore the codebase, understand the existing architecture, find relevant files, identify patterns, constraints, and prior decisions.", plan: "Synthesize scout findings into a structured plan: file changes needed, order of operations, testing strategy, rollback plan.", @@ -43,13 +83,16 @@ const STAGE_TIERS: Record = { export class AgentChain { private modelRouter: ModelRouter; private verifier: IndependentVerifier; + private options: AgentChainOptions; - constructor(modelRouter?: ModelRouter, verifier?: IndependentVerifier) { + constructor(modelRouter?: ModelRouter, verifier?: IndependentVerifier, options: AgentChainOptions = {}) { this.modelRouter = modelRouter ?? new ModelRouter(); this.verifier = verifier ?? new IndependentVerifier(); + this.options = options; } - async run(task: string, stages?: ChainStage[]): Promise { + async run(task: string, stages?: ChainStage[], options: AgentChainOptions = {}): Promise { + const merged = { ...this.options, ...options }; const config: ChainConfig = { stages: stages ?? ["scout", "plan", "build", "review"], task, @@ -70,29 +113,42 @@ export class AgentChain { requiresVision: false, }); - // Simulate stage execution using model routing + role prompt const prompt = this.buildStagePrompt(stage, task, artifacts); - const output = await this.executeStage(stage, prompt, route); + const executed = await (merged.executor ?? defaultStageExecutor)({ stage, task, prompt, route, previous: artifacts }); + const timestamp = new Date().toISOString(); + const receipt: StageExecutionReceipt = { + schema: "fable.agent_chain.stage.v1", + stage, + worker: executed.worker ?? route.primary.displayName, + mode: executed.mode ?? "simulated", + promptHash: sha256(prompt), + outputHash: sha256(executed.output), + command: executed.command, + deployAttempted: false, + timestamp, + }; + const receiptPath = merged.receiptDir ? writeStageReceipt(merged.receiptDir, receipt) : undefined; const artifact: StageArtifact = { stage, - output, + output: executed.output, modelTier: route.primary.tier, modelName: route.primary.displayName, durationMs: Date.now() - stageStart, - timestamp: new Date().toISOString(), + timestamp, + receipt, + receiptPath, }; artifacts.push(artifact); - // Verify after build and review stages if (stage === "build" || stage === "review") { const iteration = { number: config.stages.indexOf(stage) + 1, phase: "execute" as const, plan: `Stage ${stage}: ${task}`, - executed: output, + executed: executed.output, observation: `Completed ${stage} phase`, - reflection: `${stage} phase output: ${output.slice(0, 100)}`, + reflection: `${stage} phase output: ${executed.output.slice(0, 100)}`, refinement: "", metrics: { durationMs: artifact.durationMs, successRate: 0.9, qualityScore: 0.7, improvementDelta: 0.1 }, timestamp: new Date().toISOString(), @@ -102,7 +158,6 @@ export class AgentChain { } const passed = verifications.length === 0 || verifications.every((v) => v.verdict === "PASS" || v.verdict === "PARTIAL"); - return { task, artifacts, verifications, passed, totalDurationMs: Date.now() - startTime }; } @@ -121,8 +176,47 @@ export class AgentChain { return parts.join("\n"); } - - private async executeStage(_stage: ChainStage, _prompt: string, _route: RoutingResponse): Promise { - return `[${_stage.toUpperCase()}] Executed with ${_route.primary.displayName} (${_route.primary.tier}) for: ${_prompt.slice(0, 60)}...`; - } +} + +export function createLocalCommandStageExecutor(command: string, args: string[] = [], cwd = process.cwd()): StageExecutor { + return async ({ prompt, stage }) => { + const output = await runLocalCommand(command, args, prompt, cwd); + return { output, worker: command, mode: "local-command", command: [command, ...args, `stdin:${stage}`] }; + }; +} + +async function defaultStageExecutor({ stage, prompt, route }: StageExecutorInput): Promise { + return { + output: `[${stage.toUpperCase()}] Executed with ${route.primary.displayName} (${route.primary.tier}) for: ${prompt.slice(0, 60)}...`, + worker: route.primary.displayName, + mode: "simulated", + }; +} + +function runLocalCommand(command: string, args: string[], input: string, cwd: string): Promise { + return new Promise((resolve, reject) => { + const child = spawn(command, args, { cwd, shell: false, windowsHide: true, stdio: ["pipe", "pipe", "pipe"] }); + let stdout = ""; + let stderr = ""; + child.stdout.on("data", (chunk: Buffer) => { stdout += chunk.toString(); }); + child.stderr.on("data", (chunk: Buffer) => { stderr += chunk.toString(); }); + child.on("error", reject); + child.on("close", (code) => { + const output = `${stdout}${stderr ? `\n[stderr]\n${stderr}` : ""}`.slice(0, 20000); + if (code !== 0) reject(new Error(`worker command exited ${code}: ${output.slice(0, 500)}`)); + else resolve(output); + }); + child.stdin.end(input); + }); +} + +function writeStageReceipt(dir: string, receipt: StageExecutionReceipt): string { + fs.mkdirSync(dir, { recursive: true }); + const file = path.join(dir, `${receipt.stage}-${receipt.timestamp.replace(/[:.]/g, "-")}.json`); + fs.writeFileSync(file, `${JSON.stringify(receipt, null, 2)}\n`); + return file; +} + +function sha256(value: string): string { + return crypto.createHash("sha256").update(value).digest("hex"); } diff --git a/src/fable5/index.ts b/src/fable5/index.ts index 6e99d88..cb4c084 100644 --- a/src/fable5/index.ts +++ b/src/fable5/index.ts @@ -30,8 +30,8 @@ export type { LoopUntilDoneResult, } from "./dynamic-workflows.js"; -export { AgentChain } from "./agent-chains.js"; -export type { ChainStage, ChainConfig, StageArtifact, ChainResult } from "./agent-chains.js"; +export { AgentChain, createLocalCommandStageExecutor } from "./agent-chains.js"; +export type { AgentChainOptions, ChainStage, ChainConfig, StageArtifact, ChainResult, StageExecutionReceipt, StageExecutor, StageExecutorInput, StageExecutorResult } from "./agent-chains.js"; export { MetaAgent } from "./meta-agent.js"; export type { SubAgentSpec, MetaAgentConfig, MetaAgentResult } from "./meta-agent.js"; diff --git a/src/index.ts b/src/index.ts index 813ca04..f77dc6a 100644 --- a/src/index.ts +++ b/src/index.ts @@ -2574,10 +2574,18 @@ fable .command("chain ") .description("Run agent chain: Scout → Plan → Build → Review") .option("--stages ", "Comma-separated stages (scout,plan,build,review)") - .action(async (task: string, opts: { stages?: string }) => { - const { AgentChain } = await import("./fable5/agent-chains.js"); - const chain = new AgentChain(); + .option("--worker-cmd ", "Local worker command that reads the stage prompt from stdin") + .option("--worker-args ", "JSON array of worker command args", "[]") + .option("--receipt-dir ", "Directory for stage execution receipts") + .action(async (task: string, opts: { stages?: string; workerCmd?: string; workerArgs?: string; receiptDir?: string }) => { + const { AgentChain, createLocalCommandStageExecutor } = await import("./fable5/agent-chains.js"); const stages = opts.stages?.split(",").map((s) => s.trim()) as import("./fable5/agent-chains.js").ChainStage[] | undefined; + const workerArgs = JSON.parse(opts.workerArgs ?? "[]") as string[]; + if (!Array.isArray(workerArgs) || workerArgs.some((arg) => typeof arg !== "string")) throw new Error("--worker-args must be a JSON array of strings"); + const chain = new AgentChain(undefined, undefined, { + executor: opts.workerCmd ? createLocalCommandStageExecutor(opts.workerCmd, workerArgs) : undefined, + receiptDir: opts.receiptDir, + }); const result = await chain.run(task, stages); console.log(`\n Agent Chain Results`); @@ -2587,7 +2595,8 @@ fable console.log(` Passed: ${result.passed ? "✓" : "✗"}`); console.log(` `); for (const a of result.artifacts) { - console.log(` ${a.stage.toUpperCase().padEnd(10)} ${a.modelName.padEnd(30)} ${a.durationMs}ms`); + console.log(` ${a.stage.toUpperCase().padEnd(10)} ${a.receipt.worker.padEnd(30)} ${a.receipt.mode.padEnd(13)} ${a.durationMs}ms`); + if (a.receiptPath) console.log(` receipt: ${a.receiptPath}`); } for (const v of result.verifications) { console.log(` Verdict: ${v.verdict} (${v.criteriaResults.filter((c) => c.passed).length}/${v.criteriaResults.length} criteria passed)`);