feat: add receipt-backed chain workers
This commit is contained in:
parent
bb28b07f01
commit
b3fd68782a
|
|
@ -363,6 +363,9 @@ fable-agent plinius godmode "improve explanation quality"
|
|||
- `--skill <name>`
|
||||
- `fable5 chain <task>`
|
||||
- `--stages <list>`
|
||||
- `--worker-cmd <cmd>`
|
||||
- `--worker-args <args>`
|
||||
- `--receipt-dir <path>`
|
||||
- `fable5 meta <task>`
|
||||
- `--stages <list>`
|
||||
- `fable5 teams <task>`
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
|
|
@ -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<StageExecutorResult>;
|
||||
|
||||
export interface AgentChainOptions {
|
||||
executor?: StageExecutor;
|
||||
receiptDir?: string;
|
||||
}
|
||||
|
||||
const STAGE_ROLES: Record<ChainStage, string> = {
|
||||
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<ChainStage, TaskComplexity> = {
|
|||
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<ChainResult> {
|
||||
async run(task: string, stages?: ChainStage[], options: AgentChainOptions = {}): Promise<ChainResult> {
|
||||
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<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");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
17
src/index.ts
17
src/index.ts
|
|
@ -2574,10 +2574,18 @@ fable
|
|||
.command("chain <task>")
|
||||
.description("Run agent chain: Scout → Plan → Build → Review")
|
||||
.option("--stages <list>", "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 <cmd>", "Local worker command that reads the stage prompt from stdin")
|
||||
.option("--worker-args <args>", "JSON array of worker command args", "[]")
|
||||
.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 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)`);
|
||||
|
|
|
|||
Loading…
Reference in New Issue