Skip to content

Commit f9e53e0

Browse files
committed
feat: add slash commands for agent switching
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.
1 parent 369503f commit f9e53e0

8 files changed

Lines changed: 234 additions & 17 deletions

File tree

agent-schema.json

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -595,7 +595,7 @@
595595
},
596596
"CommandConfig": {
597597
"type": "object",
598-
"description": "Advanced command configuration with description and instruction",
598+
"description": "Advanced command configuration. Set 'instruction' to send a prompt to the current agent. Set 'agent' to switch the active agent to a sub-agent (e.g. /plan -> the 'planner' sub-agent). Both fields can be combined: the agent is switched first, then the instruction is sent to the new agent.",
599599
"properties": {
600600
"description": {
601601
"type": "string",
@@ -604,6 +604,10 @@
604604
"instruction": {
605605
"type": "string",
606606
"description": "The prompt sent to the agent. Supports bang commands (!`command`) and positional arguments ($1, $2, etc.)"
607+
},
608+
"agent": {
609+
"type": "string",
610+
"description": "Name of a sub-agent to switch to when this command is invoked. The agent must be reachable from the current agent's sub-agent graph. When set without 'instruction', any text typed after the slash command is forwarded as a prompt to the target agent."
607611
}
608612
},
609613
"additionalProperties": false
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
#!/usr/bin/env docker agent run
2+
3+
# This example demonstrates slash commands that switch the active agent.
4+
# Type "/plan" in the TUI to hand the conversation off to the planner sub-agent,
5+
# "/review" to switch to the reviewer, and "/back" to return to the root agent.
6+
#
7+
# Anything typed after the slash command (e.g. "/plan add a logout button") is
8+
# forwarded as the first user prompt to the target agent.
9+
10+
models:
11+
claude:
12+
provider: anthropic
13+
model: claude-sonnet-4-5
14+
15+
agents:
16+
root:
17+
model: claude
18+
description: The default agent. Implements the work once a plan exists.
19+
instruction: |
20+
You are the implementation agent. You write and edit code.
21+
If the user asks for a plan or design discussion, use the /plan command
22+
to switch to the planner sub-agent.
23+
sub_agents:
24+
- planner
25+
- reviewer
26+
toolsets:
27+
- type: filesystem
28+
- type: shell
29+
commands:
30+
plan:
31+
description: "Hand off to the planner sub-agent"
32+
agent: planner
33+
review:
34+
description: "Hand off to the reviewer sub-agent"
35+
agent: reviewer
36+
37+
planner:
38+
model: claude
39+
description: Plans the work before implementation.
40+
instruction: |
41+
You are the planning agent. Ask clarifying questions, then produce a
42+
step-by-step plan in markdown. Do not write code.
43+
sub_agents:
44+
- root
45+
commands:
46+
back:
47+
description: "Return to the root agent"
48+
agent: root
49+
50+
reviewer:
51+
model: claude
52+
description: Reviews local changes for quality and security.
53+
instruction: |
54+
You review the local Git changes and provide concise, actionable feedback
55+
on code quality, security, and maintainability.
56+
sub_agents:
57+
- root
58+
toolsets:
59+
- type: shell
60+
commands:
61+
back:
62+
description: "Return to the root agent"
63+
agent: root

pkg/app/app.go

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -301,6 +301,15 @@ func (a *App) ResolveCommand(ctx context.Context, userInput string) string {
301301
return runtime.ResolveCommand(ctx, a.runtime, userInput)
302302
}
303303

304+
// LookupCommand parses userInput as a /command invocation and returns the
305+
// matching command, the trailing arguments, and whether a match was found.
306+
// Callers that want to act on command metadata (for example switching to a
307+
// sub-agent declared via the `agent:` field) should call this before
308+
// ResolveCommand to inspect the raw command.
309+
func (a *App) LookupCommand(ctx context.Context, userInput string) (types.Command, string, bool) {
310+
return runtime.LookupCommand(ctx, a.runtime, userInput)
311+
}
312+
304313
// EmitStartupInfo emits initial agent, team, and toolset information to the provided channel
305314
func (a *App) EmitStartupInfo(ctx context.Context, events chan runtime.Event) {
306315
a.runtime.EmitStartupInfo(ctx, a.session, runtime.NewChannelSink(events))

pkg/cli/runner.go

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -326,6 +326,15 @@ func Run(ctx context.Context, out *Printer, cfg Config, rt runtime.Runtime, sess
326326
// attachment was used). Callers should pass that path to
327327
// session.Session.AddAttachedFile so sub-agents inherit the file context.
328328
func PrepareUserMessage(ctx context.Context, rt runtime.Runtime, userInput, globalAttachPath string) (*session.Message, string) {
329+
// Switch the active agent first if the /command targets a sub-agent.
330+
// This must happen before the message is added to the session so the
331+
// next runtime turn runs on the right agent.
332+
if cmd, _, ok := runtime.LookupCommand(ctx, rt, userInput); ok && cmd.Agent != "" {
333+
if err := rt.SetCurrentAgent(cmd.Agent); err != nil {
334+
slog.WarnContext(ctx, "Failed to switch agent for /command", "agent", cmd.Agent, "error", err)
335+
}
336+
}
337+
329338
// Resolve any /command to its prompt text
330339
resolvedContent := runtime.ResolveCommand(ctx, rt, userInput)
331340

pkg/config/types/commands.go

Lines changed: 29 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,12 @@ import (
1919
// instruction: |
2020
// Fix the lint issues reported by: !`golangci-lint run`
2121
// Focus on files: $1
22+
//
23+
// Agent-switching format (object value):
24+
//
25+
// plan:
26+
// description: "Hand off to the planner sub-agent"
27+
// agent: planner
2228
type Command struct {
2329
// Description is shown in completion dialogs and help text.
2430
// For simple format commands, this is empty and the instruction is used for display.
@@ -29,15 +35,30 @@ type Command struct {
2935
// - Bang commands: !`command` (executed and output inserted)
3036
// - Positional arguments: $1, $2, etc.
3137
Instruction string `json:"instruction,omitempty"`
38+
39+
// Agent, when set, makes the command switch the active agent to the named
40+
// sub-agent before any (optional) instruction is sent. This lets users
41+
// hand off to a different agent with a single slash command, e.g. /plan
42+
// to switch to the "planner" sub-agent.
43+
Agent string `json:"agent,omitempty"`
3244
}
3345

3446
// DisplayText returns the text to show in completion dialogs.
35-
// Returns Description if available, otherwise truncates the Instruction.
47+
// It returns Description when available, otherwise the Instruction. For
48+
// agent-only commands (no instruction or description), it falls back to a
49+
// short "switch to <agent>" hint so the completion dialog still has
50+
// something meaningful to show.
3651
func (c Command) DisplayText() string {
3752
if c.Description != "" {
3853
return c.Description
3954
}
40-
return c.Instruction
55+
if c.Instruction != "" {
56+
return c.Instruction
57+
}
58+
if c.Agent != "" {
59+
return "Switch to " + c.Agent
60+
}
61+
return ""
4162
}
4263

4364
// Commands represents a set of named prompts for quick-starting conversations.
@@ -113,23 +134,24 @@ func (c *Commands) UnmarshalYAML(unmarshal func(any) error) error {
113134

114135
// parseCommandValue parses a command value which can be either:
115136
// - a simple string (becomes the instruction)
116-
// - a map with description/instruction fields.
137+
// - a map with description/instruction/agent fields.
117138
func parseCommandValue(v any) (Command, error) {
118139
switch val := v.(type) {
119140
case string:
120141
return Command{Instruction: val}, nil
121142
case map[string]any:
122143
desc, _ := val["description"].(string)
123144
inst, _ := val["instruction"].(string)
145+
agent, _ := val["agent"].(string)
124146

125-
if inst == "" && desc == "" {
126-
return Command{}, errors.New("command must have at least 'instruction' or 'description'")
147+
if inst == "" && desc == "" && agent == "" {
148+
return Command{}, errors.New("command must have at least 'instruction', 'description' or 'agent'")
127149
}
128-
if inst == "" {
150+
if inst == "" && agent == "" {
129151
inst = desc
130152
}
131153

132-
return Command{Description: desc, Instruction: inst}, nil
154+
return Command{Description: desc, Instruction: inst, Agent: agent}, nil
133155
default:
134156
return Command{}, fmt.Errorf("invalid command value type: %T", v)
135157
}

pkg/runtime/commands.go

Lines changed: 35 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import (
1010
"strings"
1111
"time"
1212

13+
"github.com/docker/docker-agent/pkg/config/types"
1314
"github.com/docker/docker-agent/pkg/js"
1415
"github.com/docker/docker-agent/pkg/tools"
1516
)
@@ -18,6 +19,27 @@ import (
1819
// This includes ${args}, ${args[N]}, ${args.join(...)}, ${args.length}, etc.
1920
var argsPlaceholderRegex = regexp.MustCompile(`\$\{args[^}]*\}`)
2021

22+
// LookupCommand parses userInput as a /command invocation and returns the
23+
// matching command along with its trailing arguments. The boolean is false
24+
// when userInput doesn't start with '/' or doesn't match a configured
25+
// command. Callers that need both the resolved instruction and the original
26+
// command metadata (e.g. its target agent) typically call LookupCommand to
27+
// inspect the command before calling ResolveCommand.
28+
func LookupCommand(ctx context.Context, rt Runtime, userInput string) (cmd types.Command, rest string, ok bool) {
29+
if !strings.HasPrefix(userInput, "/") {
30+
return types.Command{}, "", false
31+
}
32+
33+
head, tail, _ := strings.Cut(userInput, " ")
34+
commandName := head[1:]
35+
36+
command, found := rt.CurrentAgentInfo(ctx).Commands[commandName]
37+
if !found {
38+
return types.Command{}, "", false
39+
}
40+
return command, tail, true
41+
}
42+
2143
// ResolveCommand transforms a /command into its expanded instruction text.
2244
// It processes:
2345
// 1. Command lookup from agent commands
@@ -26,20 +48,26 @@ var argsPlaceholderRegex = regexp.MustCompile(`\$\{args[^}]*\}`)
2648
// - ${args[0]}, ${args[1]}, etc. for positional arguments
2749
// - ${args} or ${args.join(" ")} for all arguments
2850
// - ${tool({...})} for tool calls
51+
//
52+
// For agent-switching commands (those declaring `agent: <name>` and no
53+
// instruction), ResolveCommand returns the trailing arguments verbatim so the
54+
// caller can forward them to the target sub-agent after switching. When the
55+
// command has no instruction and no arguments, the result is the empty
56+
// string, signalling "no message to send".
2957
func ResolveCommand(ctx context.Context, rt Runtime, userInput string) string {
30-
if !strings.HasPrefix(userInput, "/") {
58+
command, rest, ok := LookupCommand(ctx, rt, userInput)
59+
if !ok {
3160
return userInput
3261
}
3362

34-
cmd, rest, _ := strings.Cut(userInput, " ")
35-
commandName := cmd[1:] // Remove leading "/"
63+
instruction := command.Instruction
3664

37-
command, found := rt.CurrentAgentInfo(ctx).Commands[commandName]
38-
if !found {
39-
return userInput
65+
// Agent-only commands (no instruction): forward the trailing args verbatim
66+
// so the target sub-agent receives the user's original prompt.
67+
if instruction == "" {
68+
return rest
4069
}
4170

42-
instruction := command.Instruction
4371
args := tokenize(rest)
4472

4573
// Execute JavaScript expressions (${...} syntax) with args array

pkg/runtime/commands_test.go

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -623,3 +623,64 @@ func TestResolveCommand_ArgsSlice(t *testing.T) {
623623
result := ResolveCommand(t.Context(), rt, "/test first second third")
624624
assert.Equal(t, "Rest: second third", result)
625625
}
626+
627+
func TestLookupCommand_AgentTarget(t *testing.T) {
628+
t.Parallel()
629+
630+
rt := &mockRuntime{
631+
commands: types.Commands{
632+
"plan": types.Command{
633+
Description: "Hand off to the planner",
634+
Agent: "planner",
635+
},
636+
},
637+
}
638+
639+
cmd, rest, ok := LookupCommand(t.Context(), rt, "/plan add a logout button")
640+
assert.True(t, ok)
641+
assert.Equal(t, "planner", cmd.Agent)
642+
assert.Empty(t, cmd.Instruction)
643+
assert.Equal(t, "add a logout button", rest)
644+
}
645+
646+
func TestLookupCommand_NotACommand(t *testing.T) {
647+
t.Parallel()
648+
649+
rt := &mockRuntime{commands: types.Commands{}}
650+
651+
_, _, ok := LookupCommand(t.Context(), rt, "hello there")
652+
assert.False(t, ok)
653+
}
654+
655+
func TestResolveCommand_AgentOnlyForwardsArgs(t *testing.T) {
656+
t.Parallel()
657+
658+
rt := &mockRuntime{
659+
commands: types.Commands{
660+
"plan": types.Command{Agent: "planner"},
661+
},
662+
}
663+
664+
// With trailing args: forward verbatim to the target agent.
665+
assert.Equal(t, "design a login flow", ResolveCommand(t.Context(), rt, "/plan design a login flow"))
666+
// Without trailing args: empty so no message is sent after the switch.
667+
assert.Empty(t, ResolveCommand(t.Context(), rt, "/plan"))
668+
}
669+
670+
func TestResolveCommand_AgentWithInstruction(t *testing.T) {
671+
t.Parallel()
672+
673+
rt := &mockRuntime{
674+
commands: types.Commands{
675+
"plan": types.Command{
676+
Instruction: "Plan the work for: ${args.join(\" \")}",
677+
Agent: "planner",
678+
},
679+
},
680+
}
681+
682+
// When both instruction and agent are set, the instruction wins (the
683+
// caller is responsible for switching the agent before sending it).
684+
result := ResolveCommand(t.Context(), rt, "/plan add login")
685+
assert.Equal(t, "Plan the work for: add login", result)
686+
}

pkg/tui/handlers.go

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -624,8 +624,29 @@ func (m *appModel) handleOpenURL(url string) (tea.Model, tea.Cmd) {
624624
}
625625

626626
func (m *appModel) handleAgentCommand(command string) (tea.Model, tea.Cmd) {
627-
resolvedCommand := m.application.ResolveCommand(context.Background(), command)
628-
return m, core.CmdHandler(messages.SendMsg{Content: resolvedCommand})
627+
ctx := context.Background()
628+
629+
// Inspect the command before resolving so we can detect /commands that
630+
// switch to a sub-agent. For those, we switch first and only then send
631+
// the resolved message — otherwise the message would be processed by
632+
// the previous agent.
633+
cmd, _, ok := m.application.LookupCommand(ctx, command)
634+
resolved := m.application.ResolveCommand(ctx, command)
635+
636+
var cmds []tea.Cmd
637+
if ok && cmd.Agent != "" && cmd.Agent != m.sessionState.CurrentAgentName() {
638+
switched, switchCmd := m.handleSwitchAgent(cmd.Agent)
639+
m = switched.(*appModel)
640+
if switchCmd != nil {
641+
cmds = append(cmds, switchCmd)
642+
}
643+
}
644+
645+
if resolved != "" {
646+
cmds = append(cmds, core.CmdHandler(messages.SendMsg{Content: resolved}))
647+
}
648+
649+
return m, tea.Batch(cmds...)
629650
}
630651

631652
func (m *appModel) handleAttachFile(filePath string) (tea.Model, tea.Cmd) {

0 commit comments

Comments
 (0)