Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1a685583bd | |||
| 7f64541c5b | |||
| 43a6600378 | |||
| 220c9c5224 | |||
| cae59b589e | |||
| b5cc0db17e |
@@ -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,
|
||||||
|
|||||||
@@ -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", () => {
|
||||||
|
|||||||
@@ -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 });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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)}`);
|
||||||
|
|||||||
@@ -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,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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");
|
||||||
|
}
|
||||||
@@ -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 });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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 });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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
@@ -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" }
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user