🤖 feat: auto-handoff plan sub-agents to exec/orchestrator after propose_plan#2408
🤖 feat: auto-handoff plan sub-agents to exec/orchestrator after propose_plan#2408
Conversation
Add planSubagentDefaultsToOrchestrator across task settings, ORPC config schema, and Tasks settings UI. Enable the built-in Plan agent as subagent-runnable and update generated built-in agent content.
7866e56 to
8d152c1
Compare
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 7866e56f16
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
|
@codex review Addressed both P1 review comments:
Added 3 new tests covering both fixes. |
|
Codex Review: Didn't find any major issues. Chef's kiss. ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
If Codex has suggestions, it will comment; otherwise it will react with 👍. Codex can also answer questions or update the PR. Try commenting "@codex address that feedback". |
Summary
Enable plan-mode sub-agent tasks to automatically hand off to Exec or Orchestrator after calling
propose_plan. Previously, the Plan agent couldn't run as a sub-agent at all. Now, a plan sub-agent writes a plan viapropose_plan, the stream stops, history is compacted to the plan content, and the task continues under Exec (default) or Orchestrator (configurable via a new Settings toggle).Background
propose_plan(UI interaction), while sub-agent tasks expectagent_report. This mismatch blocked Plan from being usable as a sub-agent.Implementation
1. New setting:
planSubagentDefaultsToOrchestratorTaskSettingstype, defaults, normalization (src/common/types/tasks.ts)src/common/orpc/schemas/api.ts)TasksSection.tsx)2. Plan agent made runnable as sub-agent
subagent.runnable: false→trueinsrc/node/builtinAgents/plan.md3. Stream stops after successful
propose_planstopAfterSuccessfulProposePlanflag inStreamRequestConfighasSuccessfulProposePlanResultstop condition (pattern-matcheshasSuccessfulAgentReportResult)parentWorkspaceId+effectiveMode === "plan")4. Auto-handoff in
TaskService.handleStreamEndfindProposePlanSuccessInPartshelper detects successfulpropose_planin stream partspropose_planget a "call propose_plan" reminder (not "call agent_report")5. Tests
Validation
make static-check✅ (typecheck, lint, fmt-check, docs sync, broken links)bun test streamManager.test.ts— 26 pass, 0 failbun test taskService.test.ts— 39 pass, 0 failRisks
readPlanFile+createRuntimeForWorkspaceare used in a new context (task stream-end). If workspace metadata is incomplete, the defensive fallback handles it.📋 Implementation Plan
Auto-handoff plan sub-agents: propose_plan → exec/orchestrator
Context / Why
Today, the built-in Plan agent (
src/node/builtinAgents/plan.md) is not runnable as a sub-agent. Even if it were, sub-agent tasks are expected to end by callingagent_report, while Plan mode ends by callingpropose_plan(which is rendered in the UI and waits for a user click).Goal: enable a new workflow where a plan-mode sub-agent can:
propose_plan, thenThis makes “plan + implement” sub-agent tasks practical and keeps token usage under control by avoiding huge
file_edit_*diffs in the implementation phase.Evidence (repo facts)
propose_plantoolsrc/common/utils/tools/toolDefinitions.tssrc/node/services/tools/propose_plan.ts(returns{ success, planPath }; does not include plan content).src/browser/components/tools/ProposePlanToolCall.tsximplements:api.workspace.replaceChatHistory(... mode: "append-compaction-boundary")api.workspace.sendMessage(... agentId: "exec"|"orchestrator")src/node/services/taskService.ts#handleStreamEndmarks tasksawaiting_reportand injects a synthetic “call agent_report” message when the stream ends without anagent_reporttool result.src/node/services/streamManager.ts#createStopWhenConditioncurrently stops autonomous loops only after successfulagent_report.steps[last].toolResults[...].src/common/types/tasks.tssrc/common/orpc/schemas/api.ts(config.getConfig/config.saveConfig)src/browser/components/Settings/sections/TasksSection.tsx(existing toggle:proposePlanImplementReplacesChatHistory).src/node/builtinAgents/plan.md:subagent.runnable: falsesrc/node/services/taskService.ts:Task.createrejects non-runnable sub-agents.Proposed approach (recommended)
Implement an automatic “Plan task handoff” path for task workspaces only:
propose_plantool call (AI SDKstopWhen).TaskService.handleStreamEnd, detect successfulpropose_planoutput and:agentIdto the target agentsubagent.runnable: trueso it can be selected by tasks.Net new product LoC estimate: ~250–450 (node + browser + schemas + tests)
Why this architecture
TaskService.handleStreamEnd).stopWhenis the safest way to prevent the plan sub-agent from continuing to other tools (especiallyagent_report) afterpropose_plan.Implementation details (ordered)
1) Add setting: default target agent after plan-task proposes plan
Files
src/common/types/tasks.tssrc/common/orpc/schemas/api.tssrc/browser/components/Settings/sections/TasksSection.tsxDesign
Add a boolean toggle in
TaskSettings:planSubagentDefaultsToOrchestrator?: boolean(defaultfalse)This exactly matches the requested UX (“flip the switch → use orchestrator; default exec”).
Code shape (tasks.ts)
ORPC schema (api.ts)
Add the new field to both
config.getConfig.output.taskSettingsandconfig.saveConfig.input.taskSettings:Settings UI
areTaskSettingsEqualto compare the new field.setProposePlanImplementReplacesChatHistory.Switchunder Task Settings, e.g.Plan sub-agents: default to OrchestratorWhen enabled, plan sub-agent tasks switch to Orchestrator after propose_plan. Otherwise they switch to Exec.2) Make Plan agent runnable as a sub-agent
File
src/node/builtinAgents/plan.mdChange:
→
This is required because
TaskService.createrejects sub-agents that aren’t runnable.3) Stop plan-task streams after successful
propose_planFiles
src/node/services/streamManager.tssrc/node/services/aiService.tsStreamManager changes
StreamRequestConfigwith a flag:stopAfterSuccessfulProposePlan?: booleancreateStopWhenConditionto optionally include a condition similar tohasSuccessfulAgentReportResult:AIService changes
Compute and pass the new flag when calling
streamManager.startStream.Concrete plumbing (recommended):
StreamManager.startStream(...), e.g.:stopAfterSuccessfulProposePlan?: booleanstartStream→createStreamAtomically→buildStreamRequestConfig→StreamRequestConfigcreateStopWhenConditionto checkrequest.stopAfterSuccessfulProposePlan.In
AIService.streamMessage(...)(src/node/services/aiService.ts), compute:…and pass it into
startStream:This guarantees the new stop behavior only affects plan-mode task workspaces (sub-agents), not the primary plan workspace.
4) Auto-handoff in
TaskService.handleStreamEndFile
src/node/services/taskService.tsAdd helpers
findProposePlanSuccessInParts(parts): { planPath: string } | nullfindAgentReportArgsInParts.part.toolName === "propose_plan"part.state === "output-available"isSuccessfulToolResult(part.output)(or an equivalentoutput.success === truecheck)Insert in
handleStreamEnd(task branch)Add this logic before the existing “missing completion tool” reminder path (currently hard-coded to
agent_report):If successful propose plan detected:
Determine
targetAgentId:cfg.taskSettings.planSubagentDefaultsToOrchestrator ? "orchestrator" : "exec"Note:
orchestratoris currentlysubagent.runnable: false, but this handoff does not create a new task; it just sends the next message in the same task workspace withagentId: "orchestrator", so no runnable check is involved.Read plan content + canonical path:
workspaceService.getInfo(workspaceId)→createRuntimeForWorkspace(metadata)readPlanFile(runtime, metadata.name, metadata.projectName, workspaceId)fromsrc/node/utils/runtime/helpers.tsAlways replace history for this auto-handoff path (do not gate on
proposePlanImplementReplacesChatHistory; that toggle is for the interactive UI buttons).Replace history with a compaction boundary message:
summaryMessageusingcreateMuxMessage(..., role:"assistant", content: startHereContent, metadata:{ compacted:"user", agentId:"plan" })workspaceService.replaceHistory(workspaceId, summaryMessage, { mode: "append-compaction-boundary", deletePlanFile: false })Update the task workspace entry in config so later reminders use the right agent:
ws.agentId = targetAgentId; ws.agentType = targetAgentId;cfg.agentAiDefaults→ current task model fallback), then set:ws.taskModelString = resolvedModelws.taskThinkingLevel = resolvedThinkingSuggested resolution snippet:
Ensure the task is marked
running(a previous plan reminder may have setawaiting_report):await this.setTaskStatus(workspaceId, "running")Clear
this.remindedAwaitingReportfor this workspace (if present) so a previous reminder doesn’t force the fallback-report path after the handoff.Send the synthetic kickoff message:
"Implement the plan""Start orchestrating the implementation of this plan."workspaceService.sendMessage(workspaceId, msg, { model, agentId: targetAgentId, thinkingLevel, experiments: ws.taskExperiments }, { synthetic: true })Return early so we do not set
awaiting_report.Update the auto-reminder logic so plan-mode tasks aren’t told to call
agent_report.Today, tasks that end a stream without
agent_reportare forced into anawaiting_reportloop with a synthetic message:“Your stream ended without calling agent_report…”. That’s wrong for Plan mode.
Determine the expected “completion tool” for the current agent:
propose_plan.agent_report.If the expected completion tool is
propose_planand the stream ended without a successfulpropose_planresult:"Your stream ended without calling propose_plan. Call propose_plan exactly once now."[{ regex_match: "^propose_plan$", action: "require" }]Critically, when we do see a successful
propose_planand are doing the plan → exec/orchestrator handoff, do not inject any “missing agent_report” message in that same stream-end.Suggested lightweight plan-like detection (good enough for the built-in Plan agent):
(Optional hardening: resolve the agent inheritance chain and use
isPlanLikeInResolvedChain(...)so custom plan-like agents also get the correct reminder.)Defensive behaviors
propose_plansucceeded:handoffInProgress: Set<workspaceId>) to avoid double-handoff if multiple stream-end events race.5) Tests
StreamManager (
src/node/services/streamManager.test.ts)createStopWhenConditionwhenstopAfterSuccessfulProposePlan: true:toolName: "propose_plan"with{ success: true }.{ success: false }.TaskService (
src/node/services/taskService.test.ts)parentWorkspaceId,agentId: "plan",taskStatus: "running".StreamEndEvent.partsarray containing a successfulpropose_plantool output.workspaceService.replaceHistorycalled (or underlying history cleared/compaction boundary appended).workspaceService.sendMessagecalled with the correct kickoff message +agentIdbased on the new setting.agentId: "exec"by default, and"orchestrator"when setting enabled.agentId: "plan") with noagent_reportand nopropose_planinevent.parts.workspaceService.sendMessagewas called with text containing"propose_plan".propose_plan(regex match).agent_report.6) Validation (local)
make fmt-checkmake lintmake typecheckmake test(or at least run the modified unit tests: StreamManager + TaskService)Alternative approach (not recommended)
Have the
propose_plantool handler itself trigger an implementation kickoff.Why not
This risks starting a second stream while the plan stream is still active (racey), and it still doesn’t prevent the model from calling additional tools after
propose_planin the same turn.stopWhen+TaskService.handleStreamEndkeeps all coordination in one place.Generated with
mux• Model:anthropic:claude-opus-4-6• Thinking:xhigh• Cost:$1.84