Skip to content
Open
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
327 changes: 272 additions & 55 deletions agent-schema.json

Large diffs are not rendered by default.

35 changes: 32 additions & 3 deletions docs/configuration/agents/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ agents:
num_history_items: int # Optional: limit conversation history
skills: boolean | [list] # Optional: enable skill discovery (true/false or list of names and/or sources)
commands: # Optional: named prompts
name: "prompt text"
name: "prompt text" # or {instruction: "prompt", agent: "sub_agent_name"}
welcome_message: string # Optional: message shown at session start
handoffs: [list] # Optional: agent names this agent can hand off to
hooks: # Optional: lifecycle hooks
Expand Down Expand Up @@ -85,7 +85,7 @@ agents:
| `max_old_tool_call_tokens` | int | ✗ | Maximum number of tokens to keep from old tool call arguments and results. Older tool calls beyond this budget have their content replaced with a placeholder, saving context space. Tokens are approximated as `len/4`. Set to `-1` to disable truncation (unlimited). Default: `40000`. |
| `num_history_items` | int | ✗ | Limit the number of conversation history messages sent to the model. Useful for managing context window size with long conversations. Default: unlimited (all messages sent). |
| `skills` | bool/array | ✗ | Enable automatic skill discovery. `true` loads all discovered local skills, `false` disables them. A list can mix skill sources (`local` or `https://…` URLs) and skill names to include — see [Skills]({{ '/features/skills/' | relative_url }}). |
| `commands` | object | ✗ | Named prompts that can be run with `docker agent run config.yaml /command_name`. |
| `commands` | object | ✗ | Named prompts that can be run with `docker agent run config.yaml /command_name`. Can be simple strings or objects with `instruction` and/or `agent` fields for agent switching. See [Named Commands](#named-commands) below. |
| `welcome_message` | string | ✗ | Message displayed to the user when a session starts. Useful for providing context or instructions. |
| `handoffs` | array | ✗ | List of agent names this agent can hand off the conversation to. Enables the `handoff` tool. See [Handoffs Routing]({{ '/concepts/multi-agent/#handoffs-routing' | relative_url }}). |
| `hooks` | object | ✗ | Lifecycle hooks for running commands at various points. See [Hooks]({{ '/configuration/hooks/' | relative_url }}). |
Expand Down Expand Up @@ -251,7 +251,7 @@ agents:

## Named Commands

Define reusable prompt shortcuts:
Define reusable prompt shortcuts that can send prompts to the current agent or switch to a different sub-agent:

```yaml
agents:
Expand All @@ -263,8 +263,37 @@ agents:
logs: "Show me the last 50 lines of system logs"
greet: "Say hello to ${env.USER}"
deploy: "Deploy ${env.PROJECT_NAME || 'app'} to ${env.ENV || 'staging'}"

# Advanced format with agent switching
plan:
agent: planner # Switch to the 'planner' sub-agent
instruction: "Create a detailed plan for: $1" # Optional: send this prompt after switching

# Agent switching without instruction - forwards remaining text as prompt
review:
agent: reviewer # Any text after /review is sent to the reviewer agent
```


### Command Formats

Commands support two formats:

1. **Simple string format**: The string becomes the instruction sent to the current agent
```yaml
df: "Check disk space"
```

2. **Advanced object format**: Supports agent switching and optional instructions
```yaml
plan:
agent: planner # Required: name of sub-agent to switch to
instruction: "Plan: $1" # Optional: prompt to send after switching
description: "Switch to planning mode" # Optional: shown in help text
```

When `agent` is set without `instruction`, any text typed after the slash command (e.g., `/plan build a web app`) is forwarded as a prompt to the target agent. The target agent must be listed in the current agent's `sub_agents` array.

```bash
# Run commands from the CLI
$ docker agent run agent.yaml /df
Expand Down
63 changes: 63 additions & 0 deletions examples/agent_switching_commands.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
#!/usr/bin/env docker agent run

# This example demonstrates slash commands that switch the active agent.
# Type "/plan" in the TUI to hand the conversation off to the planner sub-agent,
# "/review" to switch to the reviewer, and "/back" to return to the root agent.
#
# Anything typed after the slash command (e.g. "/plan add a logout button") is
# forwarded as the first user prompt to the target agent.

models:
claude:
provider: anthropic
model: claude-sonnet-4-5

agents:
root:
model: claude
description: The default agent. Implements the work once a plan exists.
instruction: |
You are the implementation agent. You write and edit code.
If the user asks for a plan or design discussion, use the /plan command
to switch to the planner sub-agent.
sub_agents:
- planner
- reviewer
toolsets:
- type: filesystem
- type: shell
commands:
plan:
description: "Hand off to the planner sub-agent"
agent: planner
review:
description: "Hand off to the reviewer sub-agent"
agent: reviewer

planner:
model: claude
description: Plans the work before implementation.
instruction: |
You are the planning agent. Ask clarifying questions, then produce a
step-by-step plan in markdown. Do not write code.
sub_agents:
- root
commands:
back:
description: "Return to the root agent"
agent: root

reviewer:
model: claude
description: Reviews local changes for quality and security.
instruction: |
You review the local Git changes and provide concise, actionable feedback
on code quality, security, and maintainability.
sub_agents:
- root
toolsets:
- type: shell
commands:
back:
description: "Return to the root agent"
agent: root
9 changes: 9 additions & 0 deletions pkg/app/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -301,6 +301,15 @@ func (a *App) ResolveCommand(ctx context.Context, userInput string) string {
return runtime.ResolveCommand(ctx, a.runtime, userInput)
}

// LookupCommand parses userInput as a /command invocation and returns the
// matching command, the trailing arguments, and whether a match was found.
// Callers that want to act on command metadata (for example switching to a
// sub-agent declared via the `agent:` field) should call this before
// ResolveCommand to inspect the raw command.
func (a *App) LookupCommand(ctx context.Context, userInput string) (types.Command, string, bool) {
return runtime.LookupCommand(ctx, a.runtime, userInput)
}

// EmitStartupInfo emits initial agent, team, and toolset information to the provided channel
func (a *App) EmitStartupInfo(ctx context.Context, events chan runtime.Event) {
a.runtime.EmitStartupInfo(ctx, a.session, runtime.NewChannelSink(events))
Expand Down
14 changes: 13 additions & 1 deletion pkg/cli/runner.go
Original file line number Diff line number Diff line change
Expand Up @@ -326,9 +326,21 @@ func Run(ctx context.Context, out *Printer, cfg Config, rt runtime.Runtime, sess
// attachment was used). Callers should pass that path to
// session.Session.AddAttachedFile so sub-agents inherit the file context.
func PrepareUserMessage(ctx context.Context, rt runtime.Runtime, userInput, globalAttachPath string) (*session.Message, string) {
// Resolve any /command to its prompt text
// Resolve any /command to its prompt text BEFORE switching agents.
// This ensures the command is looked up in the original agent's command table.
resolvedContent := runtime.ResolveCommand(ctx, rt, userInput)

// Switch the active agent if the /command targets a sub-agent.
// 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 != "" {
Comment thread
dgageot marked this conversation as resolved.
// If the agent switch fails, we must not proceed with sending the message
// to the wrong agent. Return an empty message to signal the error.
if err := rt.SetCurrentAgent(cmd.Agent); err != nil {
Comment thread
dgageot marked this conversation as resolved.
slog.WarnContext(ctx, "Failed to switch agent for /command", "agent", cmd.Agent, "error", err)
return session.UserMessage(""), ""
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] Empty message silently added to session and forwarded to LLM on agent-switch failure

PrepareUserMessage returns session.UserMessage("") here to "signal" the error, but the caller in Run (line 107) unconditionally does:

userMsg, attachedPath := PrepareUserMessage(ctx, rt, userInput, cfg.AttachmentPath)
sess.AddMessage(userMsg)   // no guard — empty message is added
rt.RunStream(ctx, sess)    // LLM receives empty user message in history

There is no nil/empty check at the call site. The LLM will receive a blank user turn in the conversation history, which can confuse the model and silently corrupt the session. The TUI path (handleAgentCommand) correctly suppresses the message on failure — the CLI path needs the same treatment.

Suggested fix — return error explicitly so callers are forced to handle it:

func PrepareUserMessage(...) (*session.Message, string, error) {
    ...
    if err := rt.SetCurrentAgent(cmd.Agent); err != nil {
        slog.WarnContext(ctx, "Failed to switch agent for /command", ...)
        return nil, "", fmt.Errorf("switch agent %q: %w", cmd.Agent, err)
    }
    ...
    return msg, attachPath, nil
}

Then the caller can continue / surface the error instead of forwarding an empty message.

}
Comment thread
dgageot marked this conversation as resolved.
}
Comment thread
dgageot marked this conversation as resolved.
// Parse for /attach commands in the message
messageText, attachPath := ParseAttachCommand(resolvedContent)

Expand Down
174 changes: 174 additions & 0 deletions pkg/cli/runner_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,13 @@ package cli
import (
"bytes"
"context"
"errors"
"sync"
"testing"

"gotest.tools/v3/assert"

"github.com/docker/docker-agent/pkg/config/types"
"github.com/docker/docker-agent/pkg/runtime"
"github.com/docker/docker-agent/pkg/session"
"github.com/docker/docker-agent/pkg/sessiontitle"
Expand All @@ -27,6 +29,28 @@ type mockRuntime struct {
elicitationLastAction tools.ElicitationAction
}

// mockRuntimeWithOverrides extends mockRuntime to allow method overriding for testing
type mockRuntimeWithOverrides struct {
*mockRuntime

setCurrentAgentFn func(string) error
currentAgentInfoFn func(context.Context) runtime.CurrentAgentInfo
}

func (m *mockRuntimeWithOverrides) SetCurrentAgent(name string) error {
if m.setCurrentAgentFn != nil {
return m.setCurrentAgentFn(name)
}
return m.mockRuntime.SetCurrentAgent(name)
}

func (m *mockRuntimeWithOverrides) CurrentAgentInfo(ctx context.Context) runtime.CurrentAgentInfo {
if m.currentAgentInfoFn != nil {
return m.currentAgentInfoFn(ctx)
}
return m.mockRuntime.CurrentAgentInfo(ctx)
}

func (m *mockRuntime) CurrentAgentName() string { return "test" }
func (m *mockRuntime) CurrentAgentInfo(context.Context) runtime.CurrentAgentInfo {
return runtime.CurrentAgentInfo{Name: "test"}
Expand Down Expand Up @@ -247,3 +271,153 @@ func TestMaxIterationsSafetyCapJSONMode(t *testing.T) {
}
assert.Equal(t, resumes[maxAutoExtensions].Type, runtime.ResumeTypeReject)
}

// TestPrepareUserMessage_AgentSwitching tests that PrepareUserMessage correctly
// handles agent-switching commands and returns empty messages on switch failures.
func TestPrepareUserMessage_AgentSwitching(t *testing.T) {
t.Parallel()

tests := []struct {
name string
userInput string
commandAgent string
setAgentErr error
expectedContent string
expectedAttach string
expectAgentSwitch bool
}{
{
name: "agent switch succeeds with trailing args",
userInput: "/plan design a login flow",
commandAgent: "planner",
setAgentErr: nil,
expectedContent: "design a login flow",
expectedAttach: "",
expectAgentSwitch: true,
},
{
name: "agent switch succeeds without trailing args",
userInput: "/plan",
commandAgent: "planner",
setAgentErr: nil,
expectedContent: "",
expectedAttach: "",
expectAgentSwitch: true,
},
{
name: "agent switch fails - returns empty message",
userInput: "/plan design a login flow",
commandAgent: "planner",
setAgentErr: errors.New("agent not found"),
expectedContent: "",
expectedAttach: "",
expectAgentSwitch: true,
},
{
name: "non-agent command - no switch",
userInput: "/test regular command",
commandAgent: "",
setAgentErr: nil,
expectedContent: "This is the test instruction regular command",
expectedAttach: "",
expectAgentSwitch: false,
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()

// Create a mock runtime that tracks SetCurrentAgent calls
var setAgentCalled bool
var setAgentName string
rt := &mockRuntimeWithOverrides{
mockRuntime: &mockRuntime{events: []runtime.Event{}},
}

// Override SetCurrentAgent to track calls and return the test error
rt.setCurrentAgentFn = func(name string) error {
setAgentCalled = true
setAgentName = name
return tt.setAgentErr
}

// Override CurrentAgentInfo to return test commands
rt.currentAgentInfoFn = func(context.Context) runtime.CurrentAgentInfo {
commands := make(map[string]types.Command)
if tt.commandAgent != "" {
commands["plan"] = types.Command{
Description: "Hand off to the planner",
Agent: tt.commandAgent,
}
} else {
commands["test"] = types.Command{
Instruction: "This is the test instruction",
}
}
return runtime.CurrentAgentInfo{
Name: "test",
Commands: commands,
}
}

msg, attachPath := PrepareUserMessage(t.Context(), rt, tt.userInput, "")

// Verify agent switch was called (or not)
assert.Equal(t, tt.expectAgentSwitch, setAgentCalled, "SetCurrentAgent call mismatch")
if tt.expectAgentSwitch && setAgentCalled {
assert.Equal(t, tt.commandAgent, setAgentName, "Wrong agent name passed to SetCurrentAgent")
}

// Verify message content
assert.Equal(t, tt.expectedContent, msg.Message.Content, "Message content mismatch")
assert.Equal(t, tt.expectedAttach, attachPath, "Attachment path mismatch")
})
}
}

func TestPrepareUserMessage_EmptyMessageForAgentOnlyCommand(t *testing.T) {
t.Parallel()

rt := &mockRuntimeWithOverrides{
mockRuntime: &mockRuntime{},
}
rt.currentAgentInfoFn = func(context.Context) runtime.CurrentAgentInfo {
return runtime.CurrentAgentInfo{
Name: "test",
Commands: map[string]types.Command{
"plan": {Agent: "planner"}, // agent-only, no instruction
},
}
}

msg, attachPath := PrepareUserMessage(t.Context(), rt, "/plan", "")

// Agent-only command with no args should produce empty message
assert.Equal(t, "", msg.Message.Content, "Expected empty message for agent-only command with no args")
assert.Equal(t, "", attachPath, "Expected no attachment")
}

// TestPrepareUserMessage_CommandResolution tests that commands are resolved
// correctly before agent switching.
func TestPrepareUserMessage_CommandResolution(t *testing.T) {
t.Parallel()

rt := &mockRuntimeWithOverrides{
mockRuntime: &mockRuntime{},
}
rt.currentAgentInfoFn = func(context.Context) runtime.CurrentAgentInfo {
return runtime.CurrentAgentInfo{
Name: "test",
Commands: map[string]types.Command{
"fix": {
Instruction: "Fix the file ${args[0]}",
},
},
}
}

msg, _ := PrepareUserMessage(t.Context(), rt, "/fix main.go", "")

assert.Equal(t, "Fix the file main.go", msg.Message.Content, "Command should be resolved with args")
}
Loading
Loading