From 45bb5af99a2796e81207828f2e912bf0a9705961 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=B0=8F=E6=A9=98?= Date: Wed, 6 May 2026 11:35:45 +0000 Subject: [PATCH] feat: per-role agent config + phased planner/coder in solve-issue template MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 小橘 --- .../__tests__/solve-issue-template.test.ts | 75 +++++++++++++++++-- .../src/index.ts | 1 + .../src/moderator.ts | 26 ++++++- .../src/roles.ts | 66 +++++++++++----- 4 files changed, 141 insertions(+), 27 deletions(-) diff --git a/packages/workflow-template-solve-issue/__tests__/solve-issue-template.test.ts b/packages/workflow-template-solve-issue/__tests__/solve-issue-template.test.ts index 2a8efdb..3ec2160 100644 --- a/packages/workflow-template-solve-issue/__tests__/solve-issue-template.test.ts +++ b/packages/workflow-template-solve-issue/__tests__/solve-issue-template.test.ts @@ -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["start"] { return { @@ -31,20 +36,20 @@ function makeCtx( }; } -function plannerStep(): RoleStep { +function plannerStep(phases: PlannerMeta["phases"] = DEFAULT_PHASES): RoleStep { return { role: "planner", content: "plan", - meta: { plan: "do work", files: ["a.ts"], approach: "minimal fix" }, + meta: { phases }, timestamp: 1, }; } -function coderStep(): RoleStep { +function coderStep(completedPhase = "phase-a"): RoleStep { 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["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"]); }); }); diff --git a/packages/workflow-template-solve-issue/src/index.ts b/packages/workflow-template-solve-issue/src/index.ts index e64a205..220093d 100644 --- a/packages/workflow-template-solve-issue/src/index.ts +++ b/packages/workflow-template-solve-issue/src/index.ts @@ -16,6 +16,7 @@ export { coderMetaSchema, createSolveIssueRoles, type PlannerMeta, + phaseSchema, plannerMetaSchema, SOLVE_ISSUE_WORKFLOW_DESCRIPTION, type SolveIssueMeta, diff --git a/packages/workflow-template-solve-issue/src/moderator.ts b/packages/workflow-template-solve-issue/src/moderator.ts index fac7646..20387a7 100644 --- a/packages/workflow-template-solve-issue/src/moderator.ts +++ b/packages/workflow-template-solve-issue/src/moderator.ts @@ -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, + 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 = (ctx) => { const maxRounds = ctx.start.meta.maxRounds; @@ -17,7 +39,7 @@ export const solveIssueModerator: Moderator = (ctx) => { } if (last.role === "coder") { - return "reviewer"; + return nextAfterCoder(ctx, maxRounds); } if (last.role === "reviewer") { diff --git a/packages/workflow-template-solve-issue/src/roles.ts b/packages/workflow-template-solve-issue/src/roles.ts index 3a7e0a6..b0cd2e5 100644 --- a/packages/workflow-template-solve-issue/src/roles.ts +++ b/packages/workflow-template-solve-issue/src/roles.ts @@ -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; export type CoderMeta = z.infer; 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, +): 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, },