From 508a98bf77b1d524dc361a236182af4f85d937b8 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Wed, 24 Jun 2026 15:20:24 +0000 Subject: [PATCH] feat: return workflow plan agent results --- src/node/builtinSkills/workflow-authoring.md | 33 +++- .../builtInSkillContent.generated.ts | 33 +++- src/node/services/taskService.test.ts | 4 +- src/node/services/taskService.ts | 2 +- .../services/workflows/WorkflowRunner.test.ts | 161 +++++++++++++++++- src/node/services/workflows/WorkflowRunner.ts | 53 ++++-- 6 files changed, 252 insertions(+), 34 deletions(-) diff --git a/src/node/builtinSkills/workflow-authoring.md b/src/node/builtinSkills/workflow-authoring.md index e53944d95b..42faee79fe 100644 --- a/src/node/builtinSkills/workflow-authoring.md +++ b/src/node/builtinSkills/workflow-authoring.md @@ -161,9 +161,9 @@ Runs one workflow-owned sub-agent and waits for its final report. Required options: - `id`: stable step ID used for replay; never derive from unstable ordering unless the input ordering is stable. -- `schema`: optional JSON object schema. When present, the child reports schema-shaped data through `agent_report` and `agent()` returns that structured object directly. When omitted, `agent()` returns the child report markdown string. +- `schema`: optional JSON object schema. When present, the child reports schema-shaped data through `agent_report` and `agent()` returns that structured object directly. When omitted, non-Plan agents return the child report markdown string. -Workflow agents default to `exec`. Optional fields include `title`, `agentId`, `model`, `thinking`, `isolation`, and `onRefusal`. Use `agentId: "explore"` for read-only research/discovery stages. Use `agentId: "plan"` for markdown-only planning stages: Plan completes through `propose_plan`, `agent()` returns the proposed plan markdown, and Mux records the canonical `planFilePath` in task output metadata. Do not provide `schema` for Plan agents; model plan → exec explicitly in workflow code. `model` accepts the same aliases/full model strings as the UI, `thinking` accepts `off|low|medium|high|xhigh|max` or a numeric index, and `effort` is rejected to avoid ambiguous provider-specific behavior. +Workflow agents default to `exec`. Optional fields include `title`, `agentId`, `model`, `thinking`, `isolation`, and `onRefusal`. Use `agentId: "explore"` for read-only research/discovery stages. Use `agentId: "plan"` for planning stages that complete through `propose_plan` and return `{ reportMarkdown, planFilePath }`. Do not provide `schema` for Plan agents; model plan → exec explicitly in workflow code. `model` accepts the same aliases/full model strings as the UI, `thinking` accepts `off|low|medium|high|xhigh|max` or a numeric index, and `effort` is rejected to avoid ambiguous provider-specific behavior. ```js const scope = agent("Scope this topic", { @@ -182,21 +182,40 @@ Plan agents are first-class workflow-owned planning steps: - `agent(prompt, { id, agentId: "plan" })` starts the built-in Plan agent in Plan Mode. - The child completes by calling `propose_plan`, not `agent_report`. -- Without `schema`, `agent()` returns the proposed plan markdown string. -- The durable task/step output also includes `title: "Proposed plan"`, `taskId`, and the canonical `planFilePath` beside `reportMarkdown`. -- Do not provide `schema` for Plan agents; if implementation should follow, pass the returned plan markdown to a separate `exec` step. +- Without `schema`, `agent()` returns `{ reportMarkdown, planFilePath }`. +- `reportMarkdown` is the plan content snapshot. +- `planFilePath` is the canonical path to the plan file captured at proposal time in the Plan task/runtime. It may not be readable from every isolated sibling runtime; fall back to `reportMarkdown` when needed. +- The durable task/step output also includes `title: "Proposed plan"` and `taskId` metadata, but workflow code should use the returned `reportMarkdown` and `planFilePath` fields. +- Do not provide `schema` or `outputSchema` for Plan agents; if implementation should follow, pass `planResult.reportMarkdown` to a separate `exec` step. Plan-to-exec orchestration is explicit: ```js -const plan = agent("Plan the requested change", { id: "plan", agentId: "plan" }); -const implementation = agent(`Implement this accepted plan:\n\n${plan}`, { +const planResult = agent("Plan the requested change", { id: "plan", agentId: "plan" }); +const implementation = agent(`Implement this accepted plan:\n\n${planResult.reportMarkdown}`, { id: "implement", agentId: "exec", }); return { reportMarkdown: implementation }; ``` +If a workflow returns a Plan result object directly, final result normalization uses `reportMarkdown`; include `planFilePath` inside the returned `reportMarkdown` text or `structuredOutput` when the final workflow output must show it. + +Verifier fan-out can pass the plan path and fall back to the snapshot: + +```js +const planResult = agent("Plan the requested change", { id: "plan", agentId: "plan" }); +const reviews = parallel( + ["tests", "architecture", "UX"].map( + (section) => () => + agent( + `Read the proposed plan at ${planResult.planFilePath}. Focus on ${section}, but inspect the whole plan if needed. If the path is unreadable, use this snapshot:\n\n${planResult.reportMarkdown}`, + { id: `verify-${section}`, agentId: "explore", schema: reviewSchema } + ) + ) +); +``` + ### `parallel(thunks, options?)` Runs independent workflow agent branches concurrently and returns results in input order. Each thunk should call `agent(...)` once. `options.maxParallel` may cap live child tasks. diff --git a/src/node/services/agentSkills/builtInSkillContent.generated.ts b/src/node/services/agentSkills/builtInSkillContent.generated.ts index d3346971c0..362a67d7fa 100644 --- a/src/node/services/agentSkills/builtInSkillContent.generated.ts +++ b/src/node/services/agentSkills/builtInSkillContent.generated.ts @@ -7673,9 +7673,9 @@ export const BUILTIN_SKILL_FILES: Record> = { "Required options:", "", "- `id`: stable step ID used for replay; never derive from unstable ordering unless the input ordering is stable.", - "- `schema`: optional JSON object schema. When present, the child reports schema-shaped data through `agent_report` and `agent()` returns that structured object directly. When omitted, `agent()` returns the child report markdown string.", + "- `schema`: optional JSON object schema. When present, the child reports schema-shaped data through `agent_report` and `agent()` returns that structured object directly. When omitted, non-Plan agents return the child report markdown string.", "", - 'Workflow agents default to `exec`. Optional fields include `title`, `agentId`, `model`, `thinking`, `isolation`, and `onRefusal`. Use `agentId: "explore"` for read-only research/discovery stages. Use `agentId: "plan"` for markdown-only planning stages: Plan completes through `propose_plan`, `agent()` returns the proposed plan markdown, and Mux records the canonical `planFilePath` in task output metadata. Do not provide `schema` for Plan agents; model plan → exec explicitly in workflow code. `model` accepts the same aliases/full model strings as the UI, `thinking` accepts `off|low|medium|high|xhigh|max` or a numeric index, and `effort` is rejected to avoid ambiguous provider-specific behavior.', + 'Workflow agents default to `exec`. Optional fields include `title`, `agentId`, `model`, `thinking`, `isolation`, and `onRefusal`. Use `agentId: "explore"` for read-only research/discovery stages. Use `agentId: "plan"` for planning stages that complete through `propose_plan` and return `{ reportMarkdown, planFilePath }`. Do not provide `schema` for Plan agents; model plan → exec explicitly in workflow code. `model` accepts the same aliases/full model strings as the UI, `thinking` accepts `off|low|medium|high|xhigh|max` or a numeric index, and `effort` is rejected to avoid ambiguous provider-specific behavior.', "", "```js", 'const scope = agent("Scope this topic", {', @@ -7694,21 +7694,40 @@ export const BUILTIN_SKILL_FILES: Record> = { "", '- `agent(prompt, { id, agentId: "plan" })` starts the built-in Plan agent in Plan Mode.', "- The child completes by calling `propose_plan`, not `agent_report`.", - "- Without `schema`, `agent()` returns the proposed plan markdown string.", - '- The durable task/step output also includes `title: "Proposed plan"`, `taskId`, and the canonical `planFilePath` beside `reportMarkdown`.', - "- Do not provide `schema` for Plan agents; if implementation should follow, pass the returned plan markdown to a separate `exec` step.", + "- Without `schema`, `agent()` returns `{ reportMarkdown, planFilePath }`.", + "- `reportMarkdown` is the plan content snapshot.", + "- `planFilePath` is the canonical path to the plan file captured at proposal time in the Plan task/runtime. It may not be readable from every isolated sibling runtime; fall back to `reportMarkdown` when needed.", + '- The durable task/step output also includes `title: "Proposed plan"` and `taskId` metadata, but workflow code should use the returned `reportMarkdown` and `planFilePath` fields.', + "- Do not provide `schema` or `outputSchema` for Plan agents; if implementation should follow, pass `planResult.reportMarkdown` to a separate `exec` step.", "", "Plan-to-exec orchestration is explicit:", "", "```js", - 'const plan = agent("Plan the requested change", { id: "plan", agentId: "plan" });', - "const implementation = agent(`Implement this accepted plan:\\n\\n${plan}`, {", + 'const planResult = agent("Plan the requested change", { id: "plan", agentId: "plan" });', + "const implementation = agent(`Implement this accepted plan:\\n\\n${planResult.reportMarkdown}`, {", ' id: "implement",', ' agentId: "exec",', "});", "return { reportMarkdown: implementation };", "```", "", + "If a workflow returns a Plan result object directly, final result normalization uses `reportMarkdown`; include `planFilePath` inside the returned `reportMarkdown` text or `structuredOutput` when the final workflow output must show it.", + "", + "Verifier fan-out can pass the plan path and fall back to the snapshot:", + "", + "```js", + 'const planResult = agent("Plan the requested change", { id: "plan", agentId: "plan" });', + "const reviews = parallel(", + ' ["tests", "architecture", "UX"].map(', + " (section) => () =>", + " agent(", + " `Read the proposed plan at ${planResult.planFilePath}. Focus on ${section}, but inspect the whole plan if needed. If the path is unreadable, use this snapshot:\\n\\n${planResult.reportMarkdown}`,", + ' { id: `verify-${section}`, agentId: "explore", schema: reviewSchema }', + " )", + " )", + ");", + "```", + "", "### `parallel(thunks, options?)`", "", "Runs independent workflow agent branches concurrently and returns results in input order. Each thunk should call `agent(...)` once. `options.maxParallel` may cap live child tasks.", diff --git a/src/node/services/taskService.test.ts b/src/node/services/taskService.test.ts index ce437a8ae7..9827a233a2 100644 --- a/src/node/services/taskService.test.ts +++ b/src/node/services/taskService.test.ts @@ -13254,7 +13254,7 @@ describe("TaskService", () => { const waiterError = await waiter; expect(waiterError).toBeInstanceOf(Error); expect((waiterError as Error).message).toContain( - "Workflow plan agents return plan markdown and planFilePath" + "Workflow plan agents return { reportMarkdown, planFilePath }" ); expect(replaceHistory).not.toHaveBeenCalled(); expect(sendMessage).not.toHaveBeenCalled(); @@ -13262,7 +13262,7 @@ describe("TaskService", () => { const updatedTask = findWorkspaceInConfig(config, childId); expect(updatedTask?.taskStatus).toBe("interrupted"); expect(updatedTask?.taskLaunchError).toContain( - "Workflow plan agents return plan markdown and planFilePath" + "Workflow plan agents return { reportMarkdown, planFilePath }" ); }); diff --git a/src/node/services/taskService.ts b/src/node/services/taskService.ts index 03031967b0..63a0949441 100644 --- a/src/node/services/taskService.ts +++ b/src/node/services/taskService.ts @@ -7246,7 +7246,7 @@ export class TaskService { if (args.entry.workspace.workflowTask?.outputSchema !== undefined) { const error = new Error( - "Workflow plan agents return plan markdown and planFilePath; do not provide schema/outputSchema." + "Workflow plan agents return { reportMarkdown, planFilePath }; do not provide schema/outputSchema." ); await this.editWorkspaceEntry( args.workspaceId, diff --git a/src/node/services/workflows/WorkflowRunner.test.ts b/src/node/services/workflows/WorkflowRunner.test.ts index b3d07bb413..c498cc3965 100644 --- a/src/node/services/workflows/WorkflowRunner.test.ts +++ b/src/node/services/workflows/WorkflowRunner.test.ts @@ -118,7 +118,166 @@ describe("WorkflowRunner", () => { }); await expect(runner.run("wfr_plan_schema")).rejects.toThrow( - "Workflow plan agents return plan markdown and planFilePath" + "Workflow plan agents return { reportMarkdown, planFilePath }" + ); + }); + + test("plan agent returns report markdown and plan file path object", async () => { + using tmp = new DisposableTempDir("workflow-runner-plan-result"); + const store = new WorkflowRunStore({ + sessionDir: tmp.path, + staleLeaseMs: WORKFLOW_RUNNER_TEST_STALE_LEASE_MS, + }); + await store.createRun({ + id: "wfr_plan_result", + workspaceId: "workspace-1", + workflow: definition, + source: `export default function workflow({ agent }) { + const result = agent("Plan the change", { id: "plan", agentId: "plan" }); + return { reportMarkdown: result.reportMarkdown + "\\n" + result.planFilePath }; +} +`, + args: {}, + now: "2026-05-29T00:00:00.000Z", + }); + const runner = createRunner(store, { + async runAgent() { + return { + taskId: "task_plan", + reportMarkdown: "Plan contents", + planFilePath: "/tmp/mux/plans/example.md", + structuredOutput: {}, + }; + }, + }); + + await expect(runner.run("wfr_plan_result")).resolves.toEqual({ + reportMarkdown: "Plan contents\n/tmp/mux/plans/example.md", + structuredOutput: undefined, + }); + }); + + test("parallel plan agents return report objects in input order", async () => { + using tmp = new DisposableTempDir("workflow-runner-parallel-plan-result"); + const store = new WorkflowRunStore({ + sessionDir: tmp.path, + staleLeaseMs: WORKFLOW_RUNNER_TEST_STALE_LEASE_MS, + }); + await store.createRun({ + id: "wfr_parallel_plan_result", + workspaceId: "workspace-1", + workflow: definition, + source: `export default function workflow({ agent, parallel }) { + const results = parallel([ + () => agent("Plan A", { id: "plan-a", agentId: "plan" }), + () => agent("Plan B", { id: "plan-b", agentId: "plan" }), + ]); + return { reportMarkdown: results.map((result) => result.reportMarkdown + "@" + result.planFilePath).join("|") }; +} +`, + args: {}, + now: "2026-05-29T00:00:00.000Z", + }); + const runner = createRunner(store, { + async runAgent() { + throw new Error("parallel should create captured agent specs"); + }, + async createAgentTasks(specs, lifecycle) { + for (const [index, spec] of specs.entries()) { + await lifecycle?.onTaskCreated?.(index, `task_${spec.id}`); + } + return specs.map((spec) => ({ taskId: `task_${spec.id}`, status: "starting" as const })); + }, + async waitForAgentTask(taskId) { + const id = taskId.replace("task_", ""); + return { + taskId, + reportMarkdown: `Plan ${id}`, + planFilePath: `/tmp/mux/plans/${id}.md`, + structuredOutput: {}, + }; + }, + }); + + await expect(runner.run("wfr_parallel_plan_result")).resolves.toEqual({ + reportMarkdown: "Plan plan-a@/tmp/mux/plans/plan-a.md|Plan plan-b@/tmp/mux/plans/plan-b.md", + }); + }); + + test("pipeline plan agents pass report objects to the next stage", async () => { + using tmp = new DisposableTempDir("workflow-runner-pipeline-plan-result"); + const store = new WorkflowRunStore({ + sessionDir: tmp.path, + staleLeaseMs: WORKFLOW_RUNNER_TEST_STALE_LEASE_MS, + }); + await store.createRun({ + id: "wfr_pipeline_plan_result", + workspaceId: "workspace-1", + workflow: definition, + source: `export default function workflow({ agent, pipeline }) { + const results = pipeline( + ["a", "b"], + (item) => agent("Plan " + item, { id: "plan-" + item, agentId: "plan" }), + (plan) => plan.reportMarkdown + "@" + plan.planFilePath + ); + return { reportMarkdown: results.join("|") }; +} +`, + args: {}, + now: "2026-05-29T00:00:00.000Z", + }); + const runner = createRunner(store, { + async runAgent() { + throw new Error("pipeline should start captured agent specs"); + }, + async createAgentTasks(specs, lifecycle) { + for (const [index, spec] of specs.entries()) { + await lifecycle?.onTaskCreated?.(index, `task_${spec.id}`); + } + return specs.map((spec) => ({ taskId: `task_${spec.id}`, status: "starting" as const })); + }, + async waitForAgentTask(taskId) { + const id = taskId.replace("task_", ""); + return { + taskId, + reportMarkdown: `Plan ${id}`, + planFilePath: `/tmp/mux/plans/${id}.md`, + structuredOutput: {}, + }; + }, + }); + + await expect(runner.run("wfr_pipeline_plan_result")).resolves.toEqual({ + reportMarkdown: "Plan plan-a@/tmp/mux/plans/plan-a.md|Plan plan-b@/tmp/mux/plans/plan-b.md", + }); + }); + + test("plan agent fails fast when task output is missing plan file path", async () => { + using tmp = new DisposableTempDir("workflow-runner-plan-missing-path"); + const store = new WorkflowRunStore({ + sessionDir: tmp.path, + staleLeaseMs: WORKFLOW_RUNNER_TEST_STALE_LEASE_MS, + }); + await store.createRun({ + id: "wfr_plan_missing_path", + workspaceId: "workspace-1", + workflow: definition, + source: `export default function workflow({ agent }) { + const result = agent("Plan the change", { id: "plan", agentId: "plan" }); + return { reportMarkdown: result.reportMarkdown }; +} +`, + args: {}, + now: "2026-05-29T00:00:00.000Z", + }); + const runner = createRunner(store, { + async runAgent() { + return { taskId: "task_plan", reportMarkdown: "Plan contents", structuredOutput: {} }; + }, + }); + + await expect(runner.run("wfr_plan_missing_path")).rejects.toThrow( + "Workflow plan agent result is missing planFilePath" ); }); diff --git a/src/node/services/workflows/WorkflowRunner.ts b/src/node/services/workflows/WorkflowRunner.ts index 60c7982bc6..5041b15e07 100644 --- a/src/node/services/workflows/WorkflowRunner.ts +++ b/src/node/services/workflows/WorkflowRunner.ts @@ -2485,10 +2485,7 @@ function __muxParallel(thunks, options) { return branchResult; } const taskResult = taskResults[branchResult.index]; - if (branchResult.hasSchema) { - return taskResult.structuredOutput; - } - return taskResult.reportMarkdown; + return __muxAgentReturnValue(taskResult, branchResult.hasSchema, branchResult.isPlanAgent); }); } function __muxPipeline(items, ...stages) { @@ -2541,14 +2538,37 @@ function __muxPipeline(items, ...stages) { } pending.delete(completion.handleId); const taskResult = completion.result; - pendingEntry.state.value = pendingEntry.collectedAgent.hasSchema - ? taskResult.structuredOutput - : taskResult.reportMarkdown; + pendingEntry.state.value = __muxAgentReturnValue( + taskResult, + pendingEntry.collectedAgent.hasSchema, + pendingEntry.collectedAgent.isPlanAgent + ); pendingEntry.state.stageIndex += 1; startNextStage(pendingEntry.state); } return states.map((state) => state.value); } +function __muxPlanAgentResult(taskResult) { + if (typeof taskResult.reportMarkdown !== "string") { + throw new Error("Workflow plan agent result is missing reportMarkdown"); + } + if (typeof taskResult.planFilePath !== "string" || taskResult.planFilePath.length === 0) { + throw new Error("Workflow plan agent result is missing planFilePath"); + } + return { + reportMarkdown: taskResult.reportMarkdown, + planFilePath: taskResult.planFilePath, + }; +} +function __muxAgentReturnValue(taskResult, hasSchema, isPlanAgent) { + if (hasSchema) { + return taskResult.structuredOutput; + } + if (isPlanAgent) { + return __muxPlanAgentResult(taskResult); + } + return taskResult.reportMarkdown; +} function __muxApplyPatch(spec) { if (spec === null || typeof spec !== "object") { throw new Error("applyPatch requires a spec object"); @@ -2612,12 +2632,14 @@ function __muxAgent(prompt, options) { spec.agentId = spec.agentType; delete spec.agentType; } - if (spec.agentId === "plan" && Object.prototype.hasOwnProperty.call(spec, "outputSchema")) { + const hasSchema = Object.prototype.hasOwnProperty.call(spec, "outputSchema"); + const isPlanAgent = spec.agentId === "plan"; + if (isPlanAgent && hasSchema) { throw new Error( - "Workflow plan agents return plan markdown and planFilePath; do not provide schema/outputSchema." + "Workflow plan agents return { reportMarkdown, planFilePath }; do not provide schema/outputSchema." ); } - if (!Object.prototype.hasOwnProperty.call(spec, "outputSchema")) { + if (!hasSchema) { spec.markdownOnly = true; } if (__muxParallelCollectingAgents !== null) { @@ -2626,21 +2648,20 @@ function __muxAgent(prompt, options) { return { [__MUX_PARALLEL_AGENT_MARKER]: true, index, - hasSchema: Object.prototype.hasOwnProperty.call(spec, "outputSchema"), + hasSchema, + isPlanAgent, }; } if (__muxPipelineCollectingAgents !== null) { __muxPipelineCollectingAgents.push({ handle: __workflowAgentStart(spec), - hasSchema: Object.prototype.hasOwnProperty.call(spec, "outputSchema"), + hasSchema, + isPlanAgent, }); return null; } const result = __workflowAgent(spec); - if (Object.prototype.hasOwnProperty.call(spec, "outputSchema")) { - return result.structuredOutput; - } - return result.reportMarkdown; + return __muxAgentReturnValue(result, hasSchema, isPlanAgent); } ${compiled} return (async () => await __muxWorkflow({