Skip to content

Commit 763e8de

Browse files
committed
Add support for commands in the CLI/TUI/API
closes #128 Signed-off-by: Christopher Petito <chrisjpetito@gmail.com>
1 parent 02c43e4 commit 763e8de

16 files changed

Lines changed: 451 additions & 43 deletions

File tree

cagent-schema.json

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,8 @@
88
"version": {
99
"type": "string",
1010
"description": "Configuration version",
11-
"enum": ["2"],
12-
"examples": ["2"]
11+
"enum": ["1", "2", "v1", "v2"],
12+
"examples": ["1", "2", "v1", "v2"]
1313
},
1414
"agents": {
1515
"type": "object",
@@ -93,6 +93,22 @@
9393
"items": {
9494
"type": "string"
9595
}
96+
},
97+
"commands": {
98+
"description": "Named prompts for quick-start commands used with --command/-c",
99+
"oneOf": [
100+
{
101+
"type": "object",
102+
"additionalProperties": { "type": "string" }
103+
},
104+
{
105+
"type": "array",
106+
"items": {
107+
"type": "object",
108+
"additionalProperties": { "type": "string" }
109+
}
110+
}
111+
]
96112
}
97113
},
98114
"additionalProperties": false

cmd/root/run.go

Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import (
1111
"os"
1212
"os/signal"
1313
"path/filepath"
14+
"sort"
1415
"strings"
1516
"time"
1617

@@ -21,6 +22,7 @@ import (
2122

2223
"github.com/docker/cagent/pkg/app"
2324
"github.com/docker/cagent/pkg/chat"
25+
"github.com/docker/cagent/pkg/config"
2426
"github.com/docker/cagent/pkg/content"
2527
"github.com/docker/cagent/pkg/evaluation"
2628
"github.com/docker/cagent/pkg/remote"
@@ -40,8 +42,11 @@ var (
4042
useTUI bool
4143
remoteAddress string
4244
dryRun bool
45+
commandName string
4346
)
4447

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

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

213+
// Resolve --command/-c into a first message if provided
214+
var commandFirstMessage *string
215+
if trimmed := strings.TrimSpace(commandName); trimmed != "" {
216+
// Handle listing commands when -c is provided without a value
217+
if trimmed == commandListSentinel {
218+
// If the next positional arg looks like a value (not a flag), treat it as the command value.
219+
if len(args) == 2 && !strings.HasPrefix(args[1], "-") {
220+
trimmed = args[1]
221+
// consume the positional so it won't be treated as a message later
222+
args = args[:1]
223+
} else {
224+
cmds, err := getCommandsForAgent(agentFilename, remoteAddress != "", agents, agentName)
225+
if err != nil {
226+
return err
227+
}
228+
if len(cmds) == 0 {
229+
return fmt.Errorf("No commands defined for agent '%s'.", agentName)
230+
}
231+
printAvailableCommands(agentName, cmds)
232+
fmt.Println()
233+
return nil
234+
}
235+
}
236+
237+
if len(args) == 2 {
238+
return fmt.Errorf("cannot use --command (-c) together with a message argument")
239+
}
240+
241+
cmds, err := getCommandsForAgent(agentFilename, remoteAddress != "", agents, agentName)
242+
if err != nil {
243+
return err
244+
}
245+
if len(cmds) == 0 {
246+
return fmt.Errorf("agent '%s' has no commands", agentName)
247+
}
248+
if msg, ok := cmds[trimmed]; ok {
249+
commandFirstMessage = &msg
250+
} else {
251+
var names []string
252+
for k := range cmds {
253+
names = append(names, k)
254+
}
255+
return fmt.Errorf("'%s' is an unknown command.\n\nAvailable: %s", trimmed, strings.Join(names, ", "))
256+
}
257+
}
258+
203259
// Validate remote flag usage
204260
if remoteAddress != "" && (!useTUI || exec) {
205261
return fmt.Errorf("--remote flag can only be used with TUI mode")
@@ -267,6 +323,10 @@ func doRunCommand(ctx context.Context, args []string, exec bool) error {
267323

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

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

349+
// Override firstMessage if --command was provided (cannot be combined with a message arg)
350+
if commandFirstMessage != nil {
351+
firstMessage = commandFirstMessage
352+
}
353+
289354
a := app.New("cagent", agentFilename, rt, agents, sess, firstMessage)
290355
m := tui.New(a)
291356

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

768833
return dataURL, nil
769834
}
835+
836+
// getCommandsForAgent returns the commands map for the selected agent,
837+
// loading from the in-memory team for local runs or from the YAML file for remote runs.
838+
func getCommandsForAgent(agentFilename string, isRemote bool, agents *team.Team, agentName string) (map[string]string, error) {
839+
if !isRemote {
840+
if agents == nil {
841+
return nil, fmt.Errorf("failed to load agent team")
842+
}
843+
ag := agents.Agent(agentName)
844+
if ag == nil {
845+
return nil, fmt.Errorf("agent not found: %s", agentName)
846+
}
847+
return ag.Commands(), nil
848+
}
849+
850+
parentDir := filepath.Dir(agentFilename)
851+
fileName := filepath.Base(agentFilename)
852+
root, err := os.OpenRoot(parentDir)
853+
if err != nil {
854+
return nil, fmt.Errorf("failed to open root: %w", err)
855+
}
856+
defer func() {
857+
if err := root.Close(); err != nil {
858+
slog.Error("Failed to close root", "error", err)
859+
}
860+
}()
861+
862+
cfg, err := config.LoadConfig(fileName, root)
863+
if err != nil {
864+
return nil, fmt.Errorf("failed to load agent config: %w", err)
865+
}
866+
867+
return map[string]string(cfg.Agents[agentName].Commands), nil
868+
}
869+
870+
// printAvailableCommands pretty-prints the agent's commands sorted by name.
871+
func printAvailableCommands(agentName string, cmds map[string]string) {
872+
fmt.Printf("Available commands for agent '%s':\n", agentName)
873+
var names []string
874+
for k := range cmds {
875+
names = append(names, k)
876+
}
877+
sort.Strings(names)
878+
for _, n := range names {
879+
fmt.Printf(" - %s: %s\n", n, cmds[n])
880+
}
881+
}

docs/USAGE.md

Lines changed: 40 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ $ cagent run config.yaml -a agent_name # Run a specific agent
4141
$ cagent run config.yaml --debug # Enable debug logging
4242
$ cagent run config.yaml --yolo # Auto-accept all the tool calls
4343
$ cagent run config.yaml "First message" # Start the conversation with the agent with a first message
44+
$ cagent run config.yaml -c df # Run with a named command from YAML
4445

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

7778
### Agent Properties
7879

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

9193
#### Example
9294

@@ -101,6 +103,33 @@ agents:
101103
add_date: boolean # Add current date to context (optional)
102104
add_environment_info: boolean # Add information about the environment (working dir, OS, git...) (optional)
103105
max_iterations: int # How many times this agent can loop when calling tools (optional, default = unlimited)
106+
commands: # Either mapping or list of singleton maps
107+
df: "check how much free space i have on my disk"
108+
ls: "list the files in the current directory"
109+
```
110+
111+
### Running with named commands
112+
113+
- Use `--command` (or `-c`) to send a predefined prompt from the agent config as the first message.
114+
- Example YAML forms supported:
115+
116+
```yaml
117+
commands:
118+
df: "check how much free space i have on my disk"
119+
ls: "list the files in the current directory"
120+
```
121+
122+
```yaml
123+
commands:
124+
- df: "check how much free space i have on my disk"
125+
- ls: "list the files in the current directory"
126+
```
127+
128+
Run:
129+
130+
```bash
131+
cagent run ./agent.yaml -c df
132+
cagent run ./agent.yaml --command ls
104133
```
105134

106135
### Model Properties

pkg/agent/agent.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ type Agent struct {
3030
addPromptFiles []string
3131
toolWrapper toolWrapper
3232
memoryManager memorymanager.Manager
33+
commands map[string]string
3334
}
3435

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

149+
// Commands returns the named commands configured for this agent.
150+
func (a *Agent) Commands() map[string]string {
151+
return a.commands
152+
}
153+
148154
func (a *Agent) ensureToolSetsAreStarted() error {
149155
a.toolsetsMutex.Lock()
150156
defer a.toolsetsMutex.Unlock()

pkg/agent/opts.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,3 +90,9 @@ func WithNumHistoryItems(numHistoryItems int) Opt {
9090
a.numHistoryItems = numHistoryItems
9191
}
9292
}
93+
94+
func WithCommands(commands map[string]string) Opt {
95+
return func(a *Agent) {
96+
a.commands = commands
97+
}
98+
}

pkg/config/commands_test.go

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
package config
2+
3+
import (
4+
"testing"
5+
6+
"github.com/stretchr/testify/require"
7+
)
8+
9+
func TestV2Commands_AllForms(t *testing.T) {
10+
cfg, err := LoadConfig("commands_v2.yaml", openRoot(t, "testdata"))
11+
require.NoError(t, err)
12+
// map form
13+
cmdsMap := cfg.Agents["root"].Commands
14+
require.Equal(t, "check disk", cmdsMap["df"])
15+
require.Equal(t, "list files", cmdsMap["ls"])
16+
// list form
17+
cmdsList := cfg.Agents["another_agent"].Commands
18+
require.Equal(t, "check disk", cmdsList["df"])
19+
require.Equal(t, "list files", cmdsList["ls"])
20+
// none
21+
require.Empty(t, cfg.Agents["none_agent"].Commands)
22+
}
23+
24+
func TestMigrate_v1_Commands_AllForms(t *testing.T) {
25+
cfg, err := LoadConfig("commands_v1.yaml", openRoot(t, "testdata"))
26+
require.NoError(t, err)
27+
// map form
28+
cmdsMap := cfg.Agents["root"].Commands
29+
require.Equal(t, "check disk", cmdsMap["df"])
30+
require.Equal(t, "list files", cmdsMap["ls"])
31+
// list form
32+
cmdsList := cfg.Agents["another_agent"].Commands
33+
require.Equal(t, "check disk", cmdsList["df"])
34+
require.Equal(t, "list files", cmdsList["ls"])
35+
// none
36+
require.Empty(t, cfg.Agents["yet_another_agent"].Commands)
37+
}
38+
39+
func TestMigrate_v0_Commands_AllForms(t *testing.T) {
40+
cfg, err := LoadConfig("commands_v0.yaml", openRoot(t, "testdata"))
41+
require.NoError(t, err)
42+
// map form
43+
cmdsMap := cfg.Agents["root"].Commands
44+
require.Equal(t, "check disk", cmdsMap["df"])
45+
require.Equal(t, "list files", cmdsMap["ls"])
46+
// list form
47+
cmdsList := cfg.Agents["another_agent"].Commands
48+
require.Equal(t, "check disk", cmdsList["df"])
49+
require.Equal(t, "list files", cmdsList["ls"])
50+
// none
51+
require.Empty(t, cfg.Agents["yet_another_agent"].Commands)
52+
}
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
agents:
2+
3+
root:
4+
model: openai/gpt-4o
5+
instruction: you are a helpful computer assistant
6+
commands:
7+
df: "check disk"
8+
ls: "list files"
9+
10+
another_agent:
11+
model: openai/gpt-4o
12+
instruction: you are a helpful computer assistant
13+
commands:
14+
- df: "check disk"
15+
- ls: "list files"
16+
17+
yet_another_agent:
18+
model: openai/gpt-4o
19+
instruction: you are a helpful computer assistant
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
version: "1"
2+
3+
agents:
4+
5+
root:
6+
model: openai/gpt-4o
7+
instruction: you are a helpful computer assistant
8+
commands:
9+
df: "check disk"
10+
ls: "list files"
11+
12+
another_agent:
13+
model: openai/gpt-4o
14+
instruction: you are a helpful computer assistant
15+
commands:
16+
- df: "check disk"
17+
- ls: "list files"
18+
19+
yet_another_agent:
20+
model: openai/gpt-4o
21+
instruction: you are a helpful computer assistant

0 commit comments

Comments
 (0)