feat: per-role agent config + phased planner/coder in solve-issue template

- SolveIssueRolesConfig.agents allows per-role AgentFn overrides
- PlannerMeta now outputs phases (name, description, acceptance)
- CoderMeta reports completedPhase, works one phase at a time
- Moderator routes coder→coder until all phases done, then reviewer

小橘 <xiaoju@shazhou.work>
This commit is contained in:
2026-05-06 11:35:45 +00:00
parent c7b0beb6be
commit 45bb5af99a
4 changed files with 141 additions and 27 deletions
@@ -1,5 +1,6 @@
import { describe, expect, test } from "bun:test";
import {
type AgentFn,
END,
type RoleStep,
START,
@@ -9,7 +10,11 @@ import {
import { buildSolveIssueDescriptor } from "../src/descriptor.js";
import { solveIssueModerator } from "../src/moderator.js";
import { createSolveIssueRoles, type SolveIssueMeta } from "../src/roles.js";
import { createSolveIssueRoles, type PlannerMeta, type SolveIssueMeta } from "../src/roles.js";
const DEFAULT_PHASES: PlannerMeta["phases"] = [
{ name: "phase-a", description: "Do the work", acceptance: "Done" },
];
function makeStart(maxRounds: number): ThreadContext<SolveIssueMeta>["start"] {
return {
@@ -31,20 +36,20 @@ function makeCtx(
};
}
function plannerStep(): RoleStep<SolveIssueMeta> {
function plannerStep(phases: PlannerMeta["phases"] = DEFAULT_PHASES): RoleStep<SolveIssueMeta> {
return {
role: "planner",
content: "plan",
meta: { plan: "do work", files: ["a.ts"], approach: "minimal fix" },
meta: { phases },
timestamp: 1,
};
}
function coderStep(): RoleStep<SolveIssueMeta> {
function coderStep(completedPhase = "phase-a"): RoleStep<SolveIssueMeta> {
return {
role: "coder",
content: "code",
meta: { filesChanged: ["a.ts"], summary: "fixed" },
meta: { completedPhase, filesChanged: ["a.ts"], summary: "fixed" },
timestamp: 2,
};
}
@@ -101,11 +106,32 @@ describe("solveIssueModerator", () => {
];
expect(solveIssueModerator(makeCtx(4, steps))).toBe(END);
});
test("multiple planner phases → coder until all complete, then reviewer", () => {
const phases: PlannerMeta["phases"] = [
{ name: "p1", description: "first", acceptance: "a1" },
{ name: "p2", description: "second", acceptance: "a2" },
];
expect(solveIssueModerator(makeCtx(20, [plannerStep(phases)]))).toBe("coder");
expect(solveIssueModerator(makeCtx(20, [plannerStep(phases), coderStep("p1")]))).toBe("coder");
expect(
solveIssueModerator(makeCtx(20, [plannerStep(phases), coderStep("p1"), coderStep("p2")])),
).toBe("reviewer");
});
test("incomplete phases → END when max rounds exhausted", () => {
const phases: PlannerMeta["phases"] = [
{ name: "p1", description: "first", acceptance: "a1" },
{ name: "p2", description: "second", acceptance: "a2" },
];
const steps: ThreadContext<SolveIssueMeta>["steps"] = [plannerStep(phases), coderStep("p1")];
expect(solveIssueModerator(makeCtx(3, steps))).toBe(END);
});
});
describe("createSolveIssueRoles", () => {
test("returns all four role callables", async () => {
const agent = async () => '{"plan":"x","files":[],"approach":"y"}';
const agent = async () => '{"phases":[{"name":"x","description":"d","acceptance":"a"}]}';
const roles = createSolveIssueRoles({
agent,
workdir: "/tmp/repo",
@@ -119,8 +145,41 @@ describe("createSolveIssueRoles", () => {
const ctx = makeCtx(10, []);
const plannerOut = await roles.planner.run(ctx as unknown as ThreadContext);
expect(plannerOut.meta.plan).toBe("");
expect(Array.isArray(plannerOut.meta.files)).toBe(true);
expect(plannerOut.meta.phases).toEqual([
{ name: "phase-1", description: "placeholder", acceptance: "placeholder" },
]);
});
test("per-role agents override default agent", async () => {
const calls: string[] = [];
const tag =
(label: string): AgentFn =>
async () => {
calls.push(label);
return "";
};
const roles = createSolveIssueRoles({
agent: tag("default"),
agents: {
planner: tag("planner"),
coder: tag("coder"),
},
workdir: "/tmp/repo",
extract: null,
});
const ctx = makeCtx(10, []);
await roles.planner.run(ctx as unknown as ThreadContext);
expect(calls).toEqual(["planner"]);
calls.length = 0;
await roles.coder.run(ctx as unknown as ThreadContext);
expect(calls).toEqual(["coder"]);
calls.length = 0;
await roles.reviewer.run(ctx as unknown as ThreadContext);
expect(calls).toEqual(["default"]);
});
});
@@ -16,6 +16,7 @@ export {
coderMetaSchema,
createSolveIssueRoles,
type PlannerMeta,
phaseSchema,
plannerMetaSchema,
SOLVE_ISSUE_WORKFLOW_DESCRIPTION,
type SolveIssueMeta,
@@ -1,8 +1,30 @@
import type { Moderator } from "@uncaged/workflow";
import type { Moderator, ThreadContext } from "@uncaged/workflow";
import { END } from "@uncaged/workflow";
import type { SolveIssueMeta } from "./roles.js";
function nextAfterCoder(
ctx: ThreadContext<SolveIssueMeta>,
maxRounds: number,
): (keyof SolveIssueMeta & string) | typeof END {
const plannerStep = ctx.steps.find((s) => s.role === "planner");
if (plannerStep === undefined) {
return "reviewer";
}
const phases = plannerStep.meta.phases;
const completedPhases = new Set(
ctx.steps.filter((s) => s.role === "coder").map((s) => s.meta.completedPhase),
);
const allDone = phases.every((p) => completedPhases.has(p.name));
if (allDone) {
return "reviewer";
}
if (ctx.steps.length < maxRounds - 1) {
return "coder";
}
return END;
}
export const solveIssueModerator: Moderator<SolveIssueMeta> = (ctx) => {
const maxRounds = ctx.start.meta.maxRounds;
@@ -17,7 +39,7 @@ export const solveIssueModerator: Moderator<SolveIssueMeta> = (ctx) => {
}
if (last.role === "coder") {
return "reviewer";
return nextAfterCoder(ctx, maxRounds);
}
if (last.role === "reviewer") {
@@ -19,24 +19,33 @@ const DRY_RUN_PROVIDER: LlmProvider = {
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.
const PLANNER_SYSTEM = `You are a **planner** for a software task. Break the work into **sequential phases** the coder will execute one at a time.
Focus on: root cause, edge cases, and how the implementation will be verified. Output enough detail for a coding agent to implement without guessing.`;
Each phase must have: a short **name** (stable identifier), a **description** of what to do in that phase, and **acceptance** criteria for when that phase is done.
const CODER_SYSTEM = `You are a **coder**. The previous step produced a plan: read the thread and implement that plan in the repository.
Order phases so earlier steps unblock later ones. Cover root cause, edge cases, and verification across the phases. Do not emit separate file lists or a free-form "approach" field — put that detail inside phase descriptions.`;
Make focused changes, follow project conventions, and explain what you changed.`;
const CODER_SYSTEM = `You are a **coder**. Read the thread: the planner produced ordered **phases**. Identify the **next** phase that is not yet completed according to prior coder steps (each coder step reports a completedPhase).
Implement **only that phase** — do not tackle multiple phases in one turn unless the planner defined a single phase. Follow project conventions; summarize what changed and list touched files.
When done with the phase you worked on, set **completedPhase** to that phase's **name** exactly as given by the planner.`;
export const SOLVE_ISSUE_WORKFLOW_DESCRIPTION =
"Plan, implement, review, and commit changes to resolve an issue end-to-end (planner → coder → reviewer → committer).";
"Phased plan, incremental implementation per phase, review, and commit to resolve an issue end-to-end (planner → coder [repeat per phase] → reviewer → committer).";
export const phaseSchema = z.object({
name: z.string(),
description: z.string(),
acceptance: z.string(),
});
export const plannerMetaSchema = z.object({
plan: z.string(),
files: z.array(z.string()),
approach: z.string(),
phases: z.array(phaseSchema),
});
export const coderMetaSchema = z.object({
completedPhase: z.string(),
filesChanged: z.array(z.string()),
summary: z.string(),
});
@@ -46,12 +55,17 @@ export type PlannerMeta = z.infer<typeof plannerMetaSchema>;
export type CoderMeta = z.infer<typeof coderMetaSchema>;
const PLANNER_DRY_RUN_META: PlannerMeta = {
plan: "",
files: [],
approach: "",
phases: [
{
name: "phase-1",
description: "placeholder",
acceptance: "placeholder",
},
],
};
const CODER_DRY_RUN_META: CoderMeta = {
completedPhase: "phase-1",
filesChanged: [],
summary: "",
};
@@ -76,10 +90,23 @@ export type SolveIssueMeta = {
/** Wiring for workflow-role LLM structured extraction. Use `null` for stub extract (dry-run meta from built-in placeholders). */
export type SolveIssueRolesConfig = {
agent: AgentFn;
agents?: Partial<{
planner: AgentFn;
coder: AgentFn;
reviewer: AgentFn;
committer: AgentFn;
}>;
workdir: string;
extract: { provider: LlmProvider; dryRun: boolean | null } | null;
};
function resolveRoleAgent(
config: SolveIssueRolesConfig,
role: keyof NonNullable<SolveIssueRolesConfig["agents"]>,
): AgentFn {
return config.agents?.[role] ?? config.agent;
}
function resolveExtract(config: SolveIssueRolesConfig): {
provider: LlmProvider;
dryRun: boolean | null;
@@ -106,11 +133,16 @@ export function createSolveIssueRoles(config: SolveIssueRolesConfig): SolveIssue
cwd: config.workdir,
};
const plannerAgent = resolveRoleAgent(config, "planner");
const coderAgent = resolveRoleAgent(config, "coder");
const reviewerAgent = resolveRoleAgent(config, "reviewer");
const committerAgent = resolveRoleAgent(config, "committer");
const plannerRun = createRole({
name: "planner",
schema: plannerMetaSchema,
systemPrompt: PLANNER_SYSTEM,
agent: config.agent,
agent: plannerAgent,
extract: {
provider: extract.provider,
dryRun: extract.dryRun,
@@ -122,7 +154,7 @@ export function createSolveIssueRoles(config: SolveIssueRolesConfig): SolveIssue
name: "coder",
schema: coderMetaSchema,
systemPrompt: CODER_SYSTEM,
agent: config.agent,
agent: coderAgent,
extract: {
provider: extract.provider,
dryRun: extract.dryRun,
@@ -131,7 +163,7 @@ export function createSolveIssueRoles(config: SolveIssueRolesConfig): SolveIssue
});
const reviewerRun = createReviewerRole(
config.agent,
reviewerAgent,
{
provider: extract.provider,
dryRun: extract.dryRun,
@@ -141,7 +173,7 @@ export function createSolveIssueRoles(config: SolveIssueRolesConfig): SolveIssue
);
const committerRun = createCommitterRole(
config.agent,
committerAgent,
{
provider: extract.provider,
dryRun: extract.dryRun,
@@ -152,12 +184,12 @@ export function createSolveIssueRoles(config: SolveIssueRolesConfig): SolveIssue
return {
planner: {
description: "Analyzes the issue and proposes plan, files, and approach.",
description: "Analyzes the issue and emits ordered implementation phases.",
run: plannerRun,
schema: plannerMetaSchema,
},
coder: {
description: "Implements the planner output and summarizes touched files.",
description: "Implements the next incomplete phase and reports completedPhase.",
run: coderRun,
schema: coderMetaSchema,
},