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
20 changes: 18 additions & 2 deletions cagent-schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,8 @@
"version": {
"type": "string",
"description": "Configuration version",
"enum": ["2"],
"examples": ["2"]
"enum": ["1", "2", "v1", "v2"],
"examples": ["1", "2", "v1", "v2"]
},
"agents": {
"type": "object",
Expand Down Expand Up @@ -93,6 +93,22 @@
"items": {
"type": "string"
}
},
"commands": {
"description": "Named prompts for quick-start commands used with --command/-c",
"oneOf": [
{
"type": "object",
"additionalProperties": { "type": "string" }
},
{
"type": "array",
"items": {
"type": "object",
"additionalProperties": { "type": "string" }
}
}
]
}
},
"additionalProperties": false
Expand Down
112 changes: 112 additions & 0 deletions cmd/root/run.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import (
"os"
"os/signal"
"path/filepath"
"sort"
"strings"
"time"

Expand All @@ -21,6 +22,7 @@ import (

"github.com/docker/cagent/pkg/app"
"github.com/docker/cagent/pkg/chat"
"github.com/docker/cagent/pkg/config"
"github.com/docker/cagent/pkg/content"
"github.com/docker/cagent/pkg/evaluation"
"github.com/docker/cagent/pkg/remote"
Expand All @@ -40,8 +42,11 @@ var (
useTUI bool
remoteAddress string
dryRun bool
commandName string
)

const commandListSentinel = "__LIST__"

// NewRunCmd creates a new run command
func NewRunCmd() *cobra.Command {
cmd := &cobra.Command{
Expand All @@ -64,6 +69,11 @@ func NewRunCmd() *cobra.Command {
cmd.PersistentFlags().StringVar(&attachmentPath, "attach", "", "Attach an image file to the message")
cmd.PersistentFlags().BoolVar(&useTUI, "tui", true, "Run the agent with a Terminal User Interface (TUI)")
cmd.PersistentFlags().StringVar(&remoteAddress, "remote", "", "Use remote runtime with specified address (only supported with TUI)")
cmd.PersistentFlags().StringVarP(&commandName, "command", "c", "", "Run a named command from the agent's commands section")
if f := cmd.PersistentFlags().Lookup("command"); f != nil {
// Allow `-c` without value to list available commands
f.NoOptDefVal = commandListSentinel
}
addGatewayFlags(cmd)

return cmd
Expand Down Expand Up @@ -200,6 +210,52 @@ func doRunCommand(ctx context.Context, args []string, exec bool) error {
slog.Debug("Skipping local agent file loading for remote runtime", "filename", agentFilename)
}

// Resolve --command/-c into a first message if provided
var commandFirstMessage *string
if trimmed := strings.TrimSpace(commandName); trimmed != "" {
// Handle listing commands when -c is provided without a value
if trimmed == commandListSentinel {
// If the next positional arg looks like a value (not a flag), treat it as the command value.
if len(args) == 2 && !strings.HasPrefix(args[1], "-") {
trimmed = args[1]
// consume the positional so it won't be treated as a message later
args = args[:1]
} else {
cmds, err := getCommandsForAgent(agentFilename, remoteAddress != "", agents, agentName)
if err != nil {
return err
}
if len(cmds) == 0 {
return fmt.Errorf("No commands defined for agent '%s'.", agentName)
}
printAvailableCommands(agentName, cmds)
fmt.Println()
return nil
}
}

if len(args) == 2 {
return fmt.Errorf("cannot use --command (-c) together with a message argument")
}

cmds, err := getCommandsForAgent(agentFilename, remoteAddress != "", agents, agentName)
if err != nil {
return err
}
if len(cmds) == 0 {
return fmt.Errorf("agent '%s' has no commands", agentName)
}
if msg, ok := cmds[trimmed]; ok {
commandFirstMessage = &msg
} else {
var names []string
for k := range cmds {
names = append(names, k)
}
return fmt.Errorf("'%s' is an unknown command.\n\nAvailable: %s", trimmed, strings.Join(names, ", "))
}
}

// Validate remote flag usage
if remoteAddress != "" && (!useTUI || exec) {
return fmt.Errorf("--remote flag can only be used with TUI mode")
Expand Down Expand Up @@ -267,6 +323,10 @@ func doRunCommand(ctx context.Context, args []string, exec bool) error {

// For `cagent run --tui=false`
if !useTUI {
// Inject first message for non-TUI if --command was used
if commandFirstMessage != nil {
args = []string{args[0], *commandFirstMessage}
}
return runWithoutTUI(ctx, agentFilename, rt, sess, args)
}

Expand All @@ -286,6 +346,11 @@ func doRunCommand(ctx context.Context, args []string, exec bool) error {
}
}

// Override firstMessage if --command was provided (cannot be combined with a message arg)
if commandFirstMessage != nil {
firstMessage = commandFirstMessage
}

a := app.New("cagent", agentFilename, rt, agents, sess, firstMessage)
m := tui.New(a)

Expand Down Expand Up @@ -767,3 +832,50 @@ func fileToDataURL(filePath string) (string, error) {

return dataURL, nil
}

// getCommandsForAgent returns the commands map for the selected agent,
// loading from the in-memory team for local runs or from the YAML file for remote runs.
func getCommandsForAgent(agentFilename string, isRemote bool, agents *team.Team, agentName string) (map[string]string, error) {
if !isRemote {
if agents == nil {
return nil, fmt.Errorf("failed to load agent team")
}
ag := agents.Agent(agentName)
if ag == nil {
return nil, fmt.Errorf("agent not found: %s", agentName)
}
return ag.Commands(), nil
}

parentDir := filepath.Dir(agentFilename)
fileName := filepath.Base(agentFilename)
root, err := os.OpenRoot(parentDir)
if err != nil {
return nil, fmt.Errorf("failed to open root: %w", err)
}
defer func() {
if err := root.Close(); err != nil {
slog.Error("Failed to close root", "error", err)
}
}()

cfg, err := config.LoadConfig(fileName, root)
if err != nil {
return nil, fmt.Errorf("failed to load agent config: %w", err)
}

return map[string]string(cfg.Agents[agentName].Commands), nil
}

// printAvailableCommands pretty-prints the agent's commands sorted by name.
func printAvailableCommands(agentName string, cmds map[string]string) {
fmt.Printf("Available commands for agent '%s':\n", agentName)
var names []string
for k := range cmds {
names = append(names, k)
}
sort.Strings(names)
for _, n := range names {
fmt.Printf(" - %s: %s\n", n, cmds[n])
}
}
51 changes: 40 additions & 11 deletions docs/USAGE.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ $ cagent run config.yaml -a agent_name # Run a specific agent
$ cagent run config.yaml --debug # Enable debug logging
$ cagent run config.yaml --yolo # Auto-accept all the tool calls
$ cagent run config.yaml "First message" # Start the conversation with the agent with a first message
$ cagent run config.yaml -c df # Run with a named command from YAML

# One off without TUI
$ cagent exec config.yaml # Run the agent once, with default instructions
Expand Down Expand Up @@ -76,17 +77,18 @@ During CLI sessions, you can use special commands:

### Agent Properties

| Property | Type | Description | Required |
|------------------------|---------|-----------------------------------------------------------------|----------|
| `name` | string | Agent identifier ||
| `model` | string | Model reference ||
| `description` | string | Agent purpose ||
| `instruction` | string | Detailed behavior instructions ||
| `sub_agents` | array | List of sub-agent names ||
| `toolsets` | array | Available tools ||
| `add_date` | boolean | Add current date to context ||
| `add_environment_info` | boolean | Add information about the environment (working dir, OS, git...) ||
| `max_iterations` | int | Specifies how many times the agent can loop when using tools ||
| Property | Type | Description | Required |
|------------------------|--------------|-----------------------------------------------------------------|----------|
| `name` | string | Agent identifier ||
| `model` | string | Model reference ||
| `description` | string | Agent purpose ||
| `instruction` | string | Detailed behavior instructions ||
| `sub_agents` | array | List of sub-agent names ||
| `toolsets` | array | Available tools ||
| `add_date` | boolean | Add current date to context ||
| `add_environment_info` | boolean | Add information about the environment (working dir, OS, git...) ||
| `max_iterations` | int | Specifies how many times the agent can loop when using tools ||
| `commands` | object/array | Named prompts for quick-start commands (used with `--command`) ||

#### Example

Expand All @@ -101,6 +103,33 @@ agents:
add_date: boolean # Add current date to context (optional)
add_environment_info: boolean # Add information about the environment (working dir, OS, git...) (optional)
max_iterations: int # How many times this agent can loop when calling tools (optional, default = unlimited)
commands: # Either mapping or list of singleton maps
df: "check how much free space i have on my disk"
ls: "list the files in the current directory"
```
### Running with named commands
- Use `--command` (or `-c`) to send a predefined prompt from the agent config as the first message.
- Example YAML forms supported:

```yaml
commands:
df: "check how much free space i have on my disk"
ls: "list the files in the current directory"
```

```yaml
commands:
- df: "check how much free space i have on my disk"
- ls: "list the files in the current directory"
```

Run:

```bash
cagent run ./agent.yaml -c df
cagent run ./agent.yaml --command ls
```

### Model Properties
Expand Down
6 changes: 6 additions & 0 deletions pkg/agent/agent.go
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ type Agent struct {
addPromptFiles []string
toolWrapper toolWrapper
memoryManager memorymanager.Manager
commands map[string]string
}

// New creates a new agent
Expand Down Expand Up @@ -145,6 +146,11 @@ func (a *Agent) ToolSets() []tools.ToolSet {
return a.toolsets
}

// Commands returns the named commands configured for this agent.
func (a *Agent) Commands() map[string]string {
return a.commands
}

func (a *Agent) ensureToolSetsAreStarted() error {
a.toolsetsMutex.Lock()
defer a.toolsetsMutex.Unlock()
Expand Down
6 changes: 6 additions & 0 deletions pkg/agent/opts.go
Original file line number Diff line number Diff line change
Expand Up @@ -90,3 +90,9 @@ func WithNumHistoryItems(numHistoryItems int) Opt {
a.numHistoryItems = numHistoryItems
}
}

func WithCommands(commands map[string]string) Opt {
return func(a *Agent) {
a.commands = commands
}
}
52 changes: 52 additions & 0 deletions pkg/config/commands_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
package config

import (
"testing"

"github.com/stretchr/testify/require"
)

func TestV2Commands_AllForms(t *testing.T) {
cfg, err := LoadConfig("commands_v2.yaml", openRoot(t, "testdata"))
require.NoError(t, err)
// map form
cmdsMap := cfg.Agents["root"].Commands
require.Equal(t, "check disk", cmdsMap["df"])
require.Equal(t, "list files", cmdsMap["ls"])
// list form
cmdsList := cfg.Agents["another_agent"].Commands
require.Equal(t, "check disk", cmdsList["df"])
require.Equal(t, "list files", cmdsList["ls"])
// none
require.Empty(t, cfg.Agents["none_agent"].Commands)
}

func TestMigrate_v1_Commands_AllForms(t *testing.T) {
cfg, err := LoadConfig("commands_v1.yaml", openRoot(t, "testdata"))
require.NoError(t, err)
// map form
cmdsMap := cfg.Agents["root"].Commands
require.Equal(t, "check disk", cmdsMap["df"])
require.Equal(t, "list files", cmdsMap["ls"])
// list form
cmdsList := cfg.Agents["another_agent"].Commands
require.Equal(t, "check disk", cmdsList["df"])
require.Equal(t, "list files", cmdsList["ls"])
// none
require.Empty(t, cfg.Agents["yet_another_agent"].Commands)
}

func TestMigrate_v0_Commands_AllForms(t *testing.T) {
cfg, err := LoadConfig("commands_v0.yaml", openRoot(t, "testdata"))
require.NoError(t, err)
// map form
cmdsMap := cfg.Agents["root"].Commands
require.Equal(t, "check disk", cmdsMap["df"])
require.Equal(t, "list files", cmdsMap["ls"])
// list form
cmdsList := cfg.Agents["another_agent"].Commands
require.Equal(t, "check disk", cmdsList["df"])
require.Equal(t, "list files", cmdsList["ls"])
// none
require.Empty(t, cfg.Agents["yet_another_agent"].Commands)
}
19 changes: 19 additions & 0 deletions pkg/config/testdata/commands_v0.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
agents:

root:
model: openai/gpt-4o
instruction: you are a helpful computer assistant
commands:
df: "check disk"
ls: "list files"

another_agent:
model: openai/gpt-4o
instruction: you are a helpful computer assistant
commands:
- df: "check disk"
- ls: "list files"

yet_another_agent:
model: openai/gpt-4o
instruction: you are a helpful computer assistant
21 changes: 21 additions & 0 deletions pkg/config/testdata/commands_v1.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
version: "1"

agents:

root:
model: openai/gpt-4o
instruction: you are a helpful computer assistant
commands:
df: "check disk"
ls: "list files"

another_agent:
model: openai/gpt-4o
instruction: you are a helpful computer assistant
commands:
- df: "check disk"
- ls: "list files"

yet_another_agent:
model: openai/gpt-4o
instruction: you are a helpful computer assistant
Loading
Loading