feat: add receipt-backed chain workers

This commit is contained in:
artale 2026-06-28 17:09:24 +02:00
parent bb28b07f01
commit b3fd68782a
5 changed files with 161 additions and 20 deletions

View File

@ -363,6 +363,9 @@ fable-agent plinius godmode "improve explanation quality"
- `--skill <name>` - `--skill <name>`
- `fable5 chain <task>` - `fable5 chain <task>`
- `--stages <list>` - `--stages <list>`
- `--worker-cmd <cmd>`
- `--worker-args <args>`
- `--receipt-dir <path>`
- `fable5 meta <task>` - `fable5 meta <task>`
- `--stages <list>` - `--stages <list>`
- `fable5 teams <task>` - `fable5 teams <task>`

View File

@ -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");
});
});

View File

@ -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 { ModelRouter, type TaskComplexity, type RoutingResponse } from "./model-router.js";
import { IndependentVerifier, type VerificationResult } from "./independent-verifier.js"; import { IndependentVerifier, type VerificationResult } from "./independent-verifier.js";
@ -9,6 +13,18 @@ export interface ChainConfig {
complexity: TaskComplexity; 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 { export interface StageArtifact {
stage: ChainStage; stage: ChainStage;
output: string; output: string;
@ -16,6 +32,8 @@ export interface StageArtifact {
modelName: string; modelName: string;
durationMs: number; durationMs: number;
timestamp: string; timestamp: string;
receipt: StageExecutionReceipt;
receiptPath?: string;
} }
export interface ChainResult { export interface ChainResult {
@ -26,6 +44,28 @@ export interface ChainResult {
totalDurationMs: number; 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<StageExecutorResult>;
export interface AgentChainOptions {
executor?: StageExecutor;
receiptDir?: string;
}
const STAGE_ROLES: Record<ChainStage, string> = { const STAGE_ROLES: Record<ChainStage, string> = {
scout: "Explore the codebase, understand the existing architecture, find relevant files, identify patterns, constraints, and prior decisions.", 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.", 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<ChainStage, TaskComplexity> = {
export class AgentChain { export class AgentChain {
private modelRouter: ModelRouter; private modelRouter: ModelRouter;
private verifier: IndependentVerifier; private verifier: IndependentVerifier;
private options: AgentChainOptions;
constructor(modelRouter?: ModelRouter, verifier?: IndependentVerifier) { constructor(modelRouter?: ModelRouter, verifier?: IndependentVerifier, options: AgentChainOptions = {}) {
this.modelRouter = modelRouter ?? new ModelRouter(); this.modelRouter = modelRouter ?? new ModelRouter();
this.verifier = verifier ?? new IndependentVerifier(); this.verifier = verifier ?? new IndependentVerifier();
this.options = options;
} }
async run(task: string, stages?: ChainStage[]): Promise<ChainResult> { async run(task: string, stages?: ChainStage[], options: AgentChainOptions = {}): Promise<ChainResult> {
const merged = { ...this.options, ...options };
const config: ChainConfig = { const config: ChainConfig = {
stages: stages ?? ["scout", "plan", "build", "review"], stages: stages ?? ["scout", "plan", "build", "review"],
task, task,
@ -70,29 +113,42 @@ export class AgentChain {
requiresVision: false, requiresVision: false,
}); });
// Simulate stage execution using model routing + role prompt
const prompt = this.buildStagePrompt(stage, task, artifacts); 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 = { const artifact: StageArtifact = {
stage, stage,
output, output: executed.output,
modelTier: route.primary.tier, modelTier: route.primary.tier,
modelName: route.primary.displayName, modelName: route.primary.displayName,
durationMs: Date.now() - stageStart, durationMs: Date.now() - stageStart,
timestamp: new Date().toISOString(), timestamp,
receipt,
receiptPath,
}; };
artifacts.push(artifact); artifacts.push(artifact);
// Verify after build and review stages
if (stage === "build" || stage === "review") { if (stage === "build" || stage === "review") {
const iteration = { const iteration = {
number: config.stages.indexOf(stage) + 1, number: config.stages.indexOf(stage) + 1,
phase: "execute" as const, phase: "execute" as const,
plan: `Stage ${stage}: ${task}`, plan: `Stage ${stage}: ${task}`,
executed: output, executed: executed.output,
observation: `Completed ${stage} phase`, observation: `Completed ${stage} phase`,
reflection: `${stage} phase output: ${output.slice(0, 100)}`, reflection: `${stage} phase output: ${executed.output.slice(0, 100)}`,
refinement: "", refinement: "",
metrics: { durationMs: artifact.durationMs, successRate: 0.9, qualityScore: 0.7, improvementDelta: 0.1 }, metrics: { durationMs: artifact.durationMs, successRate: 0.9, qualityScore: 0.7, improvementDelta: 0.1 },
timestamp: new Date().toISOString(), 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"); const passed = verifications.length === 0 || verifications.every((v) => v.verdict === "PASS" || v.verdict === "PARTIAL");
return { task, artifacts, verifications, passed, totalDurationMs: Date.now() - startTime }; return { task, artifacts, verifications, passed, totalDurationMs: Date.now() - startTime };
} }
@ -121,8 +176,47 @@ export class AgentChain {
return parts.join("\n"); return parts.join("\n");
} }
}
private async executeStage(_stage: ChainStage, _prompt: string, _route: RoutingResponse): Promise<string> {
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<StageExecutorResult> {
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<string> {
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");
} }

View File

@ -30,8 +30,8 @@ export type {
LoopUntilDoneResult, LoopUntilDoneResult,
} from "./dynamic-workflows.js"; } from "./dynamic-workflows.js";
export { AgentChain } from "./agent-chains.js"; export { AgentChain, createLocalCommandStageExecutor } from "./agent-chains.js";
export type { ChainStage, ChainConfig, StageArtifact, ChainResult } 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 { MetaAgent } from "./meta-agent.js";
export type { SubAgentSpec, MetaAgentConfig, MetaAgentResult } from "./meta-agent.js"; export type { SubAgentSpec, MetaAgentConfig, MetaAgentResult } from "./meta-agent.js";

View File

@ -2574,10 +2574,18 @@ fable
.command("chain <task>") .command("chain <task>")
.description("Run agent chain: Scout → Plan → Build → Review") .description("Run agent chain: Scout → Plan → Build → Review")
.option("--stages <list>", "Comma-separated stages (scout,plan,build,review)") .option("--stages <list>", "Comma-separated stages (scout,plan,build,review)")
.action(async (task: string, opts: { stages?: string }) => { .option("--worker-cmd <cmd>", "Local worker command that reads the stage prompt from stdin")
const { AgentChain } = await import("./fable5/agent-chains.js"); .option("--worker-args <args>", "JSON array of worker command args", "[]")
const chain = new AgentChain(); .option("--receipt-dir <path>", "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 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); const result = await chain.run(task, stages);
console.log(`\n Agent Chain Results`); console.log(`\n Agent Chain Results`);
@ -2587,7 +2595,8 @@ fable
console.log(` Passed: ${result.passed ? "✓" : "✗"}`); console.log(` Passed: ${result.passed ? "✓" : "✗"}`);
console.log(` `); console.log(` `);
for (const a of result.artifacts) { 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) { for (const v of result.verifications) {
console.log(` Verdict: ${v.verdict} (${v.criteriaResults.filter((c) => c.passed).length}/${v.criteriaResults.length} criteria passed)`); console.log(` Verdict: ${v.verdict} (${v.criteriaResults.filter((c) => c.passed).length}/${v.criteriaResults.length} criteria passed)`);