feat: let agents declare a plan_persona instruction#3168
Closed
trungutt wants to merge 2 commits into
Closed
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.
In plan mode (added in PR docker#3140), the runtime filters the agent's toolset to read-only tools and injects a per-turn system reminder. For agents whose canonical instruction is heavily tuned for execution ("act now", "never ask clarifying questions", "fix files immediately"), layering a 'plan, don't act' reminder on top leaves two contradictory specs in the same context: the model can still push to an action-oriented conclusion even with the mutating tools hidden. Add an optional plan_persona block on AgentConfig that lets the agent author replace the per-turn plan-mode system reminder with a persona instruction tailored for planning. The runtime wraps the persona in the existing <system-reminder> envelope and prefixes a short guardrail line stating that only read-only tools are available, so persona authors own the workflow framing and tone while the read-only contract stays runtime-owned. Agents that don't declare plan_persona fall back to the canned reminder, preserving today's behaviour. agents: root: instruction: | You are an executor. Act now. plan_persona: instruction: | You plan. You do not execute. Iterate with the user.
docker-agent
left a comment
There was a problem hiding this comment.
Assessment: 🟢 APPROVE
The PR cleanly implements per-agent plan_persona overrides for plan mode. The three key correctness properties all hold:
- Guardrail is unconditionally prepended —
planModeReminderContentalways writesplanPersonaGuardrailbefore the persona text; there is no code path where a persona can suppress the read-only-tools constraint. - Whitespace-only instructions are safely handled —
strings.TrimSpaceis applied inplanModeReminderContentbefore the empty-string check, so a whitespace-only persona gracefully falls back to the canned reminder. - Nil-agent path is guarded — the
a == nilearly return preserves the canned reminder for cases where no agent is active.
The teamloader wiring, schema additions, and test coverage at all layers (agent opt round-trip, runtime reminder selection, end-to-end YAML→PlanInstruction()) look complete and correct. No bugs found in the added code.
Contributor
Author
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Why
Plan mode (#3140) is the host-facing primitive: a session flag a host (CLI, TUI, embedder) flips to put the agent into a research-and-draft posture instead of an act-now one. Two layers enforce it: the runtime strips every tool without
Annotations.ReadOnlyHint, and a per-turn<system-reminder>tells the model to plan rather than act.That works as a host primitive — but it leaves a real prompt conflict when the agent's canonical instruction is heavily tuned for execution. Many real-world coding-agent YAMLs read like the example below:
Layering a "no edits, no shell, no state changes, ask the user" reminder on top of that gives the model two contradictory specs in the same context. Even with the mutating tools hidden, the action-oriented framing leaks into behaviour: the model still pushes to a one-turn conclusion, refuses to iterate, and drops the plan in execution-completion shape. The PR title and the
<system-reminder>envelope alone aren't enough — the upstream instruction needs to change too.This PR adds the missing knob without disturbing the host-level primitive: agents can declare a
plan_personawhose instruction the runtime swaps in for the plan-mode reminder.What
flowchart LR A[session.Mode = plan] --> B{agent.PlanInstruction set?} B -- "no, today's behaviour" --> C["canned plan-mode reminder<br/>(no edits / no shell / etc.)"] B -- "yes (new)" --> D["<system-reminder><br/>guardrail line +<br/>persona instruction"] C --> E[GetMessages extras] D --> Elatest.AgentConfiggains an optionalPlanPersona *PlanPersonaConfig. The only field today isinstruction. The block is intentionally minimal so follow-ups can addmodel,toolsets, etc. additively without locking the shape.agent.Agentcarries aplanInstruction stringand exposesPlanInstruction()/WithPlanInstruction(...). The canonicalInstruction()is untouched — the persona is a per-mode override, not a replacement.runtime/plan_mode.go'splanModeReminderMessagestakes the active agent. When the agent has declared a persona, the runtime wraps it in the existing<system-reminder>envelope and prefixes a short guardrail line stating that only read-only tools are available, so persona authors own the workflow framing and tone while the read-only contract stays runtime-owned and impossible for the persona author to drop. When no persona is declared, the canned reminder is used unchanged — preserving today's behaviour for every agent that hasn't opted in.teamloaderexpands${env.X}placeholders in the persona instruction the same way it does forInstruction.agent-schema.jsonaddsplan_personato the agent properties and aPlanPersonaConfigdefinition.Example:
In build mode the agent runs the executor instruction. In plan mode the runtime sees
PlanInstruction()is non-empty, emits the persona-wrapped reminder, and the executor instruction is no longer the most recent system context — the persona is.Scope
In: schema field, runtime swap, teamloader wiring, agent-schema.json entry, unit tests at every layer (agent opt round-trip, runtime reminder selection incl. nil-agent / whitespace-only persona / canned-fallback, end-to-end teamloader YAML→
Agent.PlanInstruction()).Out (left to follow-ups):
plan_persona.modelfor per-mode model overrides.plan_persona.toolsetsfor per-mode toolset overrides.plan_exittool — orthogonal, on top of this.Dependencies
Stacked on #3140 (session-scoped plan/build mode). The new branch is based on that PR's commit; once #3140 lands this can be rebased on
mainand reviewed as a single commit.