diff --git a/cmd/root/run.go b/cmd/root/run.go index 552d330ff..ecc85ba8d 100644 --- a/cmd/root/run.go +++ b/cmd/root/run.go @@ -32,6 +32,7 @@ type runExecFlags struct { remoteAddress string connectRPC bool modelOverrides []string + promptFiles []string dryRun bool runConfig config.RuntimeConfig sessionDB string @@ -82,6 +83,7 @@ func addRunOrExecFlags(cmd *cobra.Command, flags *runExecFlags) { cmd.PersistentFlags().BoolVar(&flags.autoApprove, "yolo", false, "Automatically approve all tool calls without prompting") cmd.PersistentFlags().BoolVar(&flags.hideToolResults, "hide-tool-results", false, "Hide tool call results") cmd.PersistentFlags().StringVar(&flags.attachmentPath, "attach", "", "Attach an image file to the message") + cmd.PersistentFlags().StringArrayVar(&flags.promptFiles, "prompt-file", nil, "Append file contents to the prompt (repeatable)") cmd.PersistentFlags().StringArrayVar(&flags.modelOverrides, "model", nil, "Override agent model: [agent=]provider/model (repeatable)") cmd.PersistentFlags().BoolVar(&flags.dryRun, "dry-run", false, "Initialize the agent without executing anything") cmd.PersistentFlags().StringVar(&flags.remoteAddress, "remote", "", "Use remote runtime with specified address") @@ -257,7 +259,14 @@ func (f *runExecFlags) runOrExec(ctx context.Context, out *cli.Printer, args []s } func (f *runExecFlags) loadAgentFrom(ctx context.Context, agentSource config.Source) (*teamloader.LoadResult, error) { - result, err := teamloader.LoadWithConfig(ctx, agentSource, &f.runConfig, teamloader.WithModelOverrides(f.modelOverrides)) + opts := []teamloader.Opt{ + teamloader.WithModelOverrides(f.modelOverrides), + } + if len(f.promptFiles) > 0 { + opts = append(opts, teamloader.WithPromptFiles(f.promptFiles)) + } + + result, err := teamloader.LoadWithConfig(ctx, agentSource, &f.runConfig, opts...) if err != nil { return nil, err } diff --git a/pkg/teamloader/teamloader.go b/pkg/teamloader/teamloader.go index b9c648c4d..a8a081180 100644 --- a/pkg/teamloader/teamloader.go +++ b/pkg/teamloader/teamloader.go @@ -43,6 +43,7 @@ func isThinkingBudgetDisabled(tb *latest.ThinkingBudget) bool { type loadOptions struct { modelOverrides []string + promptFiles []string toolsetRegistry *ToolsetRegistry } @@ -55,6 +56,15 @@ func WithModelOverrides(overrides []string) Opt { } } +// WithPromptFiles adds additional prompt files to all agents. +// These are merged with any prompt files defined in the agent config. +func WithPromptFiles(files []string) Opt { + return func(opts *loadOptions) error { + opts.promptFiles = files + return nil + } +} + // WithToolsetRegistry allows using a custom toolset registry instead of the default func WithToolsetRegistry(registry *ToolsetRegistry) Opt { return func(opts *loadOptions) error { @@ -143,6 +153,21 @@ func LoadWithConfig(ctx context.Context, agentSource config.Source, runConfig *c skillsEnabled = *agentConfig.Skills } + // Merge CLI prompt files with agent config prompt files, deduplicating + promptFiles := append([]string{}, agentConfig.AddPromptFiles...) + promptFiles = append(promptFiles, loadOpts.promptFiles...) + + // Deduplicate to avoid redundant context (saves tokens) + seen := make(map[string]bool) + unique := promptFiles[:0] + for _, f := range promptFiles { + if !seen[f] { + seen[f] = true + unique = append(unique, f) + } + } + promptFiles = unique + opts := []agent.Opt{ agent.WithName(agentConfig.Name), agent.WithDescription(expander.Expand(ctx, agentConfig.Description)), @@ -150,7 +175,7 @@ func LoadWithConfig(ctx context.Context, agentSource config.Source, runConfig *c agent.WithAddDate(agentConfig.AddDate), agent.WithAddEnvironmentInfo(agentConfig.AddEnvironmentInfo), agent.WithAddDescriptionParameter(agentConfig.AddDescriptionParameter), - agent.WithAddPromptFiles(agentConfig.AddPromptFiles), + agent.WithAddPromptFiles(promptFiles), agent.WithMaxIterations(agentConfig.MaxIterations), agent.WithNumHistoryItems(agentConfig.NumHistoryItems), agent.WithCommands(expander.ExpandCommands(ctx, agentConfig.Commands)), diff --git a/pkg/teamloader/teamloader_test.go b/pkg/teamloader/teamloader_test.go index 8e270a89e..322440dc6 100644 --- a/pkg/teamloader/teamloader_test.go +++ b/pkg/teamloader/teamloader_test.go @@ -287,3 +287,114 @@ func TestIsThinkingBudgetDisabled(t *testing.T) { }) } } + +func TestWithPromptFiles(t *testing.T) { + t.Setenv("OPENAI_API_KEY", "dummy") + + tests := []struct { + name string + cliPromptFiles []string + expected []string + }{ + { + name: "no CLI prompt files", + cliPromptFiles: nil, + expected: []string{}, // basic.yaml has no add_prompt_files + }, + { + name: "single CLI prompt file", + cliPromptFiles: []string{"AGENTS.md"}, + expected: []string{"AGENTS.md"}, + }, + { + name: "multiple CLI prompt files", + cliPromptFiles: []string{"AGENTS.md", "CLAUDE.md"}, + expected: []string{"AGENTS.md", "CLAUDE.md"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + agentSource, err := config.Resolve("testdata/basic.yaml", nil) + require.NoError(t, err) + + var opts []Opt + if len(tt.cliPromptFiles) > 0 { + opts = append(opts, WithPromptFiles(tt.cliPromptFiles)) + } + + team, err := Load(t.Context(), agentSource, &config.RuntimeConfig{}, opts...) + require.NoError(t, err) + + rootAgent, err := team.Agent("root") + require.NoError(t, err) + + assert.Equal(t, tt.expected, rootAgent.AddPromptFiles()) + }) + } +} + +func TestWithPromptFilesMergesWithConfig(t *testing.T) { + t.Setenv("OPENAI_API_KEY", "dummy") + + // Create a temp agent file with add_prompt_files configured + tempDir := t.TempDir() + agentFile := filepath.Join(tempDir, "agent.yaml") + agentYAML := `version: "2" +agents: + root: + model: openai/gpt-4o + instruction: test + add_prompt_files: + - config-file.md +` + require.NoError(t, os.WriteFile(agentFile, []byte(agentYAML), 0o644)) + + agentSource, err := config.Resolve(agentFile, nil) + require.NoError(t, err) + + // Load with CLI prompt files - should merge with config + team, err := Load(t.Context(), agentSource, &config.RuntimeConfig{}, + WithPromptFiles([]string{"cli-file.md"})) + require.NoError(t, err) + + rootAgent, err := team.Agent("root") + require.NoError(t, err) + + // Config files come first, then CLI files + expected := []string{"config-file.md", "cli-file.md"} + assert.Equal(t, expected, rootAgent.AddPromptFiles()) +} + +func TestWithPromptFilesDeduplicates(t *testing.T) { + t.Setenv("OPENAI_API_KEY", "dummy") + + // Create a temp agent file with add_prompt_files configured + tempDir := t.TempDir() + agentFile := filepath.Join(tempDir, "agent.yaml") + agentYAML := `version: "2" +agents: + root: + model: openai/gpt-4o + instruction: test + add_prompt_files: + - AGENTS.md + - CLAUDE.md +` + require.NoError(t, os.WriteFile(agentFile, []byte(agentYAML), 0o644)) + + agentSource, err := config.Resolve(agentFile, nil) + require.NoError(t, err) + + // CLI specifies a file that's already in config - should deduplicate + team, err := Load(t.Context(), agentSource, &config.RuntimeConfig{}, + WithPromptFiles([]string{"AGENTS.md", "extra.md"})) + require.NoError(t, err) + + rootAgent, err := team.Agent("root") + require.NoError(t, err) + + // AGENTS.md should only appear once (from config), extra.md added at end + expected := []string{"AGENTS.md", "CLAUDE.md", "extra.md"} + assert.Equal(t, expected, rootAgent.AddPromptFiles()) +}