feat: add session-scoped plan/build mode#3140
Conversation
Plan mode lets the runtime filter tools to read-only ones and inject a
per-turn system reminder, so the agent drafts a plan instead of taking
actions. Build mode is the default and preserves today's behaviour.
The mode is a per-session property, persisted alongside the rest of the
session, and exposed via:
- POST /api/sessions { ..., mode }
- GET /api/sessions/:id -> { ..., mode }
- PATCH /api/sessions/:id/mode { mode }
Tool filtering is driven by the MCP-spec ReadOnlyHint annotation, so it
extends to user-added MCP tools without any per-tool config.
|
Question, why can't "plan mode" be implemented with a second agent that the user can select? |
I updated the PR description |
This is not true, you can always ctrl+number to switch to an agent |
|
@rumpl It could, but it scales poorly: every agent (built-in, community, user-authored) would need a hand-maintained "planner" twin kept in lockstep with its prompt, toolsets, hooks and sub-agents. Plan mode is effectively "every agent gets a free read-only twin", derived from each tool's MCP It also keeps you on the same agent under a different constraint, vs a sibling-agent switch (which has handoff cost: transfer event, possibly synthesised user prompt, separate conversation thread). |
|
Okay but... Not any agent can become a planner agent magically just by removing write tools. Its system prompt needs to be changed I would guess, also you are talking about ReadOnlyHint, but our builtin tools don't have it, do they? |
|
❌ PR Review Failed — The review agent encountered an error and could not complete the review. View logs. |
|
Two points: System prompt. It is changed per turn — ReadOnlyHint on built-ins. They do have it — 33 occurrences across every read-side tool:
Write-side tools ( On the sibling-agent alternative more broadly — beyond the points already in the description, it's painful operationally: every agent (built-in, community, user-authored) would need a hand-maintained planner twin kept in lockstep with its prompt, toolsets, hooks, and sub-agents. Two YAML surfaces per agent to keep in sync, every parent-agent change silently rots its planner sibling, and every third-party agent author has to do the same work. Plan mode collapses that to one knob across the fleet — including agents we don't author. |
|
❌ PR Review Failed — The review agent encountered an error and could not complete the review. View logs. |
|
It should cover most of #2788 |
Review —
|
Five concerns from dgageot's review on the original commit, kept in a single follow-up so the PR remains focused on shipping plan mode: 1. Sub-agent delegation bypass (blocking). Sub-sessions created via newSubSession (transfer_task, run_skill, run_background_agent) previously defaulted back to build mode. The delegation tools themselves are read-only and survive plan-mode filtering, so a plan-mode parent could delegate to a build-mode child with the full mutating toolset. Propagate parent.LoadMode() to the child so plan mode's hard filter applies across the whole delegation tree. 2. Harness gap. The harness path injected the plan-mode reminder but couldn't apply filterToolsForSession — the harness owns its toolset. The reminder text claimed 'tools have been filtered', making plan mode misleading there. Refuse plan mode for harness-backed agents with a new ErrorCodeUnsupportedMode so the guarantee stays a guarantee. 3. Data race on sess.Mode. UpdateSessionMode wrote Mode from the HTTP goroutine while the runtime read it (filterToolsForSession, planModeReminderMessages) without synchronisation. Add LoadMode/StoreMode accessors on *Session backed by s.mu (modelled on the existing Usage/SetUsage pair) and route every concurrent read/write through them. A race-detector test in TestSession_ModeAccessors pins the guarantee. 4. Inconsistent invalid-mode handling. POST /api/sessions silently coerced unknown modes to build while PATCH /api/sessions/:id/mode rejected them with 400. Add the same IsValid() gate to createSession so both endpoints behave the same way. 5. Missing API docs. Add a row for PATCH /api/sessions/:id/mode in the Sessions endpoint table at docs/features/api-server/index.md and a new 'Plan mode' section covering build/plan semantics, create- vs update-time entry points, the next-turn application rule, sub-session inheritance, and the harness-unsupported behaviour. Verb naming on the new accessors (Load/Store rather than Mode/SetMode) avoids a method-vs-field collision and matches sync/atomic.Value's convention.
Why
A planner sub-agent (e.g. the bundled
coderagent'splanner,pkg/config/builtin-agents/coder.yaml) puts planning under the agent's control. Plan mode puts it under the user's control: a session attribute the host (CLI, TUI, embedder) flips like a switch, and the model can't talk its way out of it.That distinction matters because:
coder's planner today still ships fullfilesystem(incl.write_file/edit_file); its read-only-ness is prompt-only. Plan mode strips every tool withoutAnnotations.ReadOnlyHintfrom what the model sees.ReadOnlyHintannotation, with no YAML to update.Additive: planner sub-agents stay the right answer when planning belongs in the agent's design; plan mode is the user-driven counterpart.
What
New per-session
Mode(builddefault,plan). In plan mode the runtime:Annotations.ReadOnlyHintfrom each turn's toolset (hard guarantee).<system-reminder>telling the model to plan, not act (so it doesn't waste turns trying tools that vanished).Sub-sessions (
transfer_task,run_skill,agentbackground-agent) inherit the parent's mode so the filter applies across the whole delegation tree. Harness-backed agents — which own their own toolset and can't be filtered from the runtime — refuse plan mode withcode: "unsupported_mode"rather than degrade plan mode to advisory.API:
PATCH /modeupdates the live in-memory session if a runtime is attached and persists to the store, so the next turn picks it up directly. Persistence is a newmode TEXTcolumn (migration 22); pre-existing rows normalize tobuildon read. The field is guarded bys.muviaLoadMode/StoreModeaccessors so concurrent runtime reads and HTTP writes can't race.Filter piggybacks on the existing
ExcludedToolsfilter site inruntime/loop.go; reminder rides the existing turn-start system-message splice.Scope
In: runtime filter + reminder, sub-session mode propagation, harness refusal, API + DTOs, persistence + migration, user-facing API docs, unit + HTTP + race tests.
Out (left to hosts): TUI/leantui UX, CLI flags, "exit plan mode" tool — hosts can PATCH the endpoint directly.