Skip to content

feat: let agents declare a plan_persona instruction#3168

Closed
trungutt wants to merge 2 commits into
docker:mainfrom
trungutt:plan-persona
Closed

feat: let agents declare a plan_persona instruction#3168
trungutt wants to merge 2 commits into
docker:mainfrom
trungutt:plan-persona

Conversation

@trungutt

Copy link
Copy Markdown
Contributor

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:

instruction: |
  You are an executor. Fix files immediately. Never ask clarifying
  questions. Tool call discipline before FIRST response. …

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_persona whose 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["&lt;system-reminder&gt;<br/>guardrail line +<br/>persona instruction"]
    C --> E[GetMessages extras]
    D --> E
Loading
  • latest.AgentConfig gains an optional PlanPersona *PlanPersonaConfig. The only field today is instruction. The block is intentionally minimal so follow-ups can add model, toolsets, etc. additively without locking the shape.
  • agent.Agent carries a planInstruction string and exposes PlanInstruction() / WithPlanInstruction(...). The canonical Instruction() is untouched — the persona is a per-mode override, not a replacement.
  • runtime/plan_mode.go's planModeReminderMessages takes 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.
  • teamloader expands ${env.X} placeholders in the persona instruction the same way it does for Instruction.
  • agent-schema.json adds plan_persona to the agent properties and a PlanPersonaConfig definition.

Example:

agents:
  root:
    instruction: |
      You are an executor. Act now. Never ask clarifying questions.
    plan_persona:
      instruction: |
        You plan. You do not execute. Ask clarifying questions and
        iterate with the user. Produce a numbered, file-specific plan.

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.model for per-mode model overrides.
  • plan_persona.toolsets for per-mode toolset overrides.
  • A plan-file artifact + plan_exit tool — 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 main and reviewed as a single commit.

trungutt added 2 commits June 16, 2026 11:18
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 docker-agent left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Assessment: 🟢 APPROVE

The PR cleanly implements per-agent plan_persona overrides for plan mode. The three key correctness properties all hold:

  1. Guardrail is unconditionally prependedplanModeReminderContent always writes planPersonaGuardrail before the persona text; there is no code path where a persona can suppress the read-only-tools constraint.
  2. Whitespace-only instructions are safely handledstrings.TrimSpace is applied in planModeReminderContent before the empty-string check, so a whitespace-only persona gracefully falls back to the canned reminder.
  3. Nil-agent path is guarded — the a == nil early 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.

@trungutt

Copy link
Copy Markdown
Contributor Author

Reopening on the fork (trungutt/docker-agent) with base set to plan-mode so the diff is correctly stacked on #3140. Will be retargeted to docker/docker-agent:main once #3140 lands.

@trungutt trungutt closed this Jun 19, 2026
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.

2 participants