feat: @uncaged/workflow-template-solve-issue — first workflow template
planner → coder → reviewer → committer flow with retry logic. - createSolveIssueWorkflow factory (agent-agnostic) - buildSolveIssueDescriptor with zod@4 JSON Schema - Moderator: reviewer reject → coder retry, maxRounds → END - 103 tests pass, biome clean Closes #13 小橘 <xiaoju@shazhou.work>
This commit is contained in:
@@ -0,0 +1,146 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import {
|
||||
END,
|
||||
type RoleStep,
|
||||
START,
|
||||
type ThreadContext,
|
||||
validateWorkflowDescriptor,
|
||||
} from "@uncaged/workflow";
|
||||
|
||||
import { buildSolveIssueDescriptor } from "../src/descriptor.js";
|
||||
import { solveIssueModerator } from "../src/moderator.js";
|
||||
import { createSolveIssueRoles, type SolveIssueMeta } from "../src/roles.js";
|
||||
|
||||
function makeStart(maxRounds: number): ThreadContext<SolveIssueMeta>["start"] {
|
||||
return {
|
||||
role: START,
|
||||
content: "Fix the flaky login test",
|
||||
meta: { maxRounds },
|
||||
timestamp: 0,
|
||||
};
|
||||
}
|
||||
|
||||
function makeCtx(
|
||||
maxRounds: number,
|
||||
steps: ThreadContext<SolveIssueMeta>["steps"],
|
||||
): ThreadContext<SolveIssueMeta> {
|
||||
return {
|
||||
start: makeStart(maxRounds),
|
||||
steps,
|
||||
};
|
||||
}
|
||||
|
||||
function plannerStep(): RoleStep<SolveIssueMeta> {
|
||||
return {
|
||||
role: "planner",
|
||||
content: "plan",
|
||||
meta: { plan: "do work", files: ["a.ts"], approach: "minimal fix" },
|
||||
timestamp: 1,
|
||||
};
|
||||
}
|
||||
|
||||
function coderStep(): RoleStep<SolveIssueMeta> {
|
||||
return {
|
||||
role: "coder",
|
||||
content: "code",
|
||||
meta: { filesChanged: ["a.ts"], summary: "fixed" },
|
||||
timestamp: 2,
|
||||
};
|
||||
}
|
||||
|
||||
function reviewerStep(approved: boolean): RoleStep<SolveIssueMeta> {
|
||||
return {
|
||||
role: "reviewer",
|
||||
content: "rev",
|
||||
meta: { approved },
|
||||
timestamp: 3,
|
||||
};
|
||||
}
|
||||
|
||||
function committerStep(): RoleStep<SolveIssueMeta> {
|
||||
return {
|
||||
role: "committer",
|
||||
content: "commit",
|
||||
meta: { committed: true },
|
||||
timestamp: 4,
|
||||
};
|
||||
}
|
||||
|
||||
describe("solveIssueModerator", () => {
|
||||
test("routes planner → coder → reviewer → committer → END", () => {
|
||||
expect(solveIssueModerator(makeCtx(20, []))).toBe("planner");
|
||||
expect(solveIssueModerator(makeCtx(20, [plannerStep()]))).toBe("coder");
|
||||
expect(solveIssueModerator(makeCtx(20, [plannerStep(), coderStep()]))).toBe("reviewer");
|
||||
expect(solveIssueModerator(makeCtx(20, [plannerStep(), coderStep(), reviewerStep(true)]))).toBe(
|
||||
"committer",
|
||||
);
|
||||
expect(
|
||||
solveIssueModerator(
|
||||
makeCtx(20, [plannerStep(), coderStep(), reviewerStep(true), committerStep()]),
|
||||
),
|
||||
).toBe(END);
|
||||
});
|
||||
|
||||
test("reviewer rejects → coder retry when budget allows", () => {
|
||||
const steps: ThreadContext<SolveIssueMeta>["steps"] = [
|
||||
plannerStep(),
|
||||
coderStep(),
|
||||
reviewerStep(false),
|
||||
];
|
||||
expect(solveIssueModerator(makeCtx(20, steps))).toBe("coder");
|
||||
});
|
||||
|
||||
test("reviewer rejects → END when max rounds exhausted", () => {
|
||||
const steps: ThreadContext<SolveIssueMeta>["steps"] = [
|
||||
plannerStep(),
|
||||
coderStep(),
|
||||
reviewerStep(false),
|
||||
];
|
||||
expect(solveIssueModerator(makeCtx(4, steps))).toBe(END);
|
||||
});
|
||||
});
|
||||
|
||||
describe("createSolveIssueRoles", () => {
|
||||
test("returns all four role callables", async () => {
|
||||
const agent = async () => '{"plan":"x","files":[],"approach":"y"}';
|
||||
const roles = createSolveIssueRoles({
|
||||
agent,
|
||||
workdir: "/tmp/repo",
|
||||
extract: null,
|
||||
});
|
||||
|
||||
expect(typeof roles.planner).toBe("function");
|
||||
expect(typeof roles.coder).toBe("function");
|
||||
expect(typeof roles.reviewer).toBe("function");
|
||||
expect(typeof roles.committer).toBe("function");
|
||||
|
||||
const ctx = makeCtx(10, []);
|
||||
const plannerOut = await roles.planner(ctx);
|
||||
expect(plannerOut.meta.plan).toBe("");
|
||||
expect(Array.isArray(plannerOut.meta.files)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildSolveIssueDescriptor", () => {
|
||||
test("lists all roles with schemas that validate", () => {
|
||||
const descriptor = buildSolveIssueDescriptor();
|
||||
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",
|
||||
]);
|
||||
for (const key of ["planner", "coder", "reviewer", "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-solve-issue",
|
||||
"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-agent-cursor": "workspace:*",
|
||||
"@uncaged/workflow-role-committer": "workspace:*",
|
||||
"@uncaged/workflow-role-llm": "workspace:*",
|
||||
"@uncaged/workflow-role-reviewer": "workspace:*",
|
||||
"zod": "^4.0.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { committerMetaSchema } from "@uncaged/workflow-role-committer";
|
||||
import { buildDescriptorFromRoles } from "@uncaged/workflow-role-llm";
|
||||
import { reviewerMetaSchema } from "@uncaged/workflow-role-reviewer";
|
||||
|
||||
import { coderMetaSchema, plannerMetaSchema } from "./roles.js";
|
||||
|
||||
export function buildSolveIssueDescriptor() {
|
||||
return buildDescriptorFromRoles({
|
||||
description:
|
||||
"Plan, implement, review, and commit changes to resolve an issue end-to-end (planner → coder → reviewer → committer).",
|
||||
roles: {
|
||||
planner: {
|
||||
name: "planner",
|
||||
schema: plannerMetaSchema,
|
||||
description: "Analyzes the issue and proposes plan, files, and approach.",
|
||||
},
|
||||
coder: {
|
||||
name: "coder",
|
||||
schema: coderMetaSchema,
|
||||
description: "Implements the planner output and summarizes touched files.",
|
||||
},
|
||||
reviewer: {
|
||||
name: "reviewer",
|
||||
schema: reviewerMetaSchema,
|
||||
description: "Runs git diff checks and sets approved when the change is ready.",
|
||||
},
|
||||
committer: {
|
||||
name: "committer",
|
||||
schema: committerMetaSchema,
|
||||
description: "Creates branch, commits, and pushes when review passes.",
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { createRoleModerator, type WorkflowFn } from "@uncaged/workflow";
|
||||
|
||||
import { solveIssueModerator } from "./moderator.js";
|
||||
import { createSolveIssueRoles, type SolveIssueMeta, type SolveIssueRolesConfig } from "./roles.js";
|
||||
|
||||
export { type CursorAgentConfig, createCursorAgent } from "@uncaged/workflow-agent-cursor";
|
||||
export { buildSolveIssueDescriptor } from "./descriptor.js";
|
||||
export { solveIssueModerator } from "./moderator.js";
|
||||
export {
|
||||
type CoderMeta,
|
||||
coderMetaSchema,
|
||||
createSolveIssueRoles,
|
||||
type PlannerMeta,
|
||||
plannerMetaSchema,
|
||||
type SolveIssueMeta,
|
||||
type SolveIssueRoles,
|
||||
type SolveIssueRolesConfig,
|
||||
} from "./roles.js";
|
||||
|
||||
/**
|
||||
* Factory for a {@link WorkflowFn}: supply an agent and repo paths at runtime, then pass the result
|
||||
* to the bundle `run` export pattern (`createRoleModerator` is already applied).
|
||||
*/
|
||||
export function createSolveIssueRun(config: SolveIssueRolesConfig): WorkflowFn {
|
||||
return createRoleModerator<SolveIssueMeta>({
|
||||
roles: createSolveIssueRoles(config),
|
||||
moderator: solveIssueModerator,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import type { Moderator } from "@uncaged/workflow";
|
||||
import { END } from "@uncaged/workflow";
|
||||
|
||||
import type { SolveIssueMeta } from "./roles.js";
|
||||
|
||||
export const solveIssueModerator: Moderator<SolveIssueMeta> = (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 "reviewer";
|
||||
}
|
||||
|
||||
if (last.role === "reviewer") {
|
||||
if (last.meta.approved === true) {
|
||||
return "committer";
|
||||
}
|
||||
if (ctx.steps.length < maxRounds - 1) {
|
||||
return "coder";
|
||||
}
|
||||
return END;
|
||||
}
|
||||
|
||||
if (last.role === "committer") {
|
||||
return END;
|
||||
}
|
||||
|
||||
return END;
|
||||
};
|
||||
@@ -0,0 +1,110 @@
|
||||
import type { AgentFn, Role } from "@uncaged/workflow";
|
||||
import { type CommitterMeta, createCommitterRole } from "@uncaged/workflow-role-committer";
|
||||
import { createRole, type LlmProvider } from "@uncaged/workflow-role-llm";
|
||||
import { createReviewerRole, type ReviewerMeta } from "@uncaged/workflow-role-reviewer";
|
||||
import * as z from "zod/v4";
|
||||
|
||||
const DRY_RUN_PROVIDER: LlmProvider = {
|
||||
baseUrl: "http://127.0.0.1:9",
|
||||
apiKey: "",
|
||||
model: "template-dry-run",
|
||||
};
|
||||
|
||||
const PLANNER_SYSTEM = `You are a **planner** for a software task. Analyze the issue, list relevant files, and produce a clear step-by-step approach.
|
||||
|
||||
Focus on: root cause, edge cases, and how the implementation will be verified. Output enough detail for a coding agent to implement without guessing.`;
|
||||
|
||||
const CODER_SYSTEM = `You are a **coder**. The previous step produced a plan: read the thread and implement that plan in the repository.
|
||||
|
||||
Make focused changes, follow project conventions, and explain what you changed.`;
|
||||
|
||||
export const plannerMetaSchema = z.object({
|
||||
plan: z.string(),
|
||||
files: z.array(z.string()),
|
||||
approach: z.string(),
|
||||
});
|
||||
|
||||
export const coderMetaSchema = z.object({
|
||||
filesChanged: z.array(z.string()),
|
||||
summary: z.string(),
|
||||
});
|
||||
|
||||
export type PlannerMeta = z.infer<typeof plannerMetaSchema>;
|
||||
|
||||
export type CoderMeta = z.infer<typeof coderMetaSchema>;
|
||||
|
||||
export type SolveIssueMeta = {
|
||||
planner: PlannerMeta;
|
||||
coder: CoderMeta;
|
||||
reviewer: ReviewerMeta;
|
||||
committer: CommitterMeta;
|
||||
};
|
||||
|
||||
/** Wiring for workflow-role LLM structured extraction. Use null for schema-default dry runs (tests / stubs). */
|
||||
export type SolveIssueRolesConfig = {
|
||||
agent: AgentFn;
|
||||
workdir: string;
|
||||
extract: { provider: LlmProvider; dryRun: boolean | null } | null;
|
||||
};
|
||||
|
||||
function resolveExtract(config: SolveIssueRolesConfig): {
|
||||
provider: LlmProvider;
|
||||
dryRun: boolean | null;
|
||||
} {
|
||||
if (config.extract === null) {
|
||||
return { provider: DRY_RUN_PROVIDER, dryRun: true };
|
||||
}
|
||||
return config.extract;
|
||||
}
|
||||
|
||||
export type SolveIssueRoles = {
|
||||
planner: Role<PlannerMeta>;
|
||||
coder: Role<CoderMeta>;
|
||||
reviewer: Role<ReviewerMeta>;
|
||||
committer: Role<CommitterMeta>;
|
||||
};
|
||||
|
||||
export function createSolveIssueRoles(config: SolveIssueRolesConfig): SolveIssueRoles {
|
||||
const extract = resolveExtract(config);
|
||||
const reviewerGit = {
|
||||
cwd: config.workdir,
|
||||
conventionsPath: null,
|
||||
extraChecks: [],
|
||||
threadId: null,
|
||||
};
|
||||
const committerGit = {
|
||||
cwd: config.workdir,
|
||||
remote: "origin",
|
||||
threadId: null,
|
||||
};
|
||||
|
||||
const planner: Role<PlannerMeta> = createRole({
|
||||
name: "planner",
|
||||
schema: plannerMetaSchema,
|
||||
systemPrompt: PLANNER_SYSTEM,
|
||||
agent: config.agent,
|
||||
extract: { provider: extract.provider, dryRun: extract.dryRun },
|
||||
});
|
||||
|
||||
const coder: Role<CoderMeta> = createRole({
|
||||
name: "coder",
|
||||
schema: coderMetaSchema,
|
||||
systemPrompt: CODER_SYSTEM,
|
||||
agent: config.agent,
|
||||
extract: { provider: extract.provider, dryRun: extract.dryRun },
|
||||
});
|
||||
|
||||
const reviewer: Role<ReviewerMeta> = createReviewerRole(
|
||||
config.agent,
|
||||
{ provider: extract.provider, dryRun: extract.dryRun },
|
||||
reviewerGit,
|
||||
);
|
||||
|
||||
const committer: Role<CommitterMeta> = createCommitterRole(
|
||||
config.agent,
|
||||
{ provider: extract.provider, dryRun: extract.dryRun },
|
||||
committerGit,
|
||||
);
|
||||
|
||||
return { planner, coder, reviewer, committer };
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"extends": "../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "dist",
|
||||
"composite": true
|
||||
},
|
||||
"include": ["src/**/*.ts"],
|
||||
"references": [{ "path": "../workflow" }, { "path": "../workflow-role-llm" }]
|
||||
}
|
||||
+2
-1
@@ -22,6 +22,7 @@
|
||||
{ "path": "packages/workflow-role-reviewer" },
|
||||
{ "path": "packages/workflow-agent-cursor" },
|
||||
{ "path": "packages/workflow-agent-hermes" },
|
||||
{ "path": "packages/cli-workflow" }
|
||||
{ "path": "packages/cli-workflow" },
|
||||
{ "path": "packages/workflow-template-solve-issue" }
|
||||
]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user