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-role-reviewer" },
|
||||||
{ "path": "packages/workflow-agent-cursor" },
|
{ "path": "packages/workflow-agent-cursor" },
|
||||||
{ "path": "packages/workflow-agent-hermes" },
|
{ "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