Compare commits

...

6 Commits

Author SHA1 Message Date
xiaoju 1a685583bd feat: tester role + develop workflow template
- New workflow-role-tester: runs tests/build/lint, reports pass/fail
- Committer: removed push, only creates branch and commits
- New workflow-template-develop: planner → coder ⟲ → reviewer ⟲ → tester → committer
- 173 tests passing

Fixes #58
2026-05-07 13:42:01 +00:00
xiaoju 7f64541c5b Merge pull request 'feat: ReAct ExtractFn with tool-use' (#53) from feat/44-react-extract into main 2026-05-07 13:28:22 +00:00
xiaoju 43a6600378 feat: ReAct ExtractFn with tool-use
- RoleDefinition.extractMode: "single" | "react"
- reactExtract: multi-turn LLM with cas_get tool for DAG traversal
- Max 10 tool-call rounds, schema validation on final output
- create-workflow routes to reactExtract when extractMode is "react"
- All existing roles set to "single" (no behavior change)
- 162 tests passing

Fixes #44
2026-05-07 13:28:00 +00:00
xiaoju 220c9c5224 Merge pull request 'feat: global extract provider config' (#52) from feat/43-extract-provider-config into main 2026-05-07 13:21:57 +00:00
xiaoju cae59b589e feat: global extract provider config
- workflow.yaml supports config section (maxDepth, extract provider)
- ExtractProviderConfig with env: prefix for apiKey resolution
- getExtractProvider(storageRoot) returns LlmProvider from config
- workflowAsAgent uses config maxDepth (fallback 3)
- Registry read/write preserves config
- 158 tests passing

Fixes #43
2026-05-07 13:21:38 +00:00
xiaoju b5cc0db17e Merge pull request 'feat: thread root node + workflowAsAgent returns root hash' (#51) from feat/42-thread-root-node into main 2026-05-07 13:18:13 +00:00
40 changed files with 1722 additions and 58 deletions
+2
View File
@@ -29,6 +29,7 @@ const greeter: RoleDefinition<Roles["greeter"]> = {
extractPrompt: "Extract the greeting string produced for the user.", extractPrompt: "Extract the greeting string produced for the user.",
schema: greeterMetaSchema, schema: greeterMetaSchema,
extractRefs: null, extractRefs: null,
extractMode: "single",
}; };
const extract = createExtract({ const extract = createExtract({
@@ -48,4 +49,5 @@ export const run = createWorkflow<Roles>(
agent: async (ctx) => `Hello, ${ctx.start.content}`, agent: async (ctx) => `Hello, ${ctx.start.content}`,
}, },
extract, extract,
null,
); );
@@ -44,6 +44,7 @@ export async function cmdRollback(
} }
const nextRegistry = { const nextRegistry = {
config: reg.value.config,
workflows: { ...reg.value.workflows, [name]: rolled.value }, workflows: { ...reg.value.workflows, [name]: rolled.value },
}; };
const written = await writeWorkflowRegistry(storageRoot, nextRegistry); const written = await writeWorkflowRegistry(storageRoot, nextRegistry);
@@ -39,4 +39,5 @@ export const coderRole: RoleDefinition<CoderMeta> = {
"Extract completedPhase: the planner phase hash finished this round (exact hash string from the plan). If multiple phases were finished in one round, use the last finished phase hash. Extract filesChanged and a summary of the work.", "Extract completedPhase: the planner phase hash finished this round (exact hash string from the plan). If multiple phases were finished in one round, use the last finished phase hash. Extract filesChanged and a summary of the work.",
schema: coderMetaSchema, schema: coderMetaSchema,
extractRefs: (meta) => [meta.completedPhase], extractRefs: (meta) => [meta.completedPhase],
extractMode: "single",
}; };
@@ -21,15 +21,16 @@ export const committerMetaSchema = z.discriminatedUnion("status", [
export type CommitterMeta = z.infer<typeof committerMetaSchema>; export type CommitterMeta = z.infer<typeof committerMetaSchema>;
const COMMITTER_SYSTEM = `You are the git committer. Create a branch, commit the changes, and push. const COMMITTER_SYSTEM = `You are the git committer. Create a branch and commit the changes.
Report the branch name and commit SHA. On failure, classify as recoverable or unrecoverable. Report the branch name and commit SHA. On failure, classify as recoverable or unrecoverable.
Do not attempt to fix failures yourself.`; Do not attempt to fix failures yourself.`;
export const committerRole: RoleDefinition<CommitterMeta> = { export const committerRole: RoleDefinition<CommitterMeta> = {
description: "Creates branch, commits, and pushes when review passes.", description: "Creates a branch and commits changes.",
systemPrompt: COMMITTER_SYSTEM, systemPrompt: COMMITTER_SYSTEM,
extractPrompt: extractPrompt:
"Extract the commit result: committed (with branch and SHA), recoverable failure, or unrecoverable failure. Include error details and log references if applicable.", "Extract the commit result: committed (with branch and SHA), recoverable failure, or unrecoverable failure. Include error details and log references if applicable.",
schema: committerMetaSchema, schema: committerMetaSchema,
extractRefs: null, extractRefs: null,
extractMode: "single",
}; };
@@ -50,4 +50,5 @@ export const plannerRole: RoleDefinition<PlannerMeta> = {
"Extract the implementation phases from the agent's output. Each phase has a hash (the CAS content-hash returned by the cas put command) and a title (one-line summary).", "Extract the implementation phases from the agent's output. Each phase has a hash (the CAS content-hash returned by the cas put command) and a title (one-line summary).",
schema: plannerMetaSchema, schema: plannerMetaSchema,
extractRefs: (meta) => meta.phases.map((p) => p.hash), extractRefs: (meta) => meta.phases.map((p) => p.hash),
extractMode: "single",
}; };
@@ -48,4 +48,5 @@ export const preparerRole: RoleDefinition<PreparerMeta> = {
"Extract repoPath (absolute path), defaultBranch, conventions (summary string or null), and toolchain (packageManager, testCommand, lintCommand, buildCommand — each string or null).", "Extract repoPath (absolute path), defaultBranch, conventions (summary string or null), and toolchain (packageManager, testCommand, lintCommand, buildCommand — each string or null).",
schema: preparerMetaSchema, schema: preparerMetaSchema,
extractRefs: null, extractRefs: null,
extractMode: "single",
}; };
@@ -22,4 +22,5 @@ export const reviewerRole: RoleDefinition<ReviewerMeta> = {
"Extract the review verdict: approved or rejected. If rejected, list the blocking issues.", "Extract the review verdict: approved or rejected. If rejected, list the blocking issues.",
schema: reviewerMetaSchema, schema: reviewerMetaSchema,
extractRefs: null, extractRefs: null,
extractMode: "single",
}; };
@@ -0,0 +1,15 @@
{
"name": "@uncaged/workflow-role-tester",
"version": "0.1.0",
"type": "module",
"main": "src/index.ts",
"types": "src/index.ts",
"scripts": {
"build": "echo 'TODO'",
"test": "bun test"
},
"dependencies": {
"@uncaged/workflow": "workspace:*",
"zod": "^4.0.0"
}
}
@@ -0,0 +1 @@
export { type TesterMeta, testerMetaSchema, testerRole } from "./tester.js";
@@ -0,0 +1,27 @@
import type { RoleDefinition } from "@uncaged/workflow";
import * as z from "zod/v4";
export const testerMetaSchema = z.discriminatedUnion("status", [
z.object({
status: z.literal("passed"),
details: z.string(),
}),
z.object({
status: z.literal("failed"),
details: z.string(),
}),
]);
export type TesterMeta = z.infer<typeof testerMetaSchema>;
const TESTER_SYSTEM = `You are a tester. Run the project's test suite, build, and lint commands. Check what commands are available from the preparer's output in the thread. Report pass/fail with details of what failed.`;
export const testerRole: RoleDefinition<TesterMeta> = {
description: "Runs test, build, and lint commands and reports pass or fail with details.",
systemPrompt: TESTER_SYSTEM,
extractPrompt:
"Extract the verification result: passed with summary details, or failed with details of what broke.",
schema: testerMetaSchema,
extractRefs: null,
extractMode: "single",
};
@@ -0,0 +1,10 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "dist",
"composite": true
},
"include": ["src/**/*.ts"],
"references": [{ "path": "../workflow" }]
}
@@ -0,0 +1,260 @@
import { describe, expect, test } from "bun:test";
import {
END,
type ModeratorContext,
type RoleStep,
START,
validateWorkflowDescriptor,
} from "@uncaged/workflow";
import type { CommitterMeta } from "@uncaged/workflow-role-committer";
import type { PlannerMeta } from "@uncaged/workflow-role-planner";
import { buildDevelopDescriptor } from "../src/descriptor.js";
import { developModerator } from "../src/index.js";
import type { DevelopMeta } from "../src/roles.js";
const DEFAULT_PHASES: PlannerMeta["phases"] = [
{
hash: "4KNMR2PX",
title: "Do the work",
},
];
function makeStart(maxRounds: number): ModeratorContext<DevelopMeta>["start"] {
return {
role: START,
content: "Implement the feature",
meta: { maxRounds },
timestamp: 0,
};
}
function makeCtx(
maxRounds: number,
steps: ModeratorContext<DevelopMeta>["steps"],
): ModeratorContext<DevelopMeta> {
return {
threadId: "01TEST000000000000000000TR",
depth: 0,
start: makeStart(maxRounds),
steps,
};
}
function plannerStep(phases: PlannerMeta["phases"] = DEFAULT_PHASES): RoleStep<DevelopMeta> {
return {
role: "planner",
contentHash: "STUBHASHPLANNER001",
meta: { phases },
refs: phases.map((p) => p.hash),
timestamp: 1,
};
}
function coderStep(completedPhase = "4KNMR2PX"): RoleStep<DevelopMeta> {
return {
role: "coder",
contentHash: "STUBHASHCODER00001",
meta: { completedPhase, filesChanged: ["a.ts"], summary: "implemented" },
refs: [completedPhase],
timestamp: 2,
};
}
function reviewerStep(approved: boolean): RoleStep<DevelopMeta> {
return {
role: "reviewer",
contentHash: "STUBHASHREVIEWER01",
meta: approved
? { status: "approved" as const }
: { status: "rejected" as const, issues: ["needs fix"] },
refs: [],
timestamp: 3,
};
}
function testerStep(passed: boolean): RoleStep<DevelopMeta> {
return {
role: "tester",
contentHash: "STUBHASHTESTER01",
meta: passed
? { status: "passed" as const, details: "all checks passed" }
: { status: "failed" as const, details: "lint failed" },
refs: [],
timestamp: 4,
};
}
function committerStep(meta: CommitterMeta): RoleStep<DevelopMeta> {
return {
role: "committer",
contentHash: "STUBHASHCOMMITTER1",
meta,
refs: [],
timestamp: 5,
};
}
describe("developModerator", () => {
test("routes initial → planner → coder → reviewer → tester → committer → END", () => {
expect(developModerator(makeCtx(20, []))).toBe("planner");
expect(developModerator(makeCtx(20, [plannerStep()]))).toBe("coder");
expect(developModerator(makeCtx(20, [plannerStep(), coderStep()]))).toBe("reviewer");
expect(developModerator(makeCtx(20, [plannerStep(), coderStep(), reviewerStep(true)]))).toBe(
"tester",
);
expect(
developModerator(
makeCtx(20, [plannerStep(), coderStep(), reviewerStep(true), testerStep(true)]),
),
).toBe("committer");
expect(
developModerator(
makeCtx(20, [
plannerStep(),
coderStep(),
reviewerStep(true),
testerStep(true),
committerStep({ status: "committed", branch: "feat/x", commitSha: "abc1234" }),
]),
),
).toBe(END);
});
test("reviewer rejects → coder retry when budget allows", () => {
const steps: ModeratorContext<DevelopMeta>["steps"] = [
plannerStep(),
coderStep(),
reviewerStep(false),
];
expect(developModerator(makeCtx(20, steps))).toBe("coder");
});
test("reviewer rejects → END when max rounds exhausted", () => {
const steps: ModeratorContext<DevelopMeta>["steps"] = [
plannerStep(),
coderStep(),
reviewerStep(false),
];
expect(developModerator(makeCtx(4, steps))).toBe(END);
});
test("tester failed → coder retry when budget allows", () => {
const steps: ModeratorContext<DevelopMeta>["steps"] = [
plannerStep(),
coderStep(),
reviewerStep(true),
testerStep(false),
];
expect(developModerator(makeCtx(20, steps))).toBe("coder");
});
test("tester failed → END when max rounds exhausted", () => {
const steps: ModeratorContext<DevelopMeta>["steps"] = [
plannerStep(),
coderStep(),
reviewerStep(true),
testerStep(false),
];
expect(developModerator(makeCtx(5, steps))).toBe(END);
});
test("multiple planner phases → coder until all complete, then reviewer", () => {
const phases: PlannerMeta["phases"] = [
{ hash: "AA000001", title: "first phase" },
{ hash: "AA000002", title: "second phase" },
];
expect(developModerator(makeCtx(20, [plannerStep(phases)]))).toBe("coder");
expect(developModerator(makeCtx(20, [plannerStep(phases), coderStep("AA000001")]))).toBe(
"coder",
);
expect(
developModerator(
makeCtx(20, [plannerStep(phases), coderStep("AA000001"), coderStep("AA000002")]),
),
).toBe("reviewer");
});
test("one-shot coder reports only last phase hash → reviewer (moderator treats as all phases done)", () => {
const phases: PlannerMeta["phases"] = [
{ hash: "BB000001", title: "setup branch" },
{ hash: "BB000002", title: "write tests" },
{ hash: "BB000003", title: "verify" },
{ hash: "BB000004", title: "polish" },
];
expect(developModerator(makeCtx(20, [plannerStep(phases), coderStep("BB000004")]))).toBe(
"reviewer",
);
});
test("unrecognised completedPhase hash → coder retry when budget allows", () => {
const phases: PlannerMeta["phases"] = [
{ hash: "CC000001", title: "first phase" },
{ hash: "CC000002", title: "second phase" },
];
expect(developModerator(makeCtx(20, [plannerStep(phases), coderStep("all-done")]))).toBe(
"coder",
);
});
test("incomplete phases → END when max rounds exhausted", () => {
const phases: PlannerMeta["phases"] = [
{ hash: "DD000001", title: "first phase" },
{ hash: "DD000002", title: "second phase" },
];
const steps: ModeratorContext<DevelopMeta>["steps"] = [
plannerStep(phases),
coderStep("DD000001"),
];
expect(developModerator(makeCtx(3, steps))).toBe(END);
});
test("committer → END for any committer meta status", () => {
const committed = committerStep({ status: "committed", branch: "f", commitSha: "x" });
const recoverable = committerStep({
status: "recoverable",
error: "merge conflict",
logRef: null,
});
const unrecoverable = committerStep({
status: "unrecoverable",
error: "repo missing",
logRef: "log1",
});
const base: ModeratorContext<DevelopMeta>["steps"] = [
plannerStep(),
coderStep(),
reviewerStep(true),
testerStep(true),
];
expect(developModerator(makeCtx(20, [...base, committed]))).toBe(END);
expect(developModerator(makeCtx(20, [...base, recoverable]))).toBe(END);
expect(developModerator(makeCtx(20, [...base, unrecoverable]))).toBe(END);
});
});
describe("buildDevelopDescriptor", () => {
test("lists all roles with schemas that validate", () => {
const descriptor = buildDevelopDescriptor();
const validated = validateWorkflowDescriptor(descriptor);
expect(validated.ok).toBe(true);
if (!validated.ok) {
throw new Error(validated.error);
}
expect(Object.keys(validated.value.roles).sort()).toEqual([
"coder",
"committer",
"planner",
"reviewer",
"tester",
]);
for (const key of ["planner", "coder", "reviewer", "tester", "committer"] as const) {
const role = validated.value.roles[key];
expect(role).toBeDefined();
expect(typeof role.schema).toBe("object");
expect(role.schema).not.toBeNull();
expect(Array.isArray(role.schema)).toBe(false);
}
});
});
@@ -0,0 +1,19 @@
{
"name": "@uncaged/workflow-template-develop",
"version": "0.1.0",
"type": "module",
"main": "src/index.ts",
"types": "src/index.ts",
"scripts": {
"build": "echo 'TODO'",
"test": "bun test"
},
"dependencies": {
"@uncaged/workflow": "workspace:*",
"@uncaged/workflow-role-coder": "workspace:*",
"@uncaged/workflow-role-committer": "workspace:*",
"@uncaged/workflow-role-planner": "workspace:*",
"@uncaged/workflow-role-reviewer": "workspace:*",
"@uncaged/workflow-role-tester": "workspace:*"
}
}
@@ -0,0 +1,12 @@
import { buildDescriptor } from "@uncaged/workflow";
import { developModerator } from "./moderator.js";
import { DEVELOP_WORKFLOW_DESCRIPTION, developRoles } from "./roles.js";
export function buildDevelopDescriptor() {
return buildDescriptor({
description: DEVELOP_WORKFLOW_DESCRIPTION,
roles: developRoles,
moderator: developModerator,
});
}
@@ -0,0 +1,60 @@
import {
type AgentBinding,
createWorkflow,
type ExtractFn,
type LlmProvider,
type WorkflowDefinition,
type WorkflowFn,
} from "@uncaged/workflow";
import { developModerator } from "./moderator.js";
import { DEVELOP_WORKFLOW_DESCRIPTION, type DevelopMeta, developRoles } from "./roles.js";
export {
type CoderMeta,
coderMetaSchema,
coderRole,
} from "@uncaged/workflow-role-coder";
export {
type CommitterMeta,
committerMetaSchema,
committerRole,
} from "@uncaged/workflow-role-committer";
export {
type PlannerMeta,
phaseSchema,
plannerMetaSchema,
plannerRole,
} from "@uncaged/workflow-role-planner";
export {
type ReviewerMeta,
reviewerMetaSchema,
reviewerRole,
} from "@uncaged/workflow-role-reviewer";
export {
type TesterMeta,
testerMetaSchema,
testerRole,
} from "@uncaged/workflow-role-tester";
export { buildDevelopDescriptor } from "./descriptor.js";
export { developModerator } from "./moderator.js";
export {
DEVELOP_WORKFLOW_DESCRIPTION,
type DevelopMeta,
type DevelopRoles,
developRoles,
} from "./roles.js";
export const developWorkflowDefinition: WorkflowDefinition<DevelopMeta> = {
description: DEVELOP_WORKFLOW_DESCRIPTION,
roles: developRoles,
moderator: developModerator,
};
export function createDevelopRun(
binding: AgentBinding,
extract: ExtractFn,
llmProvider: LlmProvider | null,
): WorkflowFn {
return createWorkflow(developWorkflowDefinition, binding, extract, llmProvider);
}
@@ -0,0 +1,89 @@
import type { Moderator, ModeratorContext } from "@uncaged/workflow";
import { END } from "@uncaged/workflow";
import type { DevelopMeta } from "./roles.js";
function coderFinishedAllPlannedPhases(
phases: ReadonlyArray<{ hash: string }>,
coderCompletedPhases: ReadonlyArray<string>,
): boolean {
if (phases.length === 0) {
return true;
}
const plannedHashes = new Set(phases.map((p) => p.hash));
const lastHash = phases[phases.length - 1].hash;
const explicit = new Set(coderCompletedPhases.filter((h) => plannedHashes.has(h)));
if (phases.every((p) => explicit.has(p.hash))) {
return true;
}
if (coderCompletedPhases.some((h) => h === lastHash)) {
return true;
}
return false;
}
function nextAfterCoder(
ctx: ModeratorContext<DevelopMeta>,
maxRounds: number,
): (keyof DevelopMeta & string) | typeof END {
const plannerStep = ctx.steps.find((s) => s.role === "planner");
if (plannerStep === undefined) {
return "reviewer";
}
const phases = plannerStep.meta.phases;
const coderCompletedPhases = ctx.steps
.filter((s) => s.role === "coder")
.map((s) => s.meta.completedPhase);
const allDone = coderFinishedAllPlannedPhases(phases, coderCompletedPhases);
if (allDone) {
return "reviewer";
}
if (ctx.steps.length < maxRounds - 1) {
return "coder";
}
return END;
}
export const developModerator: Moderator<DevelopMeta> = (ctx) => {
const maxRounds = ctx.start.meta.maxRounds;
if (ctx.steps.length === 0) {
return "planner";
}
const last = ctx.steps[ctx.steps.length - 1];
if (last.role === "planner") {
return "coder";
}
if (last.role === "coder") {
return nextAfterCoder(ctx, maxRounds);
}
if (last.role === "reviewer") {
if (last.meta.status === "approved") {
return "tester";
}
if (ctx.steps.length < maxRounds - 1) {
return "coder";
}
return END;
}
if (last.role === "tester") {
if (last.meta.status === "passed") {
return "committer";
}
if (ctx.steps.length < maxRounds - 1) {
return "coder";
}
return END;
}
if (last.role === "committer") {
return END;
}
return END;
};
@@ -0,0 +1,29 @@
import type { RoleDefinition } from "@uncaged/workflow";
import { type CoderMeta, coderRole } from "@uncaged/workflow-role-coder";
import { type CommitterMeta, committerRole } from "@uncaged/workflow-role-committer";
import { type PlannerMeta, plannerRole } from "@uncaged/workflow-role-planner";
import { type ReviewerMeta, reviewerRole } from "@uncaged/workflow-role-reviewer";
import { type TesterMeta, testerRole } from "@uncaged/workflow-role-tester";
export const DEVELOP_WORKFLOW_DESCRIPTION =
"Plan phases, implement incrementally, review, verify with tests/build/lint, and commit (planner → coder [repeat per phase] → reviewer → tester → committer).";
export type DevelopMeta = {
planner: PlannerMeta;
coder: CoderMeta;
reviewer: ReviewerMeta;
tester: TesterMeta;
committer: CommitterMeta;
};
export type DevelopRoles = {
[K in keyof DevelopMeta]: RoleDefinition<DevelopMeta[K]>;
};
export const developRoles: DevelopRoles = {
planner: plannerRole,
coder: coderRole,
reviewer: reviewerRole,
tester: testerRole,
committer: committerRole,
};
@@ -0,0 +1,17 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "dist",
"composite": true
},
"include": ["src/**/*.ts"],
"references": [
{ "path": "../workflow" },
{ "path": "../workflow-role-coder" },
{ "path": "../workflow-role-committer" },
{ "path": "../workflow-role-planner" },
{ "path": "../workflow-role-reviewer" },
{ "path": "../workflow-role-tester" }
]
}
@@ -313,7 +313,7 @@ describe("createSolveIssueRun", () => {
casDir = await mkdtemp(join(tmpdir(), "solve-issue-cas-")); casDir = await mkdtemp(join(tmpdir(), "solve-issue-cas-"));
const cas = createCasStore(casDir); const cas = createCasStore(casDir);
const run = createSolveIssueRun({ agent: async () => "" }, stubExtract); const run = createSolveIssueRun({ agent: async () => "" }, stubExtract, null);
const gen = run( const gen = run(
{ prompt: "task", steps: [] }, { prompt: "task", steps: [] },
{ threadId: "01TEST000000000000000000TR", maxRounds: 20, depth: 0, cas }, { threadId: "01TEST000000000000000000TR", maxRounds: 20, depth: 0, cas },
@@ -374,6 +374,7 @@ describe("createSolveIssueRun", () => {
}, },
}, },
stubExtract, stubExtract,
null,
); );
const gen = run( const gen = run(
{ prompt: "task", steps: [] }, { prompt: "task", steps: [] },
@@ -2,6 +2,7 @@ import {
type AgentBinding, type AgentBinding,
createWorkflow, createWorkflow,
type ExtractFn, type ExtractFn,
type LlmProvider,
type WorkflowDefinition, type WorkflowDefinition,
type WorkflowFn, type WorkflowFn,
} from "@uncaged/workflow"; } from "@uncaged/workflow";
@@ -50,6 +51,10 @@ export const solveIssueWorkflowDefinition: WorkflowDefinition<SolveIssueMeta> =
moderator: solveIssueModerator, moderator: solveIssueModerator,
}; };
export function createSolveIssueRun(binding: AgentBinding, extract: ExtractFn): WorkflowFn { export function createSolveIssueRun(
return createWorkflow(solveIssueWorkflowDefinition, binding, extract); binding: AgentBinding,
extract: ExtractFn,
llmProvider: LlmProvider | null,
): WorkflowFn {
return createWorkflow(solveIssueWorkflowDefinition, binding, extract, llmProvider);
} }
@@ -23,6 +23,7 @@ describe("buildDescriptor", () => {
extractPrompt: "Extract title and count from the analysis.", extractPrompt: "Extract title and count from the analysis.",
schema, schema,
extractRefs: null, extractRefs: null,
extractMode: "single",
}, },
}, },
moderator: () => END, moderator: () => END,
+169 -1
View File
@@ -15,7 +15,7 @@ import {
parseMerkleNode, parseMerkleNode,
serializeMerkleNode, serializeMerkleNode,
} from "../src/merkle.js"; } from "../src/merkle.js";
import { END } from "../src/types.js"; import { END, type LlmProvider } from "../src/types.js";
const plannerMetaSchema = z.object({ const plannerMetaSchema = z.object({
plan: z.string(), plan: z.string(),
@@ -97,6 +97,7 @@ const demoWorkflow = createWorkflow<DemoMeta>(
extractPrompt: "Extract plan text and affected files list.", extractPrompt: "Extract plan text and affected files list.",
schema: plannerMetaSchema, schema: plannerMetaSchema,
extractRefs: null, extractRefs: null,
extractMode: "single",
}, },
coder: { coder: {
description: "Demo coder", description: "Demo coder",
@@ -104,6 +105,7 @@ const demoWorkflow = createWorkflow<DemoMeta>(
extractPrompt: "Extract the code diff summary.", extractPrompt: "Extract the code diff summary.",
schema: coderMetaSchema, schema: coderMetaSchema,
extractRefs: null, extractRefs: null,
extractMode: "single",
}, },
}, },
moderator: (ctx) => { moderator: (ctx) => {
@@ -124,6 +126,7 @@ const demoWorkflow = createWorkflow<DemoMeta>(
}, },
}, },
demoExtract, demoExtract,
null,
); );
describe("executeThread", () => { describe("executeThread", () => {
@@ -445,4 +448,169 @@ describe("executeThread", () => {
await rm(root, { recursive: true, force: true }); await rm(root, { recursive: true, force: true });
} }
}); });
test("extractMode react traverses CAS DAG via cas_get during extraction", async () => {
const dagMetaSchema = z.object({ leafPayload: z.string() });
type DagDemoMeta = { walker: z.infer<typeof dagMetaSchema> };
const origFetch = globalThis.fetch;
restoreFetch = () => {
globalThis.fetch = origFetch;
};
let fetchRound = 0;
const root = await mkdtemp(join(tmpdir(), "wf-engine-react-"));
try {
const cas = createCasStore(join(root, "cas"));
const leafYaml = serializeMerkleNode(createContentMerkleNode("needle-from-leaf"));
const leafHash = await cas.put(leafYaml);
const rootYaml = serializeMerkleNode({
type: "thread",
payload: {
workflow: "dag-demo",
threadId: "01DAG00000000000000000001",
result: { returnCode: 0, summary: "" },
},
children: [leafHash],
});
const dagRootHash = await cas.put(rootYaml);
globalThis.fetch = Object.assign(
async (_input: Parameters<typeof fetch>[0], _init?: RequestInit) => {
fetchRound += 1;
if (fetchRound === 1) {
return new Response(
JSON.stringify({
choices: [
{
message: {
tool_calls: [
{
id: "c1",
type: "function",
function: {
name: "cas_get",
arguments: JSON.stringify({ hash: dagRootHash }),
},
},
],
},
},
],
}),
{ status: 200, headers: { "Content-Type": "application/json" } },
);
}
if (fetchRound === 2) {
return new Response(
JSON.stringify({
choices: [
{
message: {
tool_calls: [
{
id: "c2",
type: "function",
function: {
name: "cas_get",
arguments: JSON.stringify({ hash: leafHash }),
},
},
],
},
},
],
}),
{ status: 200, headers: { "Content-Type": "application/json" } },
);
}
return new Response(
JSON.stringify({
choices: [
{
message: {
tool_calls: [
{
id: "c3",
type: "function",
function: {
name: "extract",
arguments: JSON.stringify({ leafPayload: "needle-from-leaf" }),
},
},
],
},
},
],
}),
{ status: 200, headers: { "Content-Type": "application/json" } },
);
},
{ preconnect: origFetch.preconnect.bind(origFetch) },
) as typeof fetch;
const llm: LlmProvider = { baseUrl: "http://127.0.0.1:9", apiKey: "test", model: "test" };
const extractFn = createExtract(llm);
const dagWorkflow = createWorkflow<DagDemoMeta>(
{
roles: {
walker: {
description: "DAG walker",
systemPrompt: "Output only the root CAS hash.",
extractPrompt:
"Set leafPayload to the string payload of the content Merkle node under the root.",
schema: dagMetaSchema,
extractRefs: null,
extractMode: "react",
},
},
moderator: (ctx) => (ctx.steps.length === 0 ? "walker" : END),
},
{ agent: async () => dagRootHash },
extractFn,
llm,
);
const threadId = "01KQXKW18CT8G75T53R8F4G7YG";
const hash = "C9NMV6V2TQT81";
const dataPath = join(root, "logs", hash, `${threadId}.data.jsonl`);
const infoPath = join(root, "logs", hash, `${threadId}.info.jsonl`);
await mkdir(join(root, "logs", hash), { recursive: true });
const logger = createLogger({ sink: { kind: "file", path: infoPath } });
const ac = new AbortController();
const result = await executeThread(
dagWorkflow,
"dag-demo",
{ prompt: "traverse", steps: [] },
{
maxRounds: 5,
depth: 0,
signal: ac.signal,
awaitAfterEachYield: async () => {},
forkSourceThreadId: null,
prefilledDiskSteps: null,
},
{ threadId, hash, dataJsonlPath: dataPath, infoJsonlPath: infoPath, cas },
logger,
);
expect(result.returnCode).toBe(0);
expect(fetchRound).toBe(3);
const dataText = await readFile(dataPath, "utf8");
const lines = dataText
.trim()
.split("\n")
.filter((l) => l !== "");
const roleRec = JSON.parse(lines[1] ?? "{}") as Record<string, unknown>;
expect(roleRec.role).toBe("walker");
expect(roleRec.meta).toEqual({ leafPayload: "needle-from-leaf" });
} finally {
globalThis.fetch = origFetch;
await rm(root, { recursive: true, force: true });
}
});
}); });
@@ -0,0 +1,87 @@
import { describe, expect, test } from "bun:test";
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { getExtractProvider } from "../src/extract-provider.js";
describe("getExtractProvider", () => {
test("returns provider when config.extract is present", async () => {
const root = await mkdtemp(join(tmpdir(), "wf-ext-prov-ok-"));
try {
await mkdir(root, { recursive: true });
await writeFile(
join(root, "workflow.yaml"),
`config:
maxDepth: 3
extract:
baseUrl: https://dashscope.aliyuncs.com/compatible-mode/v1
model: qwen-plus
apiKey: literal-key
workflows: {}
`,
"utf8",
);
const r = await getExtractProvider(root);
expect(r.ok).toBe(true);
if (!r.ok) {
return;
}
expect(r.value.baseUrl).toBe("https://dashscope.aliyuncs.com/compatible-mode/v1");
expect(r.value.model).toBe("qwen-plus");
expect(r.value.apiKey).toBe("literal-key");
} finally {
await rm(root, { recursive: true, force: true });
}
});
test("errs when registry has no config section", async () => {
const root = await mkdtemp(join(tmpdir(), "wf-ext-prov-missing-"));
try {
await mkdir(root, { recursive: true });
await writeFile(join(root, "workflow.yaml"), "workflows: {}\n", "utf8");
const r = await getExtractProvider(root);
expect(r.ok).toBe(false);
if (r.ok) {
return;
}
expect(r.error).toContain("no global config");
} finally {
await rm(root, { recursive: true, force: true });
}
});
test("resolves apiKey from env at registry read time", async () => {
const root = await mkdtemp(join(tmpdir(), "wf-ext-prov-env-"));
const prev = process.env.WF_GET_EXTRACT_PROVIDER_KEY;
process.env.WF_GET_EXTRACT_PROVIDER_KEY = "resolved-secret";
try {
await mkdir(root, { recursive: true });
await writeFile(
join(root, "workflow.yaml"),
`config:
maxDepth: 1
extract:
baseUrl: https://example.com
model: m
apiKey: env:WF_GET_EXTRACT_PROVIDER_KEY
workflows: {}
`,
"utf8",
);
const r = await getExtractProvider(root);
expect(r.ok).toBe(true);
if (!r.ok) {
return;
}
expect(r.value.apiKey).toBe("resolved-secret");
} finally {
if (prev === undefined) {
delete process.env.WF_GET_EXTRACT_PROVIDER_KEY;
} else {
process.env.WF_GET_EXTRACT_PROVIDER_KEY = prev;
}
await rm(root, { recursive: true, force: true });
}
});
});
@@ -0,0 +1,209 @@
import { afterEach, describe, expect, test } from "bun:test";
import { mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import * as z from "zod/v4";
import { createCasStore } from "../src/cas.js";
import { createContentMerkleNode, serializeMerkleNode } from "../src/merkle.js";
import { reactExtract } from "../src/react-extract.js";
import type { LlmProvider } from "../src/types.js";
const metaSchema = z.object({ seen: z.string() });
const provider: LlmProvider = {
baseUrl: "http://127.0.0.1:9",
apiKey: "test",
model: "test",
};
describe("reactExtract", () => {
let restoreFetch: (() => void) | null = null;
afterEach(() => {
restoreFetch?.();
restoreFetch = null;
});
test("cas_get rounds then extract tool yields validated meta", async () => {
const casDir = await mkdtemp(join(tmpdir(), "react-extract-"));
const cas = createCasStore(casDir);
try {
const blob = serializeMerkleNode(createContentMerkleNode("needle"));
const h = await cas.put(blob);
const origFetch = globalThis.fetch;
let round = 0;
restoreFetch = () => {
globalThis.fetch = origFetch;
};
globalThis.fetch = Object.assign(
async (_input: Parameters<typeof fetch>[0], _init?: RequestInit) => {
round += 1;
if (round === 1) {
return new Response(
JSON.stringify({
choices: [
{
message: {
tool_calls: [
{
id: "t1",
type: "function",
function: {
name: "cas_get",
arguments: JSON.stringify({ hash: h }),
},
},
],
},
},
],
}),
{ status: 200, headers: { "Content-Type": "application/json" } },
);
}
return new Response(
JSON.stringify({
choices: [
{
message: {
tool_calls: [
{
id: "t2",
type: "function",
function: {
name: "extract",
arguments: JSON.stringify({ seen: "needle" }),
},
},
],
},
},
],
}),
{ status: 200, headers: { "Content-Type": "application/json" } },
);
},
{ preconnect: origFetch.preconnect.bind(origFetch) },
) as typeof fetch;
const text = `## Agent Output\n${h}\n## Extraction Instruction\nExtract seen from CAS.`;
const result = await reactExtract({
text,
schema: metaSchema,
provider,
cas,
});
expect(result.ok).toBe(true);
if (!result.ok) {
return;
}
expect(result.value).toEqual({ seen: "needle" });
expect(round).toBe(2);
} finally {
await rm(casDir, { recursive: true, force: true });
}
});
test("stops after max tool rounds when model keeps calling cas_get", async () => {
const casDir = await mkdtemp(join(tmpdir(), "react-extract-max-"));
const cas = createCasStore(casDir);
try {
const blob = serializeMerkleNode(createContentMerkleNode("x"));
const h = await cas.put(blob);
const origFetch = globalThis.fetch;
let round = 0;
restoreFetch = () => {
globalThis.fetch = origFetch;
};
globalThis.fetch = Object.assign(
async (_input: Parameters<typeof fetch>[0], _init?: RequestInit) => {
round += 1;
return new Response(
JSON.stringify({
choices: [
{
message: {
tool_calls: [
{
id: `loop-${round}`,
type: "function",
function: {
name: "cas_get",
arguments: JSON.stringify({ hash: h }),
},
},
],
},
},
],
}),
{ status: 200, headers: { "Content-Type": "application/json" } },
);
},
{ preconnect: origFetch.preconnect.bind(origFetch) },
) as typeof fetch;
const result = await reactExtract({
text: "## Agent Output\nnoop\n## Extraction Instruction\nExtract seen.",
schema: metaSchema,
provider,
cas,
});
expect(result.ok).toBe(false);
if (result.ok) {
return;
}
expect(result.error).toBe("max_react_rounds_exceeded");
expect(round).toBe(10);
} finally {
await rm(casDir, { recursive: true, force: true });
}
});
test("passthrough JSON assistant message without tool calls", async () => {
const casDir = await mkdtemp(join(tmpdir(), "react-extract-pass-"));
const cas = createCasStore(casDir);
try {
const origFetch = globalThis.fetch;
restoreFetch = () => {
globalThis.fetch = origFetch;
};
globalThis.fetch = Object.assign(
async (_input: Parameters<typeof fetch>[0], _init?: RequestInit) =>
new Response(
JSON.stringify({
choices: [
{
message: {
content: '{"seen":"direct"}',
},
},
],
}),
{ status: 200, headers: { "Content-Type": "application/json" } },
),
{ preconnect: origFetch.preconnect.bind(origFetch) },
) as typeof fetch;
const result = await reactExtract({
text: "## Agent Output\nok\n## Extraction Instruction\nExtract.",
schema: metaSchema,
provider,
cas,
});
expect(result.ok).toBe(true);
if (!result.ok) {
return;
}
expect(result.value).toEqual({ seen: "direct" });
} finally {
await rm(casDir, { recursive: true, force: true });
}
});
});
@@ -91,6 +91,7 @@ const refsDemoWorkflow = createWorkflow<RefsDemoMeta>(
extractPrompt: "Extract phases with CAS hashes.", extractPrompt: "Extract phases with CAS hashes.",
schema: plannerMetaSchema, schema: plannerMetaSchema,
extractRefs: (meta) => meta.phases.map((p) => p.hash), extractRefs: (meta) => meta.phases.map((p) => p.hash),
extractMode: "single",
}, },
}, },
moderator: (ctx) => (ctx.steps.length === 0 ? "planner" : END), moderator: (ctx) => (ctx.steps.length === 0 ? "planner" : END),
@@ -99,6 +100,7 @@ const refsDemoWorkflow = createWorkflow<RefsDemoMeta>(
agent: async () => "plan-output", agent: async () => "plan-output",
}, },
refsDemoExtract, refsDemoExtract,
null,
); );
describe("RoleStep refs tracking", () => { describe("RoleStep refs tracking", () => {
+82 -1
View File
@@ -4,6 +4,7 @@ import { tmpdir } from "node:os";
import { join } from "node:path"; import { join } from "node:path";
import { import {
parseWorkflowRegistryYaml,
readWorkflowRegistry, readWorkflowRegistry,
registerWorkflowVersion, registerWorkflowVersion,
rollbackWorkflowToHistoryHash, rollbackWorkflowToHistoryHash,
@@ -21,6 +22,7 @@ describe("workflow registry", () => {
if (!empty.ok) { if (!empty.ok) {
return; return;
} }
expect(empty.value.config).toBeNull();
const r1 = registerWorkflowVersion(empty.value, "solve-issue", "AAAAAAAAAAAAA", 100); const r1 = registerWorkflowVersion(empty.value, "solve-issue", "AAAAAAAAAAAAA", 100);
const w1 = await writeWorkflowRegistry(dir, r1); const w1 = await writeWorkflowRegistry(dir, r1);
@@ -68,7 +70,7 @@ describe("workflow registry", () => {
}); });
test("rollbackWorkflowToHistoryHash swaps head with a prior version", () => { test("rollbackWorkflowToHistoryHash swaps head with a prior version", () => {
let reg = registerWorkflowVersion({ workflows: {} }, "solve-issue", "H1", 100); let reg = registerWorkflowVersion({ config: null, workflows: {} }, "solve-issue", "H1", 100);
reg = registerWorkflowVersion(reg, "solve-issue", "H2", 200); reg = registerWorkflowVersion(reg, "solve-issue", "H2", 200);
reg = registerWorkflowVersion(reg, "solve-issue", "H3", 300); reg = registerWorkflowVersion(reg, "solve-issue", "H3", 300);
const entry = reg.workflows["solve-issue"]; const entry = reg.workflows["solve-issue"];
@@ -99,6 +101,85 @@ describe("workflow registry", () => {
expect(bad.ok).toBe(false); expect(bad.ok).toBe(false);
}); });
test("parses config section and literal apiKey", () => {
const yaml = `
config:
maxDepth: 3
extract:
baseUrl: https://example.com/v1
model: qwen-plus
apiKey: secret-key
workflows:
solve-issue:
hash: SPVR4BDMSGC1W
timestamp: 1
history: []
`;
const r = parseWorkflowRegistryYaml(yaml);
expect(r.ok).toBe(true);
if (!r.ok) {
return;
}
expect(r.value.config).not.toBeNull();
if (r.value.config === null) {
return;
}
expect(r.value.config.maxDepth).toBe(3);
expect(r.value.config.extract.baseUrl).toBe("https://example.com/v1");
expect(r.value.config.extract.model).toBe("qwen-plus");
expect(r.value.config.extract.apiKey).toBe("secret-key");
});
test("parses config apiKey env: prefix from process.env", () => {
const prev = process.env.WF_REGISTRY_TEST_API_KEY;
process.env.WF_REGISTRY_TEST_API_KEY = "from-env";
try {
const yaml = `
config:
maxDepth: 1
extract:
baseUrl: https://dashscope.aliyuncs.com/compatible-mode/v1
model: qwen-plus
apiKey: env:WF_REGISTRY_TEST_API_KEY
workflows: {}
`;
const r = parseWorkflowRegistryYaml(yaml);
expect(r.ok).toBe(true);
if (!r.ok) {
return;
}
expect(r.value.config?.extract.apiKey).toBe("from-env");
} finally {
if (prev === undefined) {
delete process.env.WF_REGISTRY_TEST_API_KEY;
} else {
process.env.WF_REGISTRY_TEST_API_KEY = prev;
}
}
});
test("parse errors when env: apiKey variable is unset", () => {
const prev = process.env.WF_REGISTRY_TEST_API_KEY_UNSET;
delete process.env.WF_REGISTRY_TEST_API_KEY_UNSET;
try {
const yaml = `
config:
maxDepth: 1
extract:
baseUrl: https://example.com
model: m
apiKey: env:WF_REGISTRY_TEST_API_KEY_UNSET
workflows: {}
`;
const r = parseWorkflowRegistryYaml(yaml);
expect(r.ok).toBe(false);
} finally {
if (prev !== undefined) {
process.env.WF_REGISTRY_TEST_API_KEY_UNSET = prev;
}
}
});
test("parse errors on invalid shape", async () => { test("parse errors on invalid shape", async () => {
const dir = join(tmpdir(), `wf-reg3-${process.pid}-${Date.now()}`); const dir = join(tmpdir(), `wf-reg3-${process.pid}-${Date.now()}`);
await mkdir(dir, { recursive: true }); await mkdir(dir, { recursive: true });
@@ -142,12 +142,14 @@ describe("workflowAsAgent integration", () => {
extractPrompt: "extract done flag", extractPrompt: "extract done flag",
schema: callerMetaSchema, schema: callerMetaSchema,
extractRefs: null, extractRefs: null,
extractMode: "single",
}, },
}, },
moderator: (ctx) => (ctx.steps.length === 0 ? "caller" : END), moderator: (ctx) => (ctx.steps.length === 0 ? "caller" : END),
}, },
{ agent: workflowAsAgent("child-wf", { storageRoot: root }) }, { agent: workflowAsAgent("child-wf", { storageRoot: root }) },
parentExtract, parentExtract,
null,
); );
const threadId = "01KQXKW18CT8G75T53R8F4G7YG"; const threadId = "01KQXKW18CT8G75T53R8F4G7YG";
@@ -121,6 +121,46 @@ describe("workflowAsAgent", () => {
makeAgentCtx({ storageRoot: root, depth: 3, prompt: "x", maxRounds: 5 }), makeAgentCtx({ storageRoot: root, depth: 3, prompt: "x", maxRounds: 5 }),
); );
expect(out).toContain("depth limit"); expect(out).toContain("depth limit");
expect(out).toContain("max 3");
} finally {
await rm(root, { recursive: true, force: true });
}
});
test("uses registry config maxDepth when set", async () => {
const root = await mkdtemp(join(tmpdir(), "wf-waa-maxdepth-cfg-"));
try {
await installChildWorkflow(root);
const reg = await readWorkflowRegistry(root);
expect(reg.ok).toBe(true);
if (!reg.ok) {
return;
}
const withCfg = {
...reg.value,
config: {
maxDepth: 2,
extract: {
baseUrl: "http://127.0.0.1:9",
model: "m",
apiKey: "k",
},
},
};
const wr = await writeWorkflowRegistry(root, withCfg);
expect(wr.ok).toBe(true);
const agent = workflowAsAgent("child-wf", { storageRoot: root });
const okOut = await agent(
makeAgentCtx({ storageRoot: root, depth: 1, prompt: "nest-once", maxRounds: 5 }),
);
expect(okOut).not.toContain("depth limit");
const badOut = await agent(
makeAgentCtx({ storageRoot: root, depth: 2, prompt: "x", maxRounds: 5 }),
);
expect(badOut).toContain("depth limit");
expect(badOut).toContain("max 2");
} finally { } finally {
await rm(root, { recursive: true, force: true }); await rm(root, { recursive: true, force: true });
} }
+48 -6
View File
@@ -1,11 +1,14 @@
import type { ExtractFn } from "./extract-fn.js"; import type { CasStore } from "./cas.js";
import { buildExtractUserContent, type ExtractFn } from "./extract-fn.js";
import { putContentMerkleNode } from "./merkle.js"; import { putContentMerkleNode } from "./merkle.js";
import { reactExtract } from "./react-extract.js";
import { mergeRefsWithContentHash } from "./refs-field.js"; import { mergeRefsWithContentHash } from "./refs-field.js";
import { import {
type AgentBinding, type AgentBinding,
type AgentContext, type AgentContext,
END, END,
type ExtractContext, type ExtractContext,
type LlmProvider,
type ModeratorContext, type ModeratorContext,
type RoleDefinition, type RoleDefinition,
type RoleMeta, type RoleMeta,
@@ -36,14 +39,51 @@ function resolveExtractedRefs(
return extractRefsFn(meta as Record<string, unknown>); return extractRefsFn(meta as Record<string, unknown>);
} }
async function resolveRoleMeta<M extends RoleMeta>(
roleDef: RoleDefinition<Record<string, unknown>>,
extractCtx: ExtractContext<M>,
extract: ExtractFn,
llmProvider: LlmProvider | null,
cas: CasStore,
): Promise<Record<string, unknown>> {
if (roleDef.extractMode === "react") {
if (llmProvider === null) {
throw new Error(
'createWorkflow: llmProvider is required when a role uses extractMode "react"',
);
}
const text = await buildExtractUserContent(
extractCtx as unknown as ExtractContext,
roleDef.extractPrompt,
);
const reactResult = await reactExtract({
text,
schema: roleDef.schema,
provider: llmProvider,
cas,
});
if (!reactResult.ok) {
throw new Error(`react extract failed: ${reactResult.error}`);
}
return reactResult.value as Record<string, unknown>;
}
return (await extract(
roleDef.schema,
roleDef.extractPrompt,
extractCtx as unknown as ExtractContext,
)) as Record<string, unknown>;
}
/** /**
* Binds pure role definitions + moderator to runtime agents and structured extraction. * Binds pure role definitions + moderator to runtime agents and structured extraction.
* Assign with `export const run = createWorkflow(def, binding, extract)`. * Assign with `export const run = createWorkflow(def, binding, extract, llmProvider)`.
* Pass the same {@link LlmProvider} as {@link createExtract} when any role uses `extractMode: "react"`.
*/ */
export function createWorkflow<M extends RoleMeta>( export function createWorkflow<M extends RoleMeta>(
def: Pick<WorkflowDefinition<M>, "roles" | "moderator">, def: Pick<WorkflowDefinition<M>, "roles" | "moderator">,
binding: AgentBinding, binding: AgentBinding,
extract: ExtractFn, extract: ExtractFn,
llmProvider: LlmProvider | null,
): WorkflowFn { ): WorkflowFn {
return async function* workflowLoop( return async function* workflowLoop(
input: ThreadInput, input: ThreadInput,
@@ -107,10 +147,12 @@ export function createWorkflow<M extends RoleMeta>(
agentContent: raw, agentContent: raw,
}; };
const meta = await extract( const meta = await resolveRoleMeta(
roleDef.schema, roleDef as unknown as RoleDefinition<Record<string, unknown>>,
roleDef.extractPrompt, extractCtx,
extractCtx as unknown as ExtractContext, extract,
llmProvider,
options.cas,
); );
const contentHash = await putContentMerkleNode(options.cas, raw); const contentHash = await putContentMerkleNode(options.cas, raw);
+35 -27
View File
@@ -10,6 +10,40 @@ export type ExtractFn = <T extends Record<string, unknown>>(
ctx: ExtractContext, ctx: ExtractContext,
) => Promise<T>; ) => Promise<T>;
/** Builds the user-side extraction prompt (thread + agent output + instruction). */
export async function buildExtractUserContent(
ctx: ExtractContext,
prompt: string,
): Promise<string> {
const lines: string[] = [];
lines.push(`## Role: ${ctx.currentRole.name}`);
lines.push(ctx.currentRole.systemPrompt);
lines.push("");
lines.push("## Task");
lines.push(ctx.start.content);
lines.push("");
if (ctx.steps.length > 0) {
lines.push("## Thread History");
for (const step of ctx.steps) {
const body = await getContentMerklePayload(ctx.cas, step.contentHash);
if (body === null) {
throw new Error(`extract: missing CAS blob for step ${step.role}: ${step.contentHash}`);
}
lines.push(`### ${step.role}`);
lines.push(body);
lines.push(`Meta: ${JSON.stringify(step.meta)}`);
lines.push("");
}
}
lines.push("## Agent Output");
lines.push(ctx.agentContent);
lines.push("");
lines.push("## Extraction Instruction");
lines.push(prompt);
return lines.join("\n");
}
/** /**
* Create an ExtractFn backed by an LLM provider. * Create an ExtractFn backed by an LLM provider.
* Builds prompt text from {@link ExtractContext} plus `prompt` and calls structured extraction. * Builds prompt text from {@link ExtractContext} plus `prompt` and calls structured extraction.
@@ -20,33 +54,7 @@ export function createExtract(provider: LlmProvider): ExtractFn {
prompt: string, prompt: string,
ctx: ExtractContext, ctx: ExtractContext,
): Promise<T> => { ): Promise<T> => {
const lines: string[] = []; const text = await buildExtractUserContent(ctx, prompt);
lines.push(`## Role: ${ctx.currentRole.name}`);
lines.push(ctx.currentRole.systemPrompt);
lines.push("");
lines.push("## Task");
lines.push(ctx.start.content);
lines.push("");
if (ctx.steps.length > 0) {
lines.push("## Thread History");
for (const step of ctx.steps) {
const body = await getContentMerklePayload(ctx.cas, step.contentHash);
if (body === null) {
throw new Error(`extract: missing CAS blob for step ${step.role}: ${step.contentHash}`);
}
lines.push(`### ${step.role}`);
lines.push(body);
lines.push(`Meta: ${JSON.stringify(step.meta)}`);
lines.push("");
}
}
lines.push("## Agent Output");
lines.push(ctx.agentContent);
lines.push("");
lines.push("## Extraction Instruction");
lines.push(prompt);
const text = lines.join("\n");
const result = await llmExtractWithRetry({ text, schema, provider }); const result = await llmExtractWithRetry({ text, schema, provider });
if (!result.ok) { if (!result.ok) {
throw new Error(`extract failed: ${JSON.stringify(result.error)}`); throw new Error(`extract failed: ${JSON.stringify(result.error)}`);
+35
View File
@@ -0,0 +1,35 @@
import { readWorkflowRegistry } from "./registry.js";
import type { WorkflowConfig } from "./registry-types.js";
import { err, ok, type Result } from "./result.js";
import { getDefaultWorkflowStorageRoot } from "./storage-root.js";
import type { LlmProvider } from "./types.js";
const DEFAULT_WORKFLOW_AS_AGENT_MAX_DEPTH = 3;
export function getWorkflowAsAgentMaxDepth(config: WorkflowConfig | null): number {
if (config === null) {
return DEFAULT_WORKFLOW_AS_AGENT_MAX_DEPTH;
}
return config.maxDepth;
}
/** Loads `config.extract` from workflow.yaml (apiKey already resolved at registry parse time). */
export async function getExtractProvider(
storageRoot: string | undefined,
): Promise<Result<LlmProvider, string>> {
const root = storageRoot ?? getDefaultWorkflowStorageRoot();
const regResult = await readWorkflowRegistry(root);
if (!regResult.ok) {
return err(regResult.error.message);
}
const cfg = regResult.value.config;
if (cfg === null) {
return err("workflow registry has no global config section");
}
const ex = cfg.extract;
return ok({
baseUrl: ex.baseUrl,
apiKey: ex.apiKey,
model: ex.model,
});
}
+5
View File
@@ -17,6 +17,7 @@ export {
} from "./engine.js"; } from "./engine.js";
export { type ExtractedBundleExports, extractBundleExports } from "./extract-bundle-exports.js"; export { type ExtractedBundleExports, extractBundleExports } from "./extract-bundle-exports.js";
export { createExtract, type ExtractFn } from "./extract-fn.js"; export { createExtract, type ExtractFn } from "./extract-fn.js";
export { getExtractProvider } from "./extract-provider.js";
export { export {
buildForkPlan, buildForkPlan,
type ForkHistoricalStep, type ForkHistoricalStep,
@@ -53,7 +54,9 @@ export {
serializeMerkleNode, serializeMerkleNode,
type ThreadMerklePayload, type ThreadMerklePayload,
} from "./merkle.js"; } from "./merkle.js";
export { type ReactExtractArgs, reactExtract } from "./react-extract.js";
export { export {
type ExtractProviderConfig,
getRegisteredWorkflow, getRegisteredWorkflow,
listRegisteredWorkflowNames, listRegisteredWorkflowNames,
parseWorkflowRegistryYaml, parseWorkflowRegistryYaml,
@@ -62,6 +65,7 @@ export {
rollbackWorkflowToHistoryHash, rollbackWorkflowToHistoryHash,
stringifyWorkflowRegistryYaml, stringifyWorkflowRegistryYaml,
unregisterWorkflow, unregisterWorkflow,
type WorkflowConfig,
type WorkflowHistoryEntry, type WorkflowHistoryEntry,
type WorkflowRegistryEntry, type WorkflowRegistryEntry,
type WorkflowRegistryFile, type WorkflowRegistryFile,
@@ -77,6 +81,7 @@ export {
type AgentFn, type AgentFn,
END, END,
type ExtractContext, type ExtractContext,
type ExtractMode,
type LlmProvider, type LlmProvider,
type Moderator, type Moderator,
type ModeratorContext, type ModeratorContext,
+20 -8
View File
@@ -47,6 +47,21 @@ function readToolDescription(parametersSchema: Record<string, unknown>): string
return "Extract structured data from the input text."; return "Extract structured data from the input text.";
} }
/** Builds OpenAI function-tool metadata from a Zod meta schema (same naming rules as single-shot extract). */
export function extractFunctionToolFromZodSchema(schema: z.ZodType<unknown>): {
name: string;
description: string;
parameters: Record<string, unknown>;
} {
const rawJsonSchema = z.toJSONSchema(schema) as Record<string, unknown>;
const parameters = stripJsonSchemaMeta(rawJsonSchema);
return {
name: readToolName(parameters),
description: readToolDescription(parameters),
parameters,
};
}
function readToolArgumentsJson(parsed: unknown, previewSource: string): Result<string, LlmError> { function readToolArgumentsJson(parsed: unknown, previewSource: string): Result<string, LlmError> {
if (!isRecord(parsed)) { if (!isRecord(parsed)) {
return err({ kind: "invalid_response_json", message: "Top-level JSON is not an object" }); return err({ kind: "invalid_response_json", message: "Top-level JSON is not an object" });
@@ -124,10 +139,7 @@ export function llmErrorToCause(error: LlmError): Error {
async function performLlmExtract<T>( async function performLlmExtract<T>(
options: LlmExtractArgs<T> & { userContent: string }, options: LlmExtractArgs<T> & { userContent: string },
): Promise<Result<T, LlmError>> { ): Promise<Result<T, LlmError>> {
const rawJsonSchema = z.toJSONSchema(options.schema) as Record<string, unknown>; const extractTool = extractFunctionToolFromZodSchema(options.schema);
const parameters = stripJsonSchemaMeta(rawJsonSchema);
const toolName = readToolName(parameters);
const toolDescription = readToolDescription(parameters);
const body = { const body = {
model: options.provider.model, model: options.provider.model,
@@ -142,13 +154,13 @@ async function performLlmExtract<T>(
{ {
type: "function" as const, type: "function" as const,
function: { function: {
name: toolName, name: extractTool.name,
description: toolDescription, description: extractTool.description,
parameters, parameters: extractTool.parameters,
}, },
}, },
], ],
tool_choice: { type: "function" as const, function: { name: toolName } }, tool_choice: { type: "function" as const, function: { name: extractTool.name } },
}; };
let response: Response; let response: Response;
+330
View File
@@ -0,0 +1,330 @@
import type * as z from "zod/v4";
import type { CasStore } from "./cas.js";
import { extractFunctionToolFromZodSchema } from "./llm-extract.js";
import { err, ok, type Result } from "./result.js";
import type { LlmProvider } from "./types.js";
export type ReactExtractArgs<T extends Record<string, unknown>> = {
text: string;
schema: z.ZodType<T>;
provider: LlmProvider;
cas: CasStore;
};
const MAX_REACT_ROUNDS = 10;
const CAS_GET_TOOL_DEFINITION = {
type: "function" as const,
function: {
name: "cas_get",
description:
"Read a Merkle DAG node from content-addressed storage by its hash. Returns YAML-formatted node with type, payload, and children fields.",
parameters: {
type: "object",
properties: {
hash: { type: "string", description: "The CAS hash to retrieve" },
},
required: ["hash"],
},
},
};
function chatCompletionsUrl(baseUrl: string): string {
const trimmed = baseUrl.replace(/\/+$/, "");
return `${trimmed}/chat/completions`;
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function tryParseJsonContent(content: string): unknown | null {
const trimmed = content.trim();
const fenceMatch = /^```(?:json)?\s*([\s\S]*?)```$/m.exec(trimmed);
const payload = fenceMatch !== null ? fenceMatch[1].trim() : trimmed;
try {
return JSON.parse(payload) as unknown;
} catch {
return null;
}
}
type ToolCall = {
id: string;
type: "function";
function: { name: string; arguments: string };
};
type ChatMessage =
| { role: "system"; content: string }
| { role: "user"; content: string }
| {
role: "assistant";
content: string | null;
tool_calls: ToolCall[];
}
| { role: "tool"; tool_call_id: string; content: string };
type AssistantTurn<T> =
| { kind: "plain_json"; value: T }
| { kind: "tool_calls"; calls: ToolCall[]; assistantContent: string | null };
function firstAssistantMessage(responseText: string): Result<Record<string, unknown>, string> {
let parsed: unknown;
try {
parsed = JSON.parse(responseText) as unknown;
} catch (cause) {
const message = cause instanceof Error ? cause.message : String(cause);
return err(`invalid_response_json:${message}`);
}
if (!isRecord(parsed)) {
return err("invalid_response_top_level");
}
const choices = parsed.choices;
if (!Array.isArray(choices) || choices.length === 0) {
return err("no_choices_in_response");
}
const firstChoice = choices[0];
if (!isRecord(firstChoice)) {
return err("invalid_choice");
}
const messageObj = firstChoice.message;
if (!isRecord(messageObj)) {
return err("invalid_message");
}
return ok(messageObj);
}
function normalizeToolCalls(toolCallsRaw: unknown[]): Result<ToolCall[], string> {
const toolCalls: ToolCall[] = [];
for (const tc of toolCallsRaw) {
if (!isRecord(tc)) {
return err("invalid_tool_call");
}
const id = tc.id;
const tcType = tc.type;
const fn = tc.function;
if (typeof id !== "string" || tcType !== "function" || !isRecord(fn)) {
return err("invalid_tool_call_shape");
}
const name = fn.name;
const argumentsStr = fn.arguments;
if (typeof name !== "string" || typeof argumentsStr !== "string") {
return err("invalid_tool_call_function");
}
toolCalls.push({ id, type: "function", function: { name, arguments: argumentsStr } });
}
return ok(toolCalls);
}
function classifyAssistantTurn<T extends Record<string, unknown>>(
messageObj: Record<string, unknown>,
schema: z.ZodType<T>,
): Result<AssistantTurn<T>, string> {
const toolCallsRaw = messageObj.tool_calls;
if (!Array.isArray(toolCallsRaw) || toolCallsRaw.length === 0) {
const content = messageObj.content;
if (typeof content !== "string") {
return err("no_tool_calls_and_no_string_content");
}
const jsonParsed = tryParseJsonContent(content);
if (jsonParsed === null) {
return err("no_tool_calls_and_content_not_json");
}
const validated = schema.safeParse(jsonParsed);
if (!validated.success) {
return err(`schema_validation_failed:${validated.error.message}`);
}
return ok({ kind: "plain_json", value: validated.data });
}
const callsResult = normalizeToolCalls(toolCallsRaw);
if (!callsResult.ok) {
return err(callsResult.error);
}
const assistantContent = messageObj.content;
return ok({
kind: "tool_calls",
calls: callsResult.value,
assistantContent: typeof assistantContent === "string" ? assistantContent : null,
});
}
async function appendCasGetToolResult(
tc: ToolCall,
cas: CasStore,
messages: ChatMessage[],
): Promise<Result<null, string>> {
let hash: string;
try {
const ta = JSON.parse(tc.function.arguments) as unknown;
if (!isRecord(ta) || typeof ta.hash !== "string") {
return err("cas_get_invalid_arguments");
}
hash = ta.hash;
} catch {
return err("cas_get_arguments_not_json");
}
const blob = await cas.get(hash);
const toolContent = blob === null ? "null" : blob;
messages.push({
role: "tool",
tool_call_id: tc.id,
content: toolContent,
});
return ok(null);
}
async function appendExtractToolResult<T extends Record<string, unknown>>(
tc: ToolCall,
schema: z.ZodType<T>,
messages: ChatMessage[],
): Promise<Result<T, string>> {
let parsedArgs: unknown;
try {
parsedArgs = JSON.parse(tc.function.arguments) as unknown;
} catch {
return err("extract_tool_arguments_not_json");
}
const validated = schema.safeParse(parsedArgs);
if (!validated.success) {
return err(`schema_validation_failed:${validated.error.message}`);
}
messages.push({
role: "tool",
tool_call_id: tc.id,
content: '{"ok":true}',
});
return ok(validated.data);
}
async function appendToolResults<T extends Record<string, unknown>>(
toolCalls: ToolCall[],
extractToolName: string,
schema: z.ZodType<T>,
cas: CasStore,
messages: ChatMessage[],
): Promise<Result<T | null, string>> {
let extracted: T | null = null;
for (const tc of toolCalls) {
if (tc.function.name === "cas_get") {
const casRes = await appendCasGetToolResult(tc, cas, messages);
if (!casRes.ok) {
return casRes;
}
continue;
}
if (tc.function.name === extractToolName) {
const exRes = await appendExtractToolResult(tc, schema, messages);
if (!exRes.ok) {
return exRes;
}
extracted = exRes.value;
continue;
}
return err(`unknown_tool:${tc.function.name}`);
}
return ok(extracted);
}
async function postChatCompletion(
provider: LlmProvider,
messages: ChatMessage[],
tools: readonly Record<string, unknown>[],
): Promise<Result<string, string>> {
try {
const response = await fetch(chatCompletionsUrl(provider.baseUrl), {
method: "POST",
headers: {
Authorization: `Bearer ${provider.apiKey}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
model: provider.model,
messages,
tools,
tool_choice: "auto",
}),
});
const responseText = await response.text();
if (!response.ok) {
return err(`http_error:${String(response.status)}:${responseText.slice(0, 4000)}`);
}
return ok(responseText);
} catch (cause) {
const message = cause instanceof Error ? cause.message : String(cause);
return err(`network_error:${message}`);
}
}
/**
* Multi-turn ReAct extraction with `cas_get` plus a schema-shaped extract tool (OpenAI-compatible).
* Final meta comes from a successful extract tool call or from plain JSON in the assistant message.
*/
export async function reactExtract<T extends Record<string, unknown>>(
args: ReactExtractArgs<T>,
): Promise<Result<T, string>> {
const extractTool = extractFunctionToolFromZodSchema(args.schema);
const tools = [
CAS_GET_TOOL_DEFINITION,
{
type: "function" as const,
function: {
name: extractTool.name,
description: extractTool.description,
parameters: extractTool.parameters,
},
},
];
const systemContent = `You extract structured metadata from the agent output below. Use cas_get to read Merkle DAG nodes from CAS (YAML: type, payload, children) when the agent output references hashes you must traverse. When you have the complete structured object, call the ${extractTool.name} tool with JSON arguments matching the schema. You may instead reply with only a JSON object (no prose) when no tools are needed.`;
const messages: ChatMessage[] = [
{ role: "system", content: systemContent },
{ role: "user", content: args.text },
];
for (let round = 0; round < MAX_REACT_ROUNDS; round++) {
const bodyResult = await postChatCompletion(args.provider, messages, tools);
if (!bodyResult.ok) {
return bodyResult;
}
const msgResult = firstAssistantMessage(bodyResult.value);
if (!msgResult.ok) {
return msgResult;
}
const classified = classifyAssistantTurn(msgResult.value, args.schema);
if (!classified.ok) {
return classified;
}
const turn = classified.value;
if (turn.kind === "plain_json") {
return ok(turn.value);
}
messages.push({
role: "assistant",
content: turn.assistantContent,
tool_calls: turn.calls,
});
const toolsRound = await appendToolResults(
turn.calls,
extractTool.name,
args.schema,
args.cas,
messages,
);
if (!toolsRound.ok) {
return toolsRound;
}
if (toolsRound.value !== null) {
return ok(toolsRound.value);
}
}
return err("max_react_rounds_exceeded");
}
+68 -1
View File
@@ -1,10 +1,68 @@
import type { import type {
ExtractProviderConfig,
WorkflowConfig,
WorkflowHistoryEntry, WorkflowHistoryEntry,
WorkflowRegistryEntry, WorkflowRegistryEntry,
WorkflowRegistryFile, WorkflowRegistryFile,
} from "./registry-types.js"; } from "./registry-types.js";
import { err, ok, type Result } from "./result.js"; import { err, ok, type Result } from "./result.js";
function resolveRegistryApiKey(raw: string): Result<string, Error> {
if (raw.startsWith("env:")) {
const name = raw.slice("env:".length);
if (name === "") {
return err(new Error('config.extract.apiKey "env:" reference must name a variable'));
}
const value = process.env[name];
if (value === undefined) {
return err(new Error(`config.extract.apiKey: environment variable "${name}" is not set`));
}
return ok(value);
}
return ok(raw);
}
function normalizeExtractProviderConfig(raw: unknown): Result<ExtractProviderConfig, Error> {
if (raw === null || typeof raw !== "object") {
return err(new Error('registry config must contain an "extract" mapping'));
}
const e = raw as Record<string, unknown>;
const baseUrl = e.baseUrl;
const model = e.model;
const apiKeyRaw = e.apiKey;
if (typeof baseUrl !== "string" || baseUrl === "") {
return err(new Error("config.extract.baseUrl must be a non-empty string"));
}
if (typeof model !== "string" || model === "") {
return err(new Error("config.extract.model must be a non-empty string"));
}
if (typeof apiKeyRaw !== "string" || apiKeyRaw === "") {
return err(new Error("config.extract.apiKey must be a non-empty string"));
}
const apiKeyResult = resolveRegistryApiKey(apiKeyRaw);
if (!apiKeyResult.ok) {
return apiKeyResult;
}
return ok({ baseUrl, model, apiKey: apiKeyResult.value });
}
function normalizeWorkflowConfig(raw: unknown): Result<WorkflowConfig, Error> {
if (raw === null || typeof raw !== "object") {
return err(new Error('registry "config" must be a mapping'));
}
const c = raw as Record<string, unknown>;
const maxDepth = c.maxDepth;
const extractRaw = c.extract;
if (typeof maxDepth !== "number" || !Number.isInteger(maxDepth) || maxDepth < 0) {
return err(new Error("config.maxDepth must be a non-negative integer"));
}
const extractResult = normalizeExtractProviderConfig(extractRaw);
if (!extractResult.ok) {
return extractResult;
}
return ok({ maxDepth, extract: extractResult.value });
}
export function normalizeWorkflowHistoryEntry( export function normalizeWorkflowHistoryEntry(
workflowName: string, workflowName: string,
index: number, index: number,
@@ -61,6 +119,15 @@ export function normalizeWorkflowRegistryRoot(raw: unknown): Result<WorkflowRegi
return err(new Error("registry root must be a mapping")); return err(new Error("registry root must be a mapping"));
} }
const root = raw as Record<string, unknown>; const root = raw as Record<string, unknown>;
const configRaw = root.config;
let config: WorkflowConfig | null = null;
if (configRaw !== undefined && configRaw !== null) {
const configResult = normalizeWorkflowConfig(configRaw);
if (!configResult.ok) {
return configResult;
}
config = configResult.value;
}
const workflowsRaw = root.workflows; const workflowsRaw = root.workflows;
if (workflowsRaw === null || workflowsRaw === undefined || typeof workflowsRaw !== "object") { if (workflowsRaw === null || workflowsRaw === undefined || typeof workflowsRaw !== "object") {
return err(new Error('registry must contain a "workflows" mapping')); return err(new Error('registry must contain a "workflows" mapping'));
@@ -73,5 +140,5 @@ export function normalizeWorkflowRegistryRoot(raw: unknown): Result<WorkflowRegi
} }
workflows[name] = entryResult.value; workflows[name] = entryResult.value;
} }
return ok({ workflows }); return ok({ config, workflows });
} }
+13
View File
@@ -9,6 +9,19 @@ export type WorkflowRegistryEntry = {
history: WorkflowHistoryEntry[]; history: WorkflowHistoryEntry[];
}; };
/** LLM provider settings under `config.extract` in workflow.yaml (apiKey resolved after parse). */
export type ExtractProviderConfig = {
baseUrl: string;
model: string;
apiKey: string;
};
export type WorkflowConfig = {
maxDepth: number;
extract: ExtractProviderConfig;
};
export type WorkflowRegistryFile = { export type WorkflowRegistryFile = {
config: WorkflowConfig | null;
workflows: Record<string, WorkflowRegistryEntry>; workflows: Record<string, WorkflowRegistryEntry>;
}; };
+5 -2
View File
@@ -12,6 +12,8 @@ import type {
import { err, ok, type Result } from "./result.js"; import { err, ok, type Result } from "./result.js";
export type { export type {
ExtractProviderConfig,
WorkflowConfig,
WorkflowHistoryEntry, WorkflowHistoryEntry,
WorkflowRegistryEntry, WorkflowRegistryEntry,
WorkflowRegistryFile, WorkflowRegistryFile,
@@ -22,7 +24,7 @@ export function workflowRegistryPath(storageRoot: string): string {
} }
function emptyRegistry(): WorkflowRegistryFile { function emptyRegistry(): WorkflowRegistryFile {
return { workflows: {} }; return { config: null, workflows: {} };
} }
export function parseWorkflowRegistryYaml(text: string): Result<WorkflowRegistryFile, Error> { export function parseWorkflowRegistryYaml(text: string): Result<WorkflowRegistryFile, Error> {
@@ -103,6 +105,7 @@ export function registerWorkflowVersion(
: [{ hash: prev.hash, timestamp: prev.timestamp }, ...baseHistory]; : [{ hash: prev.hash, timestamp: prev.timestamp }, ...baseHistory];
const next: WorkflowRegistryEntry = { hash, timestamp, history }; const next: WorkflowRegistryEntry = { hash, timestamp, history };
return { return {
config: registry.config,
workflows: { ...registry.workflows, [name]: next }, workflows: { ...registry.workflows, [name]: next },
}; };
} }
@@ -150,5 +153,5 @@ export function unregisterWorkflow(
return err(new Error(`workflow not registered: ${name}`)); return err(new Error(`workflow not registered: ${name}`));
} }
const { [name]: _removed, ...rest } = registry.workflows; const { [name]: _removed, ...rest } = registry.workflows;
return ok({ workflows: rest }); return ok({ config: registry.config, workflows: rest });
} }
+4
View File
@@ -16,6 +16,9 @@ export type LlmProvider = {
model: string; model: string;
}; };
/** How the engine runs meta extraction for a role after the agent phase. */
export type ExtractMode = "single" | "react";
/** What each generator yield produces — one role's output (engine adds `timestamp` when persisting). */ /** What each generator yield produces — one role's output (engine adds `timestamp` when persisting). */
export type RoleOutput = { export type RoleOutput = {
role: string; role: string;
@@ -121,6 +124,7 @@ export type RoleDefinition<Meta extends Record<string, unknown>> = {
schema: z.ZodType<Meta>; schema: z.ZodType<Meta>;
/** When non-null, produces CAS hashes to persist on this role's steps (see `RoleOutput.refs`). */ /** When non-null, produces CAS hashes to persist on this role's steps (see `RoleOutput.refs`). */
extractRefs: ((meta: Meta) => string[]) | null; extractRefs: ((meta: Meta) => string[]) | null;
extractMode: ExtractMode;
}; };
/** /**
+6 -6
View File
@@ -3,15 +3,13 @@ import { join } from "node:path";
import { createCasStore } from "./cas.js"; import { createCasStore } from "./cas.js";
import { type ExecuteThreadIo, executeThread } from "./engine.js"; import { type ExecuteThreadIo, executeThread } from "./engine.js";
import { extractBundleExports } from "./extract-bundle-exports.js"; import { extractBundleExports } from "./extract-bundle-exports.js";
import { getWorkflowAsAgentMaxDepth } from "./extract-provider.js";
import { createLogger } from "./logger.js"; import { createLogger } from "./logger.js";
import { getRegisteredWorkflow, readWorkflowRegistry } from "./registry.js"; import { getRegisteredWorkflow, readWorkflowRegistry } from "./registry.js";
import { getDefaultWorkflowStorageRoot, getGlobalCasDir } from "./storage-root.js"; import { getDefaultWorkflowStorageRoot, getGlobalCasDir } from "./storage-root.js";
import type { AgentContext, AgentFn, ThreadInput } from "./types.js"; import type { AgentContext, AgentFn, ThreadInput } from "./types.js";
import { generateUlid } from "./ulid.js"; import { generateUlid } from "./ulid.js";
/** Maximum `WorkflowFnOptions.depth` allowed for a child spawned via `workflowAsAgent`. */
const WORKFLOW_AS_AGENT_MAX_DEPTH = 3;
export type WorkflowAsAgentOptions = { export type WorkflowAsAgentOptions = {
/** When `null`, uses `getDefaultWorkflowStorageRoot()`. */ /** When `null`, uses `getDefaultWorkflowStorageRoot()`. */
storageRoot: string | null; storageRoot: string | null;
@@ -34,9 +32,6 @@ export function workflowAsAgent(
): AgentFn { ): AgentFn {
return async (ctx: AgentContext): Promise<string> => { return async (ctx: AgentContext): Promise<string> => {
const nextDepth = ctx.depth + 1; const nextDepth = ctx.depth + 1;
if (nextDepth > WORKFLOW_AS_AGENT_MAX_DEPTH) {
return `ERROR: workflow-as-agent depth limit exceeded (max ${WORKFLOW_AS_AGENT_MAX_DEPTH})`;
}
const storageRoot = resolveWorkflowAsAgentStorageRoot(options); const storageRoot = resolveWorkflowAsAgentStorageRoot(options);
@@ -45,6 +40,11 @@ export function workflowAsAgent(
return `ERROR: failed to read workflow registry: ${registryResult.error.message}`; return `ERROR: failed to read workflow registry: ${registryResult.error.message}`;
} }
const maxDepth = getWorkflowAsAgentMaxDepth(registryResult.value.config);
if (nextDepth > maxDepth) {
return `ERROR: workflow-as-agent depth limit exceeded (max ${maxDepth})`;
}
const entry = getRegisteredWorkflow(registryResult.value, workflowName); const entry = getRegisteredWorkflow(registryResult.value, workflowName);
if (entry === null) { if (entry === null) {
return `ERROR: workflow "${workflowName}" not found in registry`; return `ERROR: workflow "${workflowName}" not found in registry`;
+3 -1
View File
@@ -23,10 +23,12 @@
{ "path": "packages/workflow-role-coder" }, { "path": "packages/workflow-role-coder" },
{ "path": "packages/workflow-role-planner" }, { "path": "packages/workflow-role-planner" },
{ "path": "packages/workflow-role-reviewer" }, { "path": "packages/workflow-role-reviewer" },
{ "path": "packages/workflow-role-tester" },
{ "path": "packages/workflow-agent-cursor" }, { "path": "packages/workflow-agent-cursor" },
{ "path": "packages/workflow-agent-hermes" }, { "path": "packages/workflow-agent-hermes" },
{ "path": "packages/workflow-util-agent" }, { "path": "packages/workflow-util-agent" },
{ "path": "packages/cli-workflow" }, { "path": "packages/cli-workflow" },
{ "path": "packages/workflow-template-solve-issue" } { "path": "packages/workflow-template-solve-issue" },
{ "path": "packages/workflow-template-develop" }
] ]
} }