Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions agent-schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -725,6 +725,10 @@
"$ref": "#/definitions/CacheConfig",
"description": "Optional response cache: when the same user question is asked again, replay the previous answer instead of calling the model."
},
"plan_persona": {
"$ref": "#/definitions/PlanPersonaConfig",
"description": "Optional per-mode overrides applied while the session is in plan mode. Plan mode already filters the agent's toolset to read-only tools and injects a per-turn system reminder; plan_persona additionally replaces the agent's instruction for the duration of plan mode so the persona's framing matches its restricted toolset. Useful when the agent's normal instruction is heavily tuned for execution and would conflict with the plan-mode reminder."
},
"skills": {
"description": "Enable skills discovery for this agent. Set to true to load all discovered skills from local filesystem sources; false disables skills. A list can mix sources (\"local\" or an HTTP/HTTPS URL), skill names to include, and inline skill definitions (objects). If only names are given, local sources are loaded and filtered to just those skills.",
"oneOf": [
Expand Down Expand Up @@ -1016,6 +1020,17 @@
},
"additionalProperties": false
},
"PlanPersonaConfig": {
"type": "object",
"description": "Per-mode overrides the runtime applies to an agent when the session is in plan mode. Fields are optional; leaving one unset means 'fall back to the agent's normal value'. Plan mode also filters the agent's toolset to read-only tools and prepends a short runtime guardrail so persona authors don't need to repeat the no-edits / no-shell contract.",
"properties": {
"instruction": {
"type": "string",
"description": "Replaces the per-turn plan-mode system reminder while plan mode is active. The runtime wraps the value in a <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. Empty (or the whole plan_persona block omitted) means 'use the runtime's canned plan-mode reminder unchanged', preserving today's behaviour."
}
},
"additionalProperties": false
},
"HooksConfig": {
"type": "object",
"description": "Lifecycle hooks configuration for an agent. Hooks allow running shell commands at various points in the agent's execution lifecycle.",
Expand Down
11 changes: 11 additions & 0 deletions pkg/agent/agent.go
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,10 @@ type Agent struct {
harness *latest.HarnessConfig
hooks *latest.HooksConfig
cache *cache.Cache
// planInstruction is the persona instruction used by the runtime
// when the session is in plan mode. Empty means "use the runtime's
// canned plan-mode reminder". See [latest.PlanPersonaConfig].
planInstruction string

// warningsMu guards pendingWarnings. AddToolWarning and DrainWarnings
// may be called concurrently from the runtime loop, the MCP server,
Expand Down Expand Up @@ -79,6 +83,13 @@ func (a *Agent) Instruction() string {
return a.instruction
}

// PlanInstruction returns the persona instruction the runtime uses while
// the session is in plan mode. Empty means the agent did not declare a
// plan persona — the runtime falls back to its canned plan-mode reminder.
func (a *Agent) PlanInstruction() string {
return a.planInstruction
}

func (a *Agent) AddDate() bool {
return a.addDate
}
Expand Down
25 changes: 25 additions & 0 deletions pkg/agent/agent_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -563,3 +563,28 @@ func TestAgentToolsRecoversWhenUnderlyingToolsetDies(t *testing.T) {
assert.Equal(t, 1, stub.startCalls)
assert.Equal(t, 1, stub.restartCalls)
}

func TestPlanInstruction(t *testing.T) {
t.Run("defaults to empty when no persona is configured", func(t *testing.T) {
a := New("root", "you are an executor")
assert.Empty(t, a.PlanInstruction())
})

t.Run("WithPlanInstruction stores the persona body verbatim", func(t *testing.T) {
persona := "You plan. You do not execute."
a := New("root", "you are an executor", WithPlanInstruction(persona))
assert.Equal(t, persona, a.PlanInstruction())
// The agent's normal instruction must remain untouched — the
// persona is a per-mode override applied by the runtime, not a
// replacement of the canonical instruction.
assert.Equal(t, "you are an executor", a.Instruction())
})

t.Run("empty WithPlanInstruction clears any previously stored persona", func(t *testing.T) {
a := New("root", "you are an executor",
WithPlanInstruction("first"),
WithPlanInstruction(""),
)
assert.Empty(t, a.PlanInstruction())
})
}
9 changes: 9 additions & 0 deletions pkg/agent/opts.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,15 @@ func WithInstruction(instruction string) Opt {
}
}

// WithPlanInstruction sets the persona instruction used by the runtime
// when the session is in plan mode. An empty string clears the persona
// so the runtime falls back to its canned plan-mode reminder.
func WithPlanInstruction(instruction string) Opt {
return func(a *Agent) {
a.planInstruction = instruction
}
}

func WithToolSets(toolSet ...tools.ToolSet) Opt {
var startableToolSet []*tools.StartableToolSet
for _, ts := range toolSet {
Expand Down
13 changes: 13 additions & 0 deletions pkg/api/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -135,13 +135,25 @@ type SessionResponse struct {
OutputTokens int64 `json:"output_tokens"`
WorkingDir string `json:"working_dir,omitempty"`
Permissions *session.PermissionsConfig `json:"permissions,omitempty"`
Mode session.Mode `json:"mode,omitempty"`
}

// UpdateSessionPermissionsRequest represents a request to update session permissions.
type UpdateSessionPermissionsRequest struct {
Permissions *session.PermissionsConfig `json:"permissions"`
}

// UpdateSessionModeRequest represents a request to update a session's mode.
type UpdateSessionModeRequest struct {
Mode session.Mode `json:"mode"`
}

// UpdateSessionModeResponse represents the response from updating a session's mode.
type UpdateSessionModeResponse struct {
ID string `json:"id"`
Mode session.Mode `json:"mode"`
}

// ResumeSessionRequest represents a request to resume a session
type ResumeSessionRequest struct {
Confirmation string `json:"confirmation"`
Expand Down Expand Up @@ -304,6 +316,7 @@ type SessionSnapshotResponse struct {
Messages []session.Message `json:"messages"`
ToolsApproved bool `json:"tools_approved"`
Permissions *session.PermissionsConfig `json:"permissions,omitempty"`
Mode session.Mode `json:"mode,omitempty"`
InputTokens int64 `json:"input_tokens"`
OutputTokens int64 `json:"output_tokens"`

Expand Down
31 changes: 31 additions & 0 deletions pkg/config/latest/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -473,6 +473,37 @@ type AgentConfig struct {
UseSkills []string `json:"use_skills,omitempty"`
Hooks *HooksConfig `json:"hooks,omitempty"`
Cache *CacheConfig `json:"cache,omitempty"`

// PlanPersona overrides parts of the agent's configuration when the
// session is in plan mode. Plan mode (see session.Mode) already filters
// the agent's toolset to read-only tools and injects a per-turn system
// reminder; PlanPersona lets the agent author additionally replace the
// agent's instruction for the duration of plan mode, so the persona's
// framing matches its restricted toolset.
//
// This is useful when the agent's normal instruction is heavily tuned
// for execution (e.g. "fix files immediately", "never ask clarifying
// questions") and would conflict with the plan-mode reminder otherwise.
// Without a PlanPersona the runtime applies the canned reminder on top
// of the agent's normal instruction, which leaves the two specs in
// tension.
PlanPersona *PlanPersonaConfig `json:"plan_persona,omitempty" yaml:"plan_persona,omitempty"`
}

// PlanPersonaConfig holds the per-mode overrides the runtime applies to an
// agent when the session is in plan mode. Fields are optional; leaving one
// empty means "fall back to the agent's normal value".
type PlanPersonaConfig struct {
// Instruction replaces the per-turn plan-mode system reminder while
// plan mode is active. The runtime still wraps the value in a
// <system-reminder> envelope and prefixes a short guardrail line
// stating that only read-only tools are available, so persona authors
// don't need to repeat the constraint — they own the workflow framing
// and tone.
//
// Empty (or PlanPersona nil) means "use the runtime's canned plan-mode
// reminder unchanged", preserving today's behaviour.
Instruction string `json:"instruction,omitempty" yaml:"instruction,omitempty"`
}

// CacheConfig configures the agent's response cache. When set and Enabled
Expand Down
3 changes: 2 additions & 1 deletion pkg/runtime/harness.go
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,8 @@ func (r *LocalRuntime) runHarnessAgent(ctx context.Context, sess *session.Sessio
}()

turnStartMsgs := r.executeTurnStartHooks(ctx, sess, a, events)
messages := sess.GetMessages(a, append(baseExtra, turnStartMsgs...)...)
planReminder := planModeReminderMessages(sess, a)
messages := sess.GetMessages(a, append(append(baseExtra, turnStartMsgs...), planReminder...)...)
stop, msg, rewritten := r.executeBeforeLLMCallHooks(ctx, sess, a, modelID, 1, messages)
if stop {
slog.WarnContext(ctx, "before_llm_call hook signalled run termination",
Expand Down
41 changes: 37 additions & 4 deletions pkg/runtime/loop.go
Original file line number Diff line number Diff line change
Expand Up @@ -266,7 +266,7 @@ func (r *LocalRuntime) runStreamLoop(ctx context.Context, sess *session.Session,
sink.Emit(ErrorWithCode(ErrorCodeToolFailed, fmt.Sprintf("failed to get tools: %v", err)))
return
}
agentTools = filterExcludedTools(agentTools, sess.ExcludedTools)
agentTools = filterToolsForSession(agentTools, sess)

sink.Emit(ToolsetInfo(len(agentTools), false, a.Name()))

Expand Down Expand Up @@ -348,7 +348,7 @@ func (r *LocalRuntime) runStreamLoop(ctx context.Context, sess *session.Session,
sink.Emit(ErrorWithCode(ErrorCodeToolFailed, fmt.Sprintf("failed to get tools: %v", err)))
return
}
agentTools = filterExcludedTools(agentTools, sess.ExcludedTools)
agentTools = filterToolsForSession(agentTools, sess)

// Emit updated tool count. After a ToolListChanged MCP notification
// the cache is invalidated, so getTools above re-fetches from the
Expand Down Expand Up @@ -554,7 +554,13 @@ func (r *LocalRuntime) runTurn(
// files) refresh every turn while session-level context (cwd, OS,
// arch) stays stable — all without bloating the stored history.
turnStartMsgs := r.executeTurnStartHooks(ctx, sess, a, events)
messages := sess.GetMessages(a, slices.Concat(ls.sessionStartMsgs, ls.userPromptMsgs, turnStartMsgs)...)
// Plan-mode reminder rides alongside the turn_start hook output so it
// participates in the same per-turn splice (and the cache_control marker
// that GetMessages applies to the last extra). It is appended last so its
// instruction is the most recent system context the model sees before the
// user prompt — minimising the chance the model ignores it.
planReminder := planModeReminderMessages(sess, a)
messages := sess.GetMessages(a, slices.Concat(ls.sessionStartMsgs, ls.userPromptMsgs, turnStartMsgs, planReminder)...)
slog.DebugContext(ctx, "Retrieved messages for processing", "agent", a.Name(), "message_count", len(messages))

// before_llm_call hooks fire just before the model is invoked.
Expand Down Expand Up @@ -990,6 +996,33 @@ func filterExcludedTools(agentTools []tools.Tool, excluded []string) []tools.Too
return filtered
}

// filterToolsForSession applies all session-level tool filters: the explicit
// ExcludedTools name list (used by skill sub-sessions) and, when the session
// is in plan mode, anything whose tool definition doesn't advertise
// ReadOnlyHint. The MCP spec's ReadOnlyHint is the canonical "this tool has
// no side effects" signal, so it's the right knob for plan mode and it
// extends naturally to user-added MCP tools without any per-tool config.
func filterToolsForSession(agentTools []tools.Tool, sess *session.Session) []tools.Tool {
out := filterExcludedTools(agentTools, sess.ExcludedTools)
if sess.Mode == session.ModePlan {
out = filterToReadOnlyTools(out)
}
return out
}

// filterToReadOnlyTools keeps only tools whose definition advertises
// ReadOnlyHint. Used by plan mode to hide every write/execute tool from the
// model so it can't reach for them even if the system reminder is ignored.
func filterToReadOnlyTools(agentTools []tools.Tool) []tools.Tool {
filtered := make([]tools.Tool, 0, len(agentTools))
for _, t := range agentTools {
if t.Annotations.ReadOnlyHint {
filtered = append(filtered, t)
}
}
return filtered
}

// reprobe re-runs ensureToolSetsAreStarted after a batch of tool calls.
// If new tools became available (by name-set diff), it emits a ToolsetInfo
// event to update the TUI immediately. The new tools will be picked up by
Expand All @@ -1010,7 +1043,7 @@ func (r *LocalRuntime) reprobe(
slog.WarnContext(ctx, "reprobe: getTools failed", "agent", a.Name(), "error", err)
return
}
updated = filterExcludedTools(updated, sess.ExcludedTools)
updated = filterToolsForSession(updated, sess)

// Emit any pending warnings that getTools just generated.
r.emitAgentWarnings(a, events)
Expand Down
83 changes: 83 additions & 0 deletions pkg/runtime/plan_mode.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
package runtime

import (
"strings"

"github.com/docker/docker-agent/pkg/agent"
"github.com/docker/docker-agent/pkg/chat"
"github.com/docker/docker-agent/pkg/session"
)

// planModeReminder is the per-turn system instruction injected when a session
// is in plan mode and the active agent has not declared a plan persona. Two
// layers enforce plan mode: the runtime hides every non-read-only tool from
// the model (see filterToolsForSession in loop.go), and this reminder tells
// the model how it should behave. Hiding the tools is the hard guarantee; the
// reminder is the explanation, so the model produces a useful plan instead
// of just bouncing off missing tools.
const planModeReminder = `<system-reminder>
You are currently in PLAN MODE.

In this mode you research the codebase, ask clarifying questions, and write a
clear, actionable plan for the user. You MUST NOT make any changes to the
system:

- No edits to files (no write, edit, create, or delete).
- No shell commands or background jobs.
- No state-changing tool calls of any kind.

Only read-only tools have been made available to you for this turn. If you try
to call a tool that isn't in your list, the user has explicitly disabled it
for planning.

End the turn by presenting the plan in your final message and asking the user
to review it. The user will switch you to BUILD MODE when they want execution
to begin.
</system-reminder>`

// planPersonaGuardrail is the short preamble the runtime prepends to a
// declared plan persona. The persona owns the workflow framing and tone; the
// guardrail owns the read-only contract so persona authors don't have to
// repeat it (and can't accidentally drop it).
const planPersonaGuardrail = `You are currently in PLAN MODE. Only read-only tools have been made available to you for this turn; every state-changing tool has been filtered out by the runtime.`

// planModeReminderMessages returns the system-reminder messages to splice
// before the conversation history when sess is in plan mode. Returns nil for
// other modes so callers can use it unconditionally.
//
// When the active agent has declared a plan persona (see
// [latest.PlanPersonaConfig]), the persona's instruction is wrapped in a
// <system-reminder> envelope and prefixed with [planPersonaGuardrail] so
// persona authors own the workflow framing while the runtime keeps the
// read-only contract intact. When no persona is declared, the canned
// [planModeReminder] is used unchanged — preserving today's behaviour for
// agents that haven't opted in.
func planModeReminderMessages(sess *session.Session, a *agent.Agent) []chat.Message {
if sess == nil || sess.Mode != session.ModePlan {
return nil
}
return []chat.Message{{
Role: chat.MessageRoleSystem,
Content: planModeReminderContent(a),
}}
}

// planModeReminderContent picks the right reminder body for the active
// agent: the agent's declared plan persona (wrapped in the runtime's
// guardrail envelope) when set, otherwise the canned reminder.
func planModeReminderContent(a *agent.Agent) string {
if a == nil {
return planModeReminder
}
persona := strings.TrimSpace(a.PlanInstruction())
if persona == "" {
return planModeReminder
}
var b strings.Builder
b.WriteString("<system-reminder>\n")
b.WriteString(planPersonaGuardrail)
b.WriteString("\n\n")
b.WriteString(persona)
b.WriteString("\n</system-reminder>")
return b.String()
}
Loading
Loading