diff --git a/packages/workflow-role-submitter/__tests__/submitter.test.ts b/packages/workflow-role-submitter/__tests__/submitter.test.ts new file mode 100644 index 0000000..7975e92 --- /dev/null +++ b/packages/workflow-role-submitter/__tests__/submitter.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, test } from "bun:test"; + +import { submitterMetaSchema, submitterRole } from "../src/submitter.js"; + +describe("submitterRole", () => { + test("submitted sample validates against schema", () => { + const parsed = submitterMetaSchema.safeParse({ + status: "submitted" as const, + prUrl: "https://github.com/example/repo/pull/42", + }); + expect(parsed.success).toBe(true); + }); + + test("failed sample validates against schema", () => { + const parsed = submitterMetaSchema.safeParse({ + status: "failed" as const, + error: "gh not authenticated", + }); + expect(parsed.success).toBe(true); + }); + + test("rejects unknown status discriminant", () => { + const parsed = submitterMetaSchema.safeParse({ + status: "queued", + prUrl: "https://example.com", + }); + expect(parsed.success).toBe(false); + }); + + test("exposes submitter system prompt", () => { + expect(submitterRole.systemPrompt).toContain("submitter"); + expect(submitterRole.systemPrompt).toContain("pull request"); + }); + + test("uses single extract mode without refs", () => { + expect(submitterRole.extractMode).toBe("single"); + expect(submitterRole.extractRefs).toBeNull(); + }); +}); diff --git a/packages/workflow-role-submitter/package.json b/packages/workflow-role-submitter/package.json new file mode 100644 index 0000000..71643d4 --- /dev/null +++ b/packages/workflow-role-submitter/package.json @@ -0,0 +1,15 @@ +{ + "name": "@uncaged/workflow-role-submitter", + "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:*", + "zod": "^4.0.0" + } +} diff --git a/packages/workflow-role-submitter/src/index.ts b/packages/workflow-role-submitter/src/index.ts new file mode 100644 index 0000000..83a7428 --- /dev/null +++ b/packages/workflow-role-submitter/src/index.ts @@ -0,0 +1 @@ +export { type SubmitterMeta, submitterMetaSchema, submitterRole } from "./submitter.js"; diff --git a/packages/workflow-role-submitter/src/submitter.ts b/packages/workflow-role-submitter/src/submitter.ts new file mode 100644 index 0000000..3e24977 --- /dev/null +++ b/packages/workflow-role-submitter/src/submitter.ts @@ -0,0 +1,44 @@ +import type { RoleDefinition } from "@uncaged/workflow"; +import * as z from "zod/v4"; + +export const submitterMetaSchema = z.discriminatedUnion("status", [ + z.object({ + status: z.literal("submitted"), + prUrl: z.string(), + }), + z.object({ + status: z.literal("failed"), + error: z.string(), + }), +]); + +export type SubmitterMeta = z.infer; + +const SUBMITTER_SYSTEM = `You are the **submitter**. Your job is to push the work branch to the remote and open a pull request. + +## Inputs + +Read the thread for context: +- The **preparer**'s output gives you the absolute repo path and the default branch (and remote URL by inspecting the repo). +- The **developer**'s output gives you the branch name that was committed and a list of files changed plus a summary of the work. + +## Procedure + +1. \`cd\` into the repo path from the preparer's output. +2. Push the developer's branch to the remote: \`git push -u origin \`. +3. Open a pull request (e.g. via \`gh pr create\`) targeting the default branch. The PR title should be short and describe the change. The PR description should summarize what changed (drawing from the developer's summary and filesChanged) and reference the original issue/task if applicable. +4. Report the resulting PR URL. + +On any failure (push rejected, gh not authenticated, PR creation failed, etc.), report status="failed" with a short error message. Do not retry — surface the error so the moderator can decide.`; + +const SUBMITTER_EXTRACT_PROMPT = + "Extract the submission result. status='submitted' with prUrl on success, or status='failed' with a short error message on failure."; + +export const submitterRole: RoleDefinition = { + description: "Pushes the developer's branch to the remote and opens a pull request.", + systemPrompt: SUBMITTER_SYSTEM, + extractPrompt: SUBMITTER_EXTRACT_PROMPT, + schema: submitterMetaSchema, + extractRefs: null, + extractMode: "single", +}; diff --git a/packages/workflow-role-submitter/tsconfig.json b/packages/workflow-role-submitter/tsconfig.json new file mode 100644 index 0000000..2816fef --- /dev/null +++ b/packages/workflow-role-submitter/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "dist", + "composite": true + }, + "include": ["src/**/*.ts"], + "references": [{ "path": "../workflow" }] +} 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 c85aa30..75affd7 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 @@ -12,41 +12,71 @@ import { validateWorkflowDescriptor, } from "@uncaged/workflow"; -import type { CoderMeta } from "@uncaged/workflow-role-coder"; -import type { PlannerMeta } from "@uncaged/workflow-role-planner"; import type { PreparerMeta } from "@uncaged/workflow-role-preparer"; +import type { SubmitterMeta } from "@uncaged/workflow-role-submitter"; import { buildSolveIssueDescriptor } from "../src/descriptor.js"; +import type { DeveloperMeta } from "../src/developer.js"; import { createSolveIssueRun, solveIssueModerator } from "../src/index.js"; import type { SolveIssueMeta } from "../src/roles.js"; -const DEFAULT_PHASES: PlannerMeta["phases"] = [ - { - hash: "4KNMR2PX", - title: "Do the work", - }, -]; +function jsonResponse(payload: Record): Response { + return new Response(JSON.stringify(payload), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); +} -const EXPECT_PLANNER_META: PlannerMeta = { - phases: [ - { - hash: "7BQST3VW", - title: "placeholder phase", - }, - ], -}; +function readToolListFromBody(init: RequestInit | undefined): readonly Record[] { + if (init === undefined || init.body === undefined || init.body === null) { + return []; + } + const body = JSON.parse(String(init.body)) as Record; + const tools = body.tools; + if (!Array.isArray(tools)) { + return []; + } + return tools.filter((t): t is Record => t !== null && typeof t === "object"); +} -const EXPECT_CODER_META: CoderMeta = { - completedPhase: "7BQST3VW", - filesChanged: [], - summary: "", -}; +function singleToolName(tools: readonly Record[]): string { + if (tools.length === 0) { + return "extract"; + } + const fn = tools[0].function as Record | undefined; + return typeof fn?.name === "string" ? fn.name : "extract"; +} + +function buildSingleModeResponse(args: Record, toolName: string): Response { + return jsonResponse({ + choices: [ + { + message: { + tool_calls: [ + { + type: "function", + function: { name: toolName, arguments: JSON.stringify(args) }, + }, + ], + }, + }, + ], + }); +} + +function buildReactModeResponse(args: Record): Response { + // reactExtract accepts a plain-JSON assistant message and validates it + // directly against the schema, so we skip the cas_get / extract tool dance. + return jsonResponse({ + choices: [{ message: { content: JSON.stringify(args) } }], + }); +} function installMockChatCompletions(sequence: ReadonlyArray>): () => void { const origFetch = globalThis.fetch; let i = 0; const mockFetch = async ( - input: Parameters[0], + _input: Parameters[0], init?: RequestInit, ): Promise => { const args = sequence[i] ?? sequence[sequence.length - 1]; @@ -54,36 +84,11 @@ function installMockChatCompletions(sequence: ReadonlyArray) : {}; - const tools = body.tools; - const firstTool = - Array.isArray(tools) && tools.length > 0 && tools[0] !== null && typeof tools[0] === "object" - ? (tools[0] as Record) - : null; - const fn = - firstTool !== null ? (firstTool.function as Record | undefined) : undefined; - const toolName = typeof fn?.name === "string" ? fn.name : "extract"; - return new Response( - JSON.stringify({ - choices: [ - { - message: { - tool_calls: [ - { - type: "function", - function: { - name: toolName, - arguments: JSON.stringify(args), - }, - }, - ], - }, - }, - ], - }), - { status: 200, headers: { "Content-Type": "application/json" } }, - ); + const tools = readToolListFromBody(init); + if (tools.length > 1) { + return buildReactModeResponse(args); + } + return buildSingleModeResponse(args, singleToolName(tools)); }; globalThis.fetch = Object.assign(mockFetch, { preconnect: origFetch.preconnect.bind(origFetch), @@ -134,152 +139,86 @@ function preparerStep(): RoleStep { }; } -function plannerStep(phases: PlannerMeta["phases"] = DEFAULT_PHASES): RoleStep { +function developerStep(): RoleStep { return { - role: "planner", - contentHash: "STUBHASHPLANNER001", - meta: { phases }, - refs: phases.map((p) => p.hash), + role: "developer", + contentHash: "STUBHASHDEVELOPER1", + meta: { + branch: "feat/issue-1", + commitSha: "abc1234", + filesChanged: ["src/login.ts"], + summary: "Fixed flaky login test by stabilising async setup.", + }, + refs: [], timestamp: 1, }; } -function coderStep(completedPhase = "4KNMR2PX"): RoleStep { +function submitterStep(meta: SubmitterMeta): RoleStep { return { - role: "coder", - contentHash: "STUBHASHCODER00001", - meta: { completedPhase, filesChanged: ["a.ts"], summary: "fixed" }, - refs: [completedPhase], + role: "submitter", + contentHash: "STUBHASHSUBMITTER1", + meta, + refs: [], timestamp: 2, }; } -function reviewerStep(approved: boolean): RoleStep { - return { - role: "reviewer", - contentHash: "STUBHASHREVIEWER01", - meta: approved - ? { status: "approved" as const } - : { status: "rejected" as const, issues: ["needs fix"] }, - refs: [], - timestamp: 3, - }; -} - -function committerStep(): RoleStep { - return { - role: "committer", - contentHash: "STUBHASHCOMMITTER1", - meta: { status: "committed", branch: "feat/issue-1", commitSha: "abc1234" }, - refs: [], - timestamp: 4, - }; -} - const stubExtract = createExtract({ baseUrl: "http://127.0.0.1:9", apiKey: "", model: "test", }); +const stubLlmProvider = { + baseUrl: "http://127.0.0.1:9", + apiKey: "", + model: "test", +}; + describe("solveIssueModerator", () => { - test("routes preparer → planner → coder → reviewer → committer → END", () => { + test("routes initial → preparer → developer → submitter → END", () => { expect(solveIssueModerator(makeCtx(20, []))).toBe("preparer"); - expect(solveIssueModerator(makeCtx(20, [preparerStep()]))).toBe("planner"); - expect(solveIssueModerator(makeCtx(20, [preparerStep(), plannerStep()]))).toBe("coder"); - expect(solveIssueModerator(makeCtx(20, [preparerStep(), plannerStep(), coderStep()]))).toBe( - "reviewer", - ); - expect( - solveIssueModerator( - makeCtx(20, [preparerStep(), plannerStep(), coderStep(), reviewerStep(true)]), - ), - ).toBe("committer"); + expect(solveIssueModerator(makeCtx(20, [preparerStep()]))).toBe("developer"); + expect(solveIssueModerator(makeCtx(20, [preparerStep(), developerStep()]))).toBe("submitter"); expect( solveIssueModerator( makeCtx(20, [ preparerStep(), - plannerStep(), - coderStep(), - reviewerStep(true), - committerStep(), + developerStep(), + submitterStep({ + status: "submitted", + prUrl: "https://github.com/example/repo/pull/1", + }), ]), ), ).toBe(END); }); - test("reviewer rejects → coder retry when budget allows", () => { - const steps: ModeratorContext["steps"] = [ - plannerStep(), - coderStep(), - reviewerStep(false), - ]; - expect(solveIssueModerator(makeCtx(20, steps))).toBe("coder"); - }); - - test("reviewer rejects → END when max rounds exhausted", () => { - const steps: ModeratorContext["steps"] = [ - plannerStep(), - coderStep(), - reviewerStep(false), - ]; - expect(solveIssueModerator(makeCtx(4, steps))).toBe(END); - }); - - test("multiple planner phases → coder until all complete, then reviewer", () => { - const phases: PlannerMeta["phases"] = [ - { - hash: "AA000001", - title: "first phase", - }, - { - hash: "AA000002", - title: "second phase", - }, - ]; - expect(solveIssueModerator(makeCtx(20, [plannerStep(phases)]))).toBe("coder"); - expect(solveIssueModerator(makeCtx(20, [plannerStep(phases), coderStep("AA000001")]))).toBe( - "coder", - ); + test("submitter failed → END", () => { expect( solveIssueModerator( - makeCtx(20, [plannerStep(phases), coderStep("AA000001"), coderStep("AA000002")]), + makeCtx(20, [ + preparerStep(), + developerStep(), + submitterStep({ status: "failed", error: "gh not authenticated" }), + ]), ), - ).toBe("reviewer"); + ).toBe(END); }); - test("one-shot coder reports only last phase hash → reviewer (moderator treats as all phases done)", () => { - const phases: PlannerMeta["phases"] = [ - { hash: "BB000001", title: "setup branch" }, - { hash: "BB000002", title: "write tests" }, - { hash: "BB000003", title: "verify" }, - { hash: "BB000004", title: "commit and pr" }, - ]; - expect(solveIssueModerator(makeCtx(20, [plannerStep(phases), coderStep("BB000004")]))).toBe( - "reviewer", - ); - }); - - test("unrecognised completedPhase hash → coder retry when budget allows", () => { - const phases: PlannerMeta["phases"] = [ - { hash: "CC000001", title: "first phase" }, - { hash: "CC000002", title: "second phase" }, - ]; - expect(solveIssueModerator(makeCtx(20, [plannerStep(phases), coderStep("all-done")]))).toBe( - "coder", - ); - }); - - test("incomplete phases → END when max rounds exhausted", () => { - const phases: PlannerMeta["phases"] = [ - { hash: "DD000001", title: "first phase" }, - { hash: "DD000002", title: "second phase" }, - ]; - const steps: ModeratorContext["steps"] = [ - plannerStep(phases), - coderStep("DD000001"), - ]; - expect(solveIssueModerator(makeCtx(3, steps))).toBe(END); + test("returns END for any unexpected last step (defensive)", () => { + // A submitter step with a pseudo-unknown future status would still be + // routed to END, since the moderator is a closed switch over known roles. + expect( + solveIssueModerator( + makeCtx(20, [ + preparerStep(), + developerStep(), + submitterStep({ status: "submitted", prUrl: "https://example.com/pr/1" }), + ]), + ), + ).toBe(END); }); }); @@ -296,7 +235,7 @@ describe("createSolveIssueRun", () => { } }); - test("structured extraction yields preparer then planner meta from mocked chat completions", async () => { + test("structured extraction yields preparer meta from mocked chat completions", async () => { const EXPECT_PREPARER_META: PreparerMeta = { repoPath: "/home/user/repos/test", defaultBranch: "main", @@ -308,12 +247,20 @@ describe("createSolveIssueRun", () => { buildCommand: "bun run build", }, }; - restoreFetch = installMockChatCompletions([EXPECT_PREPARER_META, EXPECT_PLANNER_META]); + restoreFetch = installMockChatCompletions([EXPECT_PREPARER_META]); casDir = await mkdtemp(join(tmpdir(), "solve-issue-cas-")); const cas = createCasStore(casDir); - const run = createSolveIssueRun({ agent: async () => "" }, stubExtract, null); + // Override developer so the test does not spin up a child workflow. + const run = createSolveIssueRun( + { + agent: async () => "", + overrides: { developer: async () => "stub-root-hash" }, + }, + stubExtract, + stubLlmProvider, + ); const gen = run( { prompt: "task", steps: [] }, { threadId: "01TEST000000000000000000TR", maxRounds: 20, depth: 0, cas }, @@ -325,14 +272,6 @@ describe("createSolveIssueRun", () => { } expect(first.value.role).toBe("preparer"); expect(first.value.meta).toEqual(EXPECT_PREPARER_META); - - const second = await gen.next(); - expect(second.done).toBe(false); - if (second.done) { - throw new Error("expected yield"); - } - expect(second.value.role).toBe("planner"); - expect(second.value.meta).toEqual(EXPECT_PLANNER_META); }); test("per-role agent overrides default", async () => { @@ -342,11 +281,17 @@ describe("createSolveIssueRun", () => { conventions: null, toolchain: { packageManager: null, testCommand: null, lintCommand: null, buildCommand: null }, }; - restoreFetch = installMockChatCompletions([ - PREPARER_META, - EXPECT_PLANNER_META, - EXPECT_CODER_META, - ]); + const DEVELOPER_META: DeveloperMeta = { + branch: "feat/x", + commitSha: "abc1234", + filesChanged: ["a.ts"], + summary: "did the work", + }; + const SUBMITTER_META: SubmitterMeta = { + status: "submitted", + prUrl: "https://github.com/example/repo/pull/2", + }; + restoreFetch = installMockChatCompletions([PREPARER_META, DEVELOPER_META, SUBMITTER_META]); casDir = await mkdtemp(join(tmpdir(), "solve-issue-cas-")); const cas = createCasStore(casDir); @@ -363,18 +308,18 @@ describe("createSolveIssueRun", () => { calls.push("preparer"); return ""; }, - planner: async () => { - calls.push("planner"); - return ""; + developer: async () => { + calls.push("developer"); + return "stub-root-hash"; }, - coder: async () => { - calls.push("coder"); + submitter: async () => { + calls.push("submitter"); return ""; }, }, }, stubExtract, - null, + stubLlmProvider, ); const gen = run( { prompt: "task", steps: [] }, @@ -385,16 +330,65 @@ describe("createSolveIssueRun", () => { calls.length = 0; await gen.next(); - expect(calls).toEqual(["planner"]); + expect(calls).toEqual(["developer"]); calls.length = 0; await gen.next(); - expect(calls).toEqual(["coder"]); + expect(calls).toEqual(["submitter"]); + }); + + test("developer defaults to workflowAsAgent override (caller override still wins)", async () => { + const PREPARER_META: PreparerMeta = { + repoPath: "/tmp/r", + defaultBranch: "main", + conventions: null, + toolchain: { packageManager: null, testCommand: null, lintCommand: null, buildCommand: null }, + }; + const DEVELOPER_META: DeveloperMeta = { + branch: "feat/y", + commitSha: "def5678", + filesChanged: ["b.ts"], + summary: "more work", + }; + restoreFetch = installMockChatCompletions([PREPARER_META, DEVELOPER_META]); + + casDir = await mkdtemp(join(tmpdir(), "solve-issue-cas-")); + const cas = createCasStore(casDir); + + let developerInvocations = 0; + const run = createSolveIssueRun( + { + agent: async () => "", + overrides: { + developer: async () => { + developerInvocations += 1; + return "stub-root-hash"; + }, + }, + }, + stubExtract, + stubLlmProvider, + ); + const gen = run( + { prompt: "task", steps: [] }, + { threadId: "01TEST000000000000000000TR", maxRounds: 20, depth: 0, cas }, + ); + // preparer + await gen.next(); + // developer (caller override should be invoked, NOT workflowAsAgent default) + const devYield = await gen.next(); + expect(devYield.done).toBe(false); + if (devYield.done) { + throw new Error("expected yield"); + } + expect(devYield.value.role).toBe("developer"); + expect(devYield.value.meta).toEqual(DEVELOPER_META); + expect(developerInvocations).toBe(1); }); }); describe("buildSolveIssueDescriptor", () => { - test("lists all roles with schemas that validate", () => { + test("lists preparer, developer, submitter with schemas that validate", () => { const descriptor = buildSolveIssueDescriptor(); const validated = validateWorkflowDescriptor(descriptor); expect(validated.ok).toBe(true); @@ -402,13 +396,11 @@ describe("buildSolveIssueDescriptor", () => { throw new Error(validated.error); } expect(Object.keys(validated.value.roles).sort()).toEqual([ - "coder", - "committer", - "planner", + "developer", "preparer", - "reviewer", + "submitter", ]); - for (const key of ["preparer", "planner", "coder", "reviewer", "committer"] as const) { + for (const key of ["preparer", "developer", "submitter"] as const) { const role = validated.value.roles[key]; expect(role).toBeDefined(); expect(typeof role.schema).toBe("object"); diff --git a/packages/workflow-template-solve-issue/package.json b/packages/workflow-template-solve-issue/package.json index b31fdde..246c75c 100644 --- a/packages/workflow-template-solve-issue/package.json +++ b/packages/workflow-template-solve-issue/package.json @@ -10,10 +10,8 @@ }, "dependencies": { "@uncaged/workflow": "workspace:*", - "@uncaged/workflow-role-committer": "workspace:*", - "@uncaged/workflow-role-coder": "workspace:*", - "@uncaged/workflow-role-planner": "workspace:*", "@uncaged/workflow-role-preparer": "workspace:*", - "@uncaged/workflow-role-reviewer": "workspace:*" + "@uncaged/workflow-role-submitter": "workspace:*", + "zod": "^4.0.0" } } diff --git a/packages/workflow-template-solve-issue/src/developer.ts b/packages/workflow-template-solve-issue/src/developer.ts new file mode 100644 index 0000000..4a7801d --- /dev/null +++ b/packages/workflow-template-solve-issue/src/developer.ts @@ -0,0 +1,37 @@ +import type { RoleDefinition } from "@uncaged/workflow"; +import * as z from "zod/v4"; + +export const developerMetaSchema = z.object({ + branch: z.string(), + commitSha: z.string(), + filesChanged: z.array(z.string()), + summary: z.string(), +}); + +export type DeveloperMeta = z.infer; + +const DEVELOPER_SYSTEM = `You are the **developer**. You delegate the implementation work to the \`develop\` workflow. + +The actual implementation (planning → coding → reviewing → testing → committing) is handled by a child workflow that runs in your place. Your output is the Merkle DAG root hash of that child thread. + +Pass through the task and let the child workflow do the work.`; + +const DEVELOPER_EXTRACT_PROMPT = `The agent output is the root CAS hash of a child workflow thread. Use the cas_get tool to traverse the Merkle DAG and extract the developer summary. + +Procedure: +1. cas_get() — the root node lists all child step hashes (planner, coder, reviewer, tester, committer). +2. Find the committer step. cas_get its hash to read the committer's meta — extract branch and commitSha from there. +3. Find every coder step. cas_get each to read the coder's filesChanged. Union all filesChanged across coder steps. +4. Compose a short human-readable summary describing what the develop child workflow accomplished (drawn from the coder summaries, or a synthesis of them). + +Return: { branch, commitSha, filesChanged, summary }.`; + +export const developerRole: RoleDefinition = { + description: + "Delegates the actual implementation to the develop workflow (workflow-as-agent). Produces a summary by traversing the child thread's Merkle DAG.", + systemPrompt: DEVELOPER_SYSTEM, + extractPrompt: DEVELOPER_EXTRACT_PROMPT, + schema: developerMetaSchema, + extractRefs: () => [], + extractMode: "react", +}; diff --git a/packages/workflow-template-solve-issue/src/index.ts b/packages/workflow-template-solve-issue/src/index.ts index 317414d..a59dd60 100644 --- a/packages/workflow-template-solve-issue/src/index.ts +++ b/packages/workflow-template-solve-issue/src/index.ts @@ -5,38 +5,28 @@ import { type LlmProvider, type WorkflowDefinition, type WorkflowFn, + workflowAsAgent, } from "@uncaged/workflow"; import { solveIssueModerator } from "./moderator.js"; import { SOLVE_ISSUE_WORKFLOW_DESCRIPTION, type SolveIssueMeta, solveIssueRoles } from "./roles.js"; -export { - type CoderMeta, - coderMetaSchema, - coderRole, -} from "@uncaged/workflow-role-coder"; -export { - type CommitterMeta, - committerMetaSchema, - committerRole, -} from "@uncaged/workflow-role-committer"; -export { - type PlannerMeta, - phaseSchema, - plannerMetaSchema, - plannerRole, -} from "@uncaged/workflow-role-planner"; export { type PreparerMeta, preparerMetaSchema, preparerRole, } from "@uncaged/workflow-role-preparer"; export { - type ReviewerMeta, - reviewerMetaSchema, - reviewerRole, -} from "@uncaged/workflow-role-reviewer"; + type SubmitterMeta, + submitterMetaSchema, + submitterRole, +} from "@uncaged/workflow-role-submitter"; export { buildSolveIssueDescriptor } from "./descriptor.js"; +export { + type DeveloperMeta, + developerMetaSchema, + developerRole, +} from "./developer.js"; export { solveIssueModerator } from "./moderator.js"; export { SOLVE_ISSUE_WORKFLOW_DESCRIPTION, @@ -51,10 +41,25 @@ export const solveIssueWorkflowDefinition: WorkflowDefinition = moderator: solveIssueModerator, }; +/** + * Build the solve-issue {@link WorkflowFn}. + * + * The `developer` role always delegates to the registered `develop` workflow via + * {@link workflowAsAgent}; if the caller supplies their own `developer` override in + * `binding.overrides`, it takes precedence so tests and custom hosts can stub it. + */ export function createSolveIssueRun( binding: AgentBinding, extract: ExtractFn, llmProvider: LlmProvider | null, ): WorkflowFn { - return createWorkflow(solveIssueWorkflowDefinition, binding, extract, llmProvider); + const developerOverride = binding.overrides?.developer ?? workflowAsAgent("develop"); + const mergedBinding: AgentBinding = { + agent: binding.agent, + overrides: { + ...(binding.overrides ?? {}), + developer: developerOverride, + }, + }; + return createWorkflow(solveIssueWorkflowDefinition, mergedBinding, extract, llmProvider); } diff --git a/packages/workflow-template-solve-issue/src/moderator.ts b/packages/workflow-template-solve-issue/src/moderator.ts index ee4c9b3..fd27422 100644 --- a/packages/workflow-template-solve-issue/src/moderator.ts +++ b/packages/workflow-template-solve-issue/src/moderator.ts @@ -1,52 +1,9 @@ -import type { Moderator, ModeratorContext } from "@uncaged/workflow"; +import type { Moderator } from "@uncaged/workflow"; import { END } from "@uncaged/workflow"; import type { SolveIssueMeta } from "./roles.js"; -function coderFinishedAllPlannedPhases( - phases: ReadonlyArray<{ hash: string }>, - coderCompletedPhases: ReadonlyArray, -): boolean { - if (phases.length === 0) { - return true; - } - const plannedHashes = new Set(phases.map((p) => p.hash)); - const lastHash = phases[phases.length - 1].hash; - const explicit = new Set(coderCompletedPhases.filter((h) => plannedHashes.has(h))); - if (phases.every((p) => explicit.has(p.hash))) { - return true; - } - if (coderCompletedPhases.some((h) => h === lastHash)) { - return true; - } - return false; -} - -function nextAfterCoder( - ctx: ModeratorContext, - 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 coderCompletedPhases = ctx.steps - .filter((s) => s.role === "coder") - .map((s) => s.meta.completedPhase); - const allDone = coderFinishedAllPlannedPhases(phases, coderCompletedPhases); - 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; - if (ctx.steps.length === 0) { return "preparer"; } @@ -54,31 +11,14 @@ export const solveIssueModerator: Moderator = (ctx) => { const last = ctx.steps[ctx.steps.length - 1]; if (last.role === "preparer") { - return "planner"; + return "developer"; } - if (last.role === "planner") { - return "coder"; + if (last.role === "developer") { + return "submitter"; } - if (last.role === "coder") { - return nextAfterCoder(ctx, maxRounds); - } - - if (last.role === "reviewer") { - if (last.meta.status === "approved") { - return "committer"; - } - if (ctx.steps.length < maxRounds - 1) { - return "coder"; - } - return END; - } - - if (last.role === "committer") { - if (last.meta.status === "recoverable" && ctx.steps.length < maxRounds - 1) { - return "coder"; - } + if (last.role === "submitter") { return END; } diff --git a/packages/workflow-template-solve-issue/src/roles.ts b/packages/workflow-template-solve-issue/src/roles.ts index 625269a..f177bdc 100644 --- a/packages/workflow-template-solve-issue/src/roles.ts +++ b/packages/workflow-template-solve-issue/src/roles.ts @@ -1,19 +1,16 @@ import type { RoleDefinition } from "@uncaged/workflow"; -import { type CoderMeta, coderRole } from "@uncaged/workflow-role-coder"; -import { type CommitterMeta, committerRole } from "@uncaged/workflow-role-committer"; -import { type PlannerMeta, plannerRole } from "@uncaged/workflow-role-planner"; import { type PreparerMeta, preparerRole } from "@uncaged/workflow-role-preparer"; -import { type ReviewerMeta, reviewerRole } from "@uncaged/workflow-role-reviewer"; +import { type SubmitterMeta, submitterRole } from "@uncaged/workflow-role-submitter"; + +import { type DeveloperMeta, developerRole } from "./developer.js"; export const SOLVE_ISSUE_WORKFLOW_DESCRIPTION = - "Prepare repo context, plan phases, implement incrementally, review, and commit to resolve an issue end-to-end (preparer → planner → coder [repeat per phase] → reviewer → committer)."; + "Resolve an issue end-to-end by preparing the repo, delegating implementation to the develop workflow, and opening a pull request (preparer → developer → submitter)."; export type SolveIssueMeta = { preparer: PreparerMeta; - planner: PlannerMeta; - coder: CoderMeta; - reviewer: ReviewerMeta; - committer: CommitterMeta; + developer: DeveloperMeta; + submitter: SubmitterMeta; }; export type SolveIssueRoles = { @@ -22,8 +19,6 @@ export type SolveIssueRoles = { export const solveIssueRoles: SolveIssueRoles = { preparer: preparerRole, - planner: plannerRole, - coder: coderRole, - reviewer: reviewerRole, - committer: committerRole, + developer: developerRole, + submitter: submitterRole, }; diff --git a/packages/workflow-template-solve-issue/tsconfig.json b/packages/workflow-template-solve-issue/tsconfig.json index 5963394..70efff6 100644 --- a/packages/workflow-template-solve-issue/tsconfig.json +++ b/packages/workflow-template-solve-issue/tsconfig.json @@ -8,9 +8,7 @@ "include": ["src/**/*.ts"], "references": [ { "path": "../workflow" }, - { "path": "../workflow-role-coder" }, - { "path": "../workflow-role-committer" }, - { "path": "../workflow-role-planner" }, - { "path": "../workflow-role-reviewer" } + { "path": "../workflow-role-preparer" }, + { "path": "../workflow-role-submitter" } ] } diff --git a/tsconfig.json b/tsconfig.json index 463fc15..4acabb4 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -22,7 +22,9 @@ { "path": "packages/workflow-role-committer" }, { "path": "packages/workflow-role-coder" }, { "path": "packages/workflow-role-planner" }, + { "path": "packages/workflow-role-preparer" }, { "path": "packages/workflow-role-reviewer" }, + { "path": "packages/workflow-role-submitter" }, { "path": "packages/workflow-role-tester" }, { "path": "packages/workflow-agent-cursor" }, { "path": "packages/workflow-agent-hermes" },