Skip to content

feat: add slash commands for agent switching#2790

Open
dgageot wants to merge 3 commits into
docker:mainfrom
dgageot:feat/agent-switching-commands
Open

feat: add slash commands for agent switching#2790
dgageot wants to merge 3 commits into
docker:mainfrom
dgageot:feat/agent-switching-commands

Conversation

@dgageot
Copy link
Copy Markdown
Member

@dgageot dgageot commented May 13, 2026

Summary

Adds support for slash commands that switch the active agent. For example, declaring /plan in the agent config makes the planner sub-agent take over the conversation when the user types /plan.

Usage

A new agent: field can be set on a command in agent.yaml:

agents:
  root:
    sub_agents: [planner, reviewer]
    commands:
      plan:
        description: Hand off to the planner
        agent: planner
      review:
        description: Hand off to the reviewer
        agent: reviewer

When the user types /plan, the active agent is switched to planner. Anything typed after the command (e.g. /plan add a logout button) is forwarded to the target agent as the first user message.

agent: can be combined with instruction: to switch and send a fixed prompt; on its own it acts as a pure handoff.

A complete example lives in examples/agent_switching_commands.yaml.

Changes

  • pkg/config/types/commands.go — new Agent field on Command; YAML parser accepts the agent key.
  • pkg/runtime/commands.go — new LookupCommand helper; ResolveCommand forwards trailing args verbatim for agent-only commands.
  • pkg/app/app.go — exposes App.LookupCommand.
  • pkg/tui/handlers.gohandleAgentCommand switches the active agent before sending the resolved message.
  • pkg/cli/runner.goPrepareUserMessage does the same for the non-TUI flow.
  • agent-schema.json — documents the new agent property.
  • examples/agent_switching_commands.yaml — full example with /plan, /review, /back.
  • pkg/runtime/commands_test.go — unit tests for the new behavior.

Validation

  • task lint clean.
  • task test passes for all changed packages.
  • task build succeeds.

Add support for commands that switch the active agent via a new 'agent:' field
in the commands section of agent.yaml. Users can now use /plan to switch to a
planner sub-agent, /review to switch to a reviewer, etc. Trailing arguments
after the command are forwarded to the target agent as the first prompt.
@dgageot dgageot requested a review from a team as a code owner May 13, 2026 14:01
trungutt
trungutt previously approved these changes May 13, 2026
Copy link
Copy Markdown

@docker-agent docker-agent left a comment

Choose a reason for hiding this comment

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

Assessment: 🔴 CRITICAL

Comment thread pkg/cli/runner.go
// This must happen before the message is added to the session so the
// next runtime turn runs on the right agent.
if cmd, _, ok := runtime.LookupCommand(ctx, rt, userInput); ok && cmd.Agent != "" {
if err := rt.SetCurrentAgent(cmd.Agent); err != nil {
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[HIGH] Agent switched before ResolveCommand — raw /command string sent to target agent

In PrepareUserMessage, rt.SetCurrentAgent(cmd.Agent) is called on line 333 before runtime.ResolveCommand on line 339. Since ResolveCommand delegates to LookupCommand, which calls rt.CurrentAgentInfo(ctx) to look up the command by name, after SetCurrentAgent the lookup runs against the new agent's command table — not the original agent's.

If the target agent (e.g. planner) does not have the /plan command in its own commands: map (the common case — it's usually only defined on the root agent), LookupCommand returns ok=false and ResolveCommand falls back to returning the raw input verbatim, e.g. /plan add a login button, instead of the intended resolved form add a login button. That raw slash-command string is then sent to the target agent as the first user message.

Trigger: Any agent-only slash command (no instruction:) where the target agent doesn't re-declare that command.

Fix: Capture the resolved content before switching agents (mirroring the TUI flow in handleAgentCommand, which correctly calls LookupCommand/ResolveCommand first and switches after):

resolvedContent := runtime.ResolveCommand(ctx, rt, userInput)

if cmd, _, ok := runtime.LookupCommand(ctx, rt, userInput); ok && cmd.Agent != "" {
    if err := rt.SetCurrentAgent(cmd.Agent); err != nil {
        slog.WarnContext(ctx, "Failed to switch agent for /command", "agent", cmd.Agent, "error", err)
    }
}

Comment thread pkg/tui/handlers.go Outdated
}
}

if resolved != "" {
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

[MEDIUM] Resolved message sent to old agent when handleSwitchAgent fails

When m.application.SwitchAgent(agentName) returns an error, handleSwitchAgent returns early with a notification.ErrorCmd without updating m.sessionState.CurrentAgentName. Back in handleAgentCommand, the if resolved != "" branch on line 645 is then unconditionally reached, and SendMsg{Content: resolved} is appended to cmds and dispatched via tea.Batch.

The result: the user sees the "Failed to switch to agent" notification AND has their message silently dispatched to the original (still-active) agent — not the intended target. This can cause user confusion because the message appears to be accepted even though the intended agent hand-off failed.

Suggested fix: Skip the SendMsg when the agent switch was requested but failed, by tracking whether the switch succeeded:

switchSucceeded := true
if ok && cmd.Agent != "" && cmd.Agent != m.sessionState.CurrentAgentName() {
    prevAgent := m.sessionState.CurrentAgentName()
    switched, switchCmd := m.handleSwitchAgent(cmd.Agent)
    m = switched.(*appModel)
    if switchCmd != nil {
        cmds = append(cmds, switchCmd)
    }
    switchSucceeded = m.sessionState.CurrentAgentName() != prevAgent
}

if resolved != "" && switchSucceeded {
    cmds = append(cmds, core.CmdHandler(messages.SendMsg{Content: resolved}))
}

Wires the new agent-switching slash command feature into the built-in coder agent:
- Adds a planner sub-agent with read-only tools (filesystem, fetch, todo, user_prompt)
- Adds /plan command on root agent to switch to plan mode
- Adds symmetric /back command on planner agent to hand work back
- Updates root instruction to mention the /plan command
Addresses review feedback from docker-agent bot:

1. In pkg/cli/runner.go (PrepareUserMessage):
   - Moved ResolveCommand call BEFORE SetCurrentAgent
   - This ensures the command is looked up in the original agent's command table
   - Prevents raw slash-command strings from being sent to target agents when
     the target agent doesn't have the command defined

2. In pkg/tui/handlers.go (handleAgentCommand):
   - Added switchSucceeded flag to track whether agent switch was successful
   - Only send resolved message if the agent switch succeeded
   - Prevents messages from being sent to the wrong agent when switching fails
Comment thread pkg/cli/runner.go
Comment on lines +335 to +339
// next runtime turn runs on the right agent.
if cmd, _, ok := runtime.LookupCommand(ctx, rt, userInput); ok && cmd.Agent != "" {
if err := rt.SetCurrentAgent(cmd.Agent); err != nil {
slog.WarnContext(ctx, "Failed to switch agent for /command", "agent", cmd.Agent, "error", err)
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Blocking — same bug 7df0e5a just fixed in the TUI path is still live here.

If SetCurrentAgent fails (typo in agent:, agent name removed at runtime, or — for the remote runtime — a transport error fetching the team config), we log a warning and then carry on:

resolvedContent := runtime.ResolveCommand(ctx, rt, userInput)
if cmd, _, ok := runtime.LookupCommand(ctx, rt, userInput); ok && cmd.Agent != "" {
    if err := rt.SetCurrentAgent(cmd.Agent); err != nil {
        slog.WarnContext(...) // swallowed
    }
}

For an agent-only command (no instruction), ResolveCommand returns the raw trailing args (e.g. "add a logout button" for /plan add a logout button). Those args then get wrapped into a session.Message and forwarded to the original agent, which has no /plan context and will treat the bare string as a normal user prompt. Worse, an empty string (from /plan with no args) still produces a message and gets sent.

Two options:

  • Skip sending altogether on switch failure (mirror the TUI's switchSucceeded gate), and ideally surface the error to the user so they don't think the message went through silently.
  • Or only call SetCurrentAgent when cmd.Agent != currentAgent, and propagate the error.

Either way, please add a regression test for the agent: <missing> path — PrepareUserMessage has zero coverage today.

Comment thread agent-schema.json
"type": "string",
"description": "The prompt sent to the agent. Supports bang commands (!`command`) and positional arguments ($1, $2, etc.)"
},
"agent": {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Non-blocking — schema vs. runtime mismatch.

The description promises "must be reachable from the current agent's sub-agent graph", but agentRouter.SetValidated only checks team.Agent(name) — i.e. existence in the whole team, not reachability from the current agent's sub_agents list. So a config with commands.go: { agent: foo } where foo is a sibling tree-far-away will validate fine and switch successfully.

Pick one:

  • Add a real reachability check in SetCurrentAgent (walk sub_agents transitively from the current agent), and validate at config load that every command.agent is reachable from its declaring agent.
  • Or relax the schema text to "must exist in the team".

Reachability would be the more useful guarantee — otherwise /plan from agent A can teleport you to an unrelated agent B.

Comment thread pkg/tui/handlers.go
Comment on lines +638 to +648
if ok && cmd.Agent != "" && cmd.Agent != m.sessionState.CurrentAgentName() {
prevAgent := m.sessionState.CurrentAgentName()
switched, switchCmd := m.handleSwitchAgent(cmd.Agent)
m = switched.(*appModel)
if switchCmd != nil {
cmds = append(cmds, switchCmd)
}
switchSucceeded = m.sessionState.CurrentAgentName() != prevAgent
}

if resolved != "" && switchSucceeded {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Non-blockingswitchSucceeded is inferred by comparing the agent name before/after, which couples this code to an internal side effect of handleSwitchAgent. If handleSwitchAgent ever pushes an async switch (e.g. via a tea.Cmd) the comparison will silently report success while the switch hasn't actually happened yet.

Cleaner contract: have handleSwitchAgent return (tea.Model, tea.Cmd, bool) (or split out a sync trySwitchAgent(name) error helper that this caller and the existing wrapper both use). Then this branch becomes:

if err := m.trySwitchAgent(cmd.Agent); err != nil {
    return m, notification.ErrorCmd(...)
}

Same effect, no leaky abstraction.

Comment thread pkg/cli/runner.go
if err := rt.SetCurrentAgent(cmd.Agent); err != nil {
slog.WarnContext(ctx, "Failed to switch agent for /command", "agent", cmd.Agent, "error", err)
}
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Non-blocking — both PrepareUserMessage's new branch and handleAgentCommand's switch-then-send wiring landed without unit tests. Only LookupCommand and the ResolveCommand agent-only branch are covered. A test that:

  1. Configures a runtime with /plan { agent: planner }.
  2. Calls PrepareUserMessage(rt, "/plan design X", "").
  3. Asserts (a) the runtime's current agent is now planner, and (b) the message content is "design X".

…would have caught the failure mode in the Blocking comment above and pin behaviour for future refactors.

if inst == "" {
if inst == "" && agent == "" {
inst = desc
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Nit — the inst == "" && agent == ""inst = desc fallback is preserved (good — keeps the legacy single-string-as-description shape working), but with three legal input shapes (instruction-only, agent-only, description-only) the implicit promotion now reads as magic. A one-line comment would help future readers, e.g.:

// Backwards compatibility: when only `description` is set, promote it to
// `instruction` so the legacy "description doubles as prompt" shape keeps
// working. Skipped when `agent` is set, because agent-only commands are
// meant to have no instruction.

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.

4 participants