Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 26 additions & 7 deletions src/node/builtinSkills/workflow-authoring.md
Original file line number Diff line number Diff line change
Expand Up @@ -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", {
Expand All @@ -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.
Expand Down
33 changes: 26 additions & 7 deletions src/node/services/agentSkills/builtInSkillContent.generated.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7673,9 +7673,9 @@ export const BUILTIN_SKILL_FILES: Record<string, Record<string, string>> = {
"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", {',
Expand All @@ -7694,21 +7694,40 @@ export const BUILTIN_SKILL_FILES: Record<string, Record<string, string>> = {
"",
'- `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.",
Expand Down
4 changes: 2 additions & 2 deletions src/node/services/taskService.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13254,15 +13254,15 @@ 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();

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 }"
);
});

Expand Down
2 changes: 1 addition & 1 deletion src/node/services/taskService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
161 changes: 160 additions & 1 deletion src/node/services/workflows/WorkflowRunner.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
);
});

Expand Down
Loading
Loading