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:
@@ -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,
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user