feat: add slash commands for agent switching#2790
Conversation
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.
| // 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 { |
There was a problem hiding this comment.
[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)
}
}| } | ||
| } | ||
|
|
||
| if resolved != "" { |
There was a problem hiding this comment.
[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
| // 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) | ||
| } |
There was a problem hiding this comment.
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
switchSucceededgate), and ideally surface the error to the user so they don't think the message went through silently. - Or only call
SetCurrentAgentwhencmd.Agent != currentAgent, and propagate the error.
Either way, please add a regression test for the agent: <missing> path — PrepareUserMessage has zero coverage today.
| "type": "string", | ||
| "description": "The prompt sent to the agent. Supports bang commands (!`command`) and positional arguments ($1, $2, etc.)" | ||
| }, | ||
| "agent": { |
There was a problem hiding this comment.
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(walksub_agentstransitively from the current agent), and validate at config load that everycommand.agentis 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.
| 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 { |
There was a problem hiding this comment.
Non-blocking — switchSucceeded 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.
| if err := rt.SetCurrentAgent(cmd.Agent); err != nil { | ||
| slog.WarnContext(ctx, "Failed to switch agent for /command", "agent", cmd.Agent, "error", err) | ||
| } | ||
| } |
There was a problem hiding this comment.
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:
- Configures a runtime with
/plan { agent: planner }. - Calls
PrepareUserMessage(rt, "/plan design X", ""). - 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 | ||
| } |
There was a problem hiding this comment.
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.
Summary
Adds support for slash commands that switch the active agent. For example, declaring
/planin 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 inagent.yaml:When the user types
/plan, the active agent is switched toplanner. 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 withinstruction: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— newAgentfield onCommand; YAML parser accepts theagentkey.pkg/runtime/commands.go— newLookupCommandhelper;ResolveCommandforwards trailing args verbatim for agent-only commands.pkg/app/app.go— exposesApp.LookupCommand.pkg/tui/handlers.go—handleAgentCommandswitches the active agent before sending the resolved message.pkg/cli/runner.go—PrepareUserMessagedoes the same for the non-TUI flow.agent-schema.json— documents the newagentproperty.examples/agent_switching_commands.yaml— full example with/plan,/review,/back.pkg/runtime/commands_test.go— unit tests for the new behavior.Validation
task lintclean.task testpasses for all changed packages.task buildsucceeds.