Skip to content
Merged
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
19 changes: 18 additions & 1 deletion pkg/app/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -156,7 +156,15 @@ func (a *App) SendFirstMessage() tea.Cmd {
cmds := []tea.Cmd{
func() tea.Msg {
// Use the shared PrepareUserMessage function for consistent attachment handling
userMsg, attachedPath := cli.PrepareUserMessage(context.Background(), a.runtime, *a.firstMessage, a.firstMessageAttach)
userMsg, attachedPath, err := cli.PrepareUserMessage(context.Background(), a.runtime, *a.firstMessage, a.firstMessageAttach)
if err != nil {
slog.Error("Failed to prepare first message", "error", err)
return nil
}
if userMsg == nil {
// Agent-only command with no content - agent switched but no message to send
return nil
}
// Inherit the attachment in any sub-session created by this turn.
a.session.AddAttachedFile(attachedPath)

Expand Down Expand Up @@ -301,6 +309,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
42 changes: 34 additions & 8 deletions pkg/cli/runner.go
Original file line number Diff line number Diff line change
Expand Up @@ -103,7 +103,14 @@ func Run(ctx context.Context, out *Printer, cfg Config, rt runtime.Runtime, sess
return nil
}

userMsg, attachedPath := PrepareUserMessage(ctx, rt, userInput, cfg.AttachmentPath)
userMsg, attachedPath, err := PrepareUserMessage(ctx, rt, userInput, cfg.AttachmentPath)
if err != nil {
return fmt.Errorf("failed to prepare message: %w", err)
}
if userMsg == nil {
// Agent-only command with no content - agent switched but no message to send
return nil
}
sess.AddMessage(userMsg)
sess.AddAttachedFile(attachedPath)

Expand Down Expand Up @@ -311,6 +318,10 @@ func Run(ctx context.Context, out *Printer, cfg Config, rt runtime.Runtime, sess
return nil
}

// PrepareUserMessage resolves commands, parses /attach directives, and creates
// a user message with optional image attachment. This is the common flow for
// both TUI and CLI modes.
//
// PrepareUserMessage resolves commands, parses /attach directives, and creates
// a user message with optional image attachment. This is the common flow for
// both TUI and CLI modes.
Expand All @@ -323,23 +334,38 @@ func Run(ctx context.Context, out *Printer, cfg Config, rt runtime.Runtime, sess
//
// Returns the prepared session.Message ready to be added to the session, plus
// the absolute path of the file that was actually attached (empty when no
// 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
// attachment was used), and an error if agent switching fails. Callers should
// pass the attachment 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, error) {
// 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 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 nil, "", fmt.Errorf("switch agent %q: %w", cmd.Agent, err)
}
// Agent-only command with no trailing args: switch but send no message.
if resolvedContent == "" {
return nil, "", nil
}
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)

// Use either the per-message attachment or the global one
finalAttachPath := cmp.Or(attachPath, globalAttachPath)

return CreateUserMessageWithAttachment(ctx, messageText, finalAttachPath)
msg, attachedPath := CreateUserMessageWithAttachment(ctx, messageText, finalAttachPath)
return msg, attachedPath, nil
}

// ParseAttachCommand parses user input for /attach commands
// Returns the message text (with /attach commands removed) and the attachment path
func ParseAttachCommand(userInput string) (messageText, attachPath string) {
lines := strings.Split(userInput, "\n")
var messageLines []string
Expand Down
Loading
Loading