Skip to content

🤖 feat: auto-handoff plan sub-agents to exec/orchestrator after propose_plan#2408

Open
ThomasK33 wants to merge 7 commits intomainfrom
plan-agents-zbr0
Open

🤖 feat: auto-handoff plan sub-agents to exec/orchestrator after propose_plan#2408
ThomasK33 wants to merge 7 commits intomainfrom
plan-agents-zbr0

Conversation

@ThomasK33
Copy link
Member

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 via propose_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

  • Plan mode ends with propose_plan (UI interaction), while sub-agent tasks expect agent_report. This mismatch blocked Plan from being usable as a sub-agent.
  • Goal: make "plan + implement" sub-agent tasks practical, keeping token usage low by compacting context between the planning and implementation phases.

Implementation

1. New setting: planSubagentDefaultsToOrchestrator

  • Added to TaskSettings type, defaults, normalization (src/common/types/tasks.ts)
  • Added to ORPC schema (src/common/orpc/schemas/api.ts)
  • New toggle in Settings → Tasks section (TasksSection.tsx)

2. Plan agent made runnable as sub-agent

  • Flipped subagent.runnable: falsetrue in src/node/builtinAgents/plan.md
  • Synced auto-generated docs

3. Stream stops after successful propose_plan

  • New stopAfterSuccessfulProposePlan flag in StreamRequestConfig
  • New hasSuccessfulProposePlanResult stop condition (pattern-matches hasSuccessfulAgentReportResult)
  • Activated only for plan-mode task workspaces (parentWorkspaceId + effectiveMode === "plan")

4. Auto-handoff in TaskService.handleStreamEnd

  • findProposePlanSuccessInParts helper detects successful propose_plan in stream parts
  • On detection: reads plan file, compacts history with plan content, switches agent to exec/orchestrator, sends synthetic kickoff message
  • Plan-mode tasks without propose_plan get a "call propose_plan" reminder (not "call agent_report")
  • Defensive: double-handoff guard, fallback if plan read fails

5. Tests

  • StreamManager: 3 tests covering stop condition (enabled/disabled, success/failure)
  • TaskService: 3 tests covering handoff-to-exec, handoff-to-orchestrator, and propose_plan reminder

Validation

  • make static-check ✅ (typecheck, lint, fmt-check, docs sync, broken links)
  • bun test streamManager.test.ts — 26 pass, 0 fail
  • bun test taskService.test.ts — 39 pass, 0 fail

Risks

  • Medium: The handoff path is best-effort on plan read failures (logs + continues with fallback). If plan content is missing, the exec/orchestrator phase starts with a minimal "read the plan file" instruction instead of the full plan content.
  • Low: readPlanFile + createRuntimeForWorkspace are 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 calling agent_report, while Plan mode ends by calling propose_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:

  1. write a plan and call propose_plan, then
  2. automatically stop, compact context to the plan, and
  3. immediately continue in Exec (default) or Orchestrator, selected by a new settings toggle.

This 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_plan tool
    • Definition: src/common/utils/tools/toolDefinitions.ts
    • Handler: src/node/services/tools/propose_plan.ts (returns { success, planPath }; does not include plan content).
  • Current UI flow for manual handoff
    • src/browser/components/tools/ProposePlanToolCall.tsx implements:
      • optional compaction via api.workspace.replaceChatHistory(... mode: "append-compaction-boundary")
      • then api.workspace.sendMessage(... agentId: "exec"|"orchestrator")
  • Sub-agent lifecycle + missing-report reminder
    • src/node/services/taskService.ts#handleStreamEnd marks tasks awaiting_report and injects a synthetic “call agent_report” message when the stream ends without an agent_report tool result.
  • AI SDK stop policy
    • src/node/services/streamManager.ts#createStopWhenCondition currently stops autonomous loops only after successful agent_report.
    • Condition functions can inspect steps[last].toolResults[...].
  • Settings plumbing for task-related toggles
    • Type + defaults + normalization: src/common/types/tasks.ts
    • ORPC schema: src/common/orpc/schemas/api.ts (config.getConfig / config.saveConfig)
    • UI: src/browser/components/Settings/sections/TasksSection.tsx (existing toggle: proposePlanImplementReplacesChatHistory).
  • Plan agent is currently blocked as a sub-agent
    • src/node/builtinAgents/plan.md: subagent.runnable: false
    • src/node/services/taskService.ts: Task.create rejects non-runnable sub-agents.

Proposed approach (recommended)

Implement an automatic “Plan task handoff” path for task workspaces only:

  1. Stop plan-task streams right after a successful propose_plan tool call (AI SDK stopWhen).
  2. In TaskService.handleStreamEnd, detect successful propose_plan output and:
    • compact the task workspace chat history to the plan content (append compaction boundary)
    • update the task workspace’s agentId to the target agent
    • send a synthetic “Implement the plan” / “Start orchestrating…” message under the target agent
  3. Add a global Settings → Task Settings toggle to choose the default target agent:
    • default: Exec
    • when enabled: Orchestrator
  4. Flip the built-in Plan agent to subagent.runnable: true so it can be selected by tasks.

Net new product LoC estimate: ~250–450 (node + browser + schemas + tests)

Why this architecture
  • Task workspaces already have a natural “stream-end” interception point (TaskService.handleStreamEnd).
  • stopWhen is the safest way to prevent the plan sub-agent from continuing to other tools (especially agent_report) after propose_plan.
  • Reusing the same compaction boundary behavior as the UI “Implement” button keeps context small and consistent.

Implementation details (ordered)

1) Add setting: default target agent after plan-task proposes plan

Files

  • src/common/types/tasks.ts
  • src/common/orpc/schemas/api.ts
  • src/browser/components/Settings/sections/TasksSection.tsx

Design
Add a boolean toggle in TaskSettings:

  • planSubagentDefaultsToOrchestrator?: boolean (default false)

This exactly matches the requested UX (“flip the switch → use orchestrator; default exec”).

Code shape (tasks.ts)

export interface TaskSettings {
  ...
  /**
   * When a plan-mode *sub-agent task* calls propose_plan, auto-handoff to:
   * - Exec (default)
   * - Orchestrator (when enabled)
   */
  planSubagentDefaultsToOrchestrator?: boolean;
}

export const DEFAULT_TASK_SETTINGS: TaskSettings = {
  ...,
  planSubagentDefaultsToOrchestrator: false,
};

export function normalizeTaskSettings(raw: unknown): TaskSettings {
  const record = raw && typeof raw === "object" ? (raw as Record<string, unknown>) : {};

  const planSubagentDefaultsToOrchestrator =
    typeof record.planSubagentDefaultsToOrchestrator === "boolean"
      ? record.planSubagentDefaultsToOrchestrator
      : (DEFAULT_TASK_SETTINGS.planSubagentDefaultsToOrchestrator ?? false);

  const result: TaskSettings = {
    ...,
    planSubagentDefaultsToOrchestrator,
  };

  assert(
    typeof planSubagentDefaultsToOrchestrator === "boolean",
    "normalizeTaskSettings: planSubagentDefaultsToOrchestrator must be a boolean"
  );

  return result;
}

ORPC schema (api.ts)
Add the new field to both config.getConfig.output.taskSettings and config.saveConfig.input.taskSettings:

planSubagentDefaultsToOrchestrator: z.boolean().optional(),

Settings UI

  • Update areTaskSettingsEqual to compare the new field.
  • Add a setter similar to setProposePlanImplementReplacesChatHistory.
  • Add a Switch under Task Settings, e.g.
    • Label: Plan sub-agents: default to Orchestrator
    • Description: When 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.md

Change:

subagent:
  runnable: false

subagent:
  runnable: true

This is required because TaskService.create rejects sub-agents that aren’t runnable.

3) Stop plan-task streams after successful propose_plan

Files

  • src/node/services/streamManager.ts
  • src/node/services/aiService.ts

StreamManager changes

  • Extend StreamRequestConfig with a flag:
    • stopAfterSuccessfulProposePlan?: boolean
  • Extend createStopWhenCondition to optionally include a condition similar to hasSuccessfulAgentReportResult:
const hasSuccessfulProposePlanResult: ReturnType<typeof stepCountIs> = ({ steps }) => {
  const lastStep = steps[steps.length - 1];
  return (
    lastStep?.toolResults?.some(
      (toolResult) =>
        toolResult.toolName === "propose_plan" &&
        typeof toolResult.output === "object" &&
        toolResult.output !== null &&
        (toolResult.output as { success?: unknown }).success === true
    ) ?? false
  );
};

return [
  stepCountIs(100000),
  () => request.hasQueuedMessage?.() ?? false,
  hasSuccessfulAgentReportResult,
  ...(request.stopAfterSuccessfulProposePlan ? [hasSuccessfulProposePlanResult] : []),
];

AIService changes
Compute and pass the new flag when calling streamManager.startStream.

Concrete plumbing (recommended):

  • Add a new optional boolean final parameter to StreamManager.startStream(...), e.g.:
    • stopAfterSuccessfulProposePlan?: boolean
  • Thread it through:
    • startStreamcreateStreamAtomicallybuildStreamRequestConfigStreamRequestConfig
  • Update createStopWhenCondition to check request.stopAfterSuccessfulProposePlan.

In AIService.streamMessage(...) (src/node/services/aiService.ts), compute:

const stopAfterSuccessfulProposePlan = Boolean(metadata.parentWorkspaceId && effectiveMode === "plan");

…and pass it into startStream:

await this.streamManager.startStream(
  /* existing args... */
  requestHeaders,
  stopAfterSuccessfulProposePlan
);

This guarantees the new stop behavior only affects plan-mode task workspaces (sub-agents), not the primary plan workspace.

4) Auto-handoff in TaskService.handleStreamEnd

File

  • src/node/services/taskService.ts

Add helpers

  • findProposePlanSuccessInParts(parts): { planPath: string } | null
    • Mirror findAgentReportArgsInParts.
    • Require:
      • part.toolName === "propose_plan"
      • part.state === "output-available"
      • isSuccessfulToolResult(part.output) (or an equivalent output.success === true check)

Insert in handleStreamEnd (task branch)
Add this logic before the existing “missing completion tool” reminder path (currently hard-coded to agent_report):

  1. If successful propose plan detected:

    • Determine targetAgentId:

      • cfg.taskSettings.planSubagentDefaultsToOrchestrator ? "orchestrator" : "exec"
    • Note: orchestrator is currently subagent.runnable: false, but this handoff does not create a new task; it just sends the next message in the same task workspace with agentId: "orchestrator", so no runnable check is involved.

    • Read plan content + canonical path:

      • Use workspaceService.getInfo(workspaceId)createRuntimeForWorkspace(metadata)
      • Use readPlanFile(runtime, metadata.name, metadata.projectName, workspaceId) from src/node/utils/runtime/helpers.ts
    • Always 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:

      • Build a summaryMessage using createMuxMessage(..., role:"assistant", content: startHereContent, metadata:{ compacted:"user", agentId:"plan" })
      • Call 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;
      • Resolve target-agent model/thinking (workspace override → cfg.agentAiDefaults → current task model fallback), then set:
        • ws.taskModelString = resolvedModel
        • ws.taskThinkingLevel = resolvedThinking

      Suggested resolution snippet:

      const override = entry.workspace.aiSettingsByAgent?.[targetAgentId];
      const global = cfg.agentAiDefaults?.[targetAgentId];
      
      const resolvedModel = normalizeGatewayModel(
        (override?.model ?? global?.modelString ?? entry.workspace.taskModelString ?? defaultModel).trim()
      );
      
      const resolvedThinking = enforceThinkingPolicy(
        resolvedModel,
        override?.thinkingLevel ?? global?.thinkingLevel ?? entry.workspace.taskThinkingLevel
      );
    • Ensure the task is marked running (a previous plan reminder may have set awaiting_report):

      • await this.setTaskStatus(workspaceId, "running")
    • Clear this.remindedAwaitingReport for this workspace (if present) so a previous reminder doesn’t force the fallback-report path after the handoff.

    • Send the synthetic kickoff message:

      • Exec: "Implement the plan"
      • Orchestrator: "Start orchestrating the implementation of this plan."
      • Use workspaceService.sendMessage(workspaceId, msg, { model, agentId: targetAgentId, thinkingLevel, experiments: ws.taskExperiments }, { synthetic: true })
    • Return early so we do not set awaiting_report.

  2. Update the auto-reminder logic so plan-mode tasks aren’t told to call agent_report.

    Today, tasks that end a stream without agent_report are forced into an awaiting_report loop 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:

      • If the task’s current agent is plan-like (plan mode), expect propose_plan.
      • Otherwise, expect agent_report.
    • If the expected completion tool is propose_plan and the stream ended without a successful propose_plan result:

      • Send a synthetic reminder:
        • Message: "Your stream ended without calling propose_plan. Call propose_plan exactly once now."
        • Tool policy: [{ regex_match: "^propose_plan$", action: "require" }]
      • Reuse the existing “reminded once → fallback” mechanism to avoid infinite loops.
    • Critically, when we do see a successful propose_plan and 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):

    const agentId = (entry.workspace.agentId ?? entry.workspace.agentType ?? "").trim().toLowerCase();
    const isPlanLike = agentId === "plan";

    (Optional hardening: resolve the agent inheritance chain and use isPlanLikeInResolvedChain(...) so custom plan-like agents also get the correct reminder.)

Defensive behaviors

  • If plan file read fails unexpectedly after propose_plan succeeded:
    • Log error and still proceed (send kickoff message) with a short instruction to re-open the plan file path.
  • Add a small in-memory guard (e.g., handoffInProgress: Set<workspaceId>) to avoid double-handoff if multiple stream-end events race.

5) Tests

StreamManager (src/node/services/streamManager.test.ts)

  • Add a unit test for createStopWhenCondition when stopAfterSuccessfulProposePlan: true:
    • Ensure it returns true when last step includes toolName: "propose_plan" with { success: true }.
    • Ensure it does not stop on { success: false }.

TaskService (src/node/services/taskService.test.ts)

  • Add a test: “stream-end with propose_plan success triggers handoff instead of awaiting_report reminder”
    • Setup a task workspace with parentWorkspaceId, agentId: "plan", taskStatus: "running".
    • Provide a StreamEndEvent.parts array containing a successful propose_plan tool output.
    • Assert:
      • workspaceService.replaceHistory called (or underlying history cleared/compaction boundary appended).
      • workspaceService.sendMessage called with the correct kickoff message + agentId based on the new setting.
      • Config workspace entry updated to agentId: "exec" by default, and "orchestrator" when setting enabled.
      • No “call agent_report” reminder message was sent.
  • Add a test: “plan task stream-end without propose_plan sends propose_plan reminder (not agent_report)”
    • Setup a plan-like task workspace (agentId: "plan") with no agent_report and no propose_plan in event.parts.
    • Assert:
      • workspaceService.sendMessage was called with text containing "propose_plan".
      • The toolPolicy requires propose_plan (regex match).
      • The sent message does not mention agent_report.

6) Validation (local)

  • make fmt-check
  • make lint
  • make typecheck
  • make test (or at least run the modified unit tests: StreamManager + TaskService)

Alternative approach (not recommended)

Have the propose_plan tool 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_plan in the same turn. stopWhen + TaskService.handleStreamEnd keeps all coordination in one place.


Generated with mux • Model: anthropic:claude-opus-4-6 • Thinking: xhigh • Cost: $1.84

Copy link

@chatgpt-codex-connector chatgpt-codex-connector bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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".

@ThomasK33
Copy link
Member Author

@codex review

Addressed both P1 review comments:

  1. Plan-like detection now resolves via agent inheritance chain (isPlanLikeInResolvedChain) instead of just agentId === "plan" string check. Falls back to ID check if resolution fails.
  2. sendMessage failure in handoff path now catches errors and resets task status to awaiting_report instead of leaving it stuck as running.

Added 3 new tests covering both fixes.

@chatgpt-codex-connector
Copy link

Codex Review: Didn't find any major issues. Chef's kiss.

ℹ️ 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".

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant