diff --git a/README.md b/README.md index 4aad64c..8df85a2 100644 --- a/README.md +++ b/README.md @@ -52,6 +52,7 @@ Claude Code (LLM Runtime) Agent Binary +-- --custom-instructions -> system prompt +-- serve-mcp -> MCP server (tools + memory) + +-- config get|set|reset -> runtime config overrides +-- --version -> semver +-- --describe -> JSON manifest +-- validate -> check wiring @@ -115,6 +116,26 @@ agentfile install github.com/you/my-agent | **Distribution** | Copy the file | Folder copy | N/A | `agentfile install` from anywhere | | **Context isolation** | No | No | Yes | No | | **Cost model** | One-time | Text in context | Baseline per call | Marginal per turn | +| **Runtime config** | Edit the file | N/A | N/A | `config set model opus` — override without rebuilding | + +## Runtime Configuration + +Agents ship with compiled defaults, but consumers can override settings without rebuilding: + +```bash +# Override the model hint at install time +agentfile install --model opus github.com/acme/my-agent + +# Or change it later +./my-agent config set model opus +./my-agent config get model # opus (override) +./my-agent config reset model # revert to compiled default + +# Show all config (compiled defaults + overrides) +./my-agent config get +``` + +Overrides are stored at `~/.agentfile//config.yaml`. The model hint is surfaced to the runtime via MCP server instructions. See the [model-override example](examples/model-override/) for a complete setup. ## Install diff --git a/cmd/agentfile/install.go b/cmd/agentfile/install.go index 2d84671..b49a2a8 100644 --- a/cmd/agentfile/install.go +++ b/cmd/agentfile/install.go @@ -8,6 +8,7 @@ import ( "time" "github.com/spf13/cobra" + "github.com/teabranch/agentfile/pkg/config" "github.com/teabranch/agentfile/pkg/fsutil" "github.com/teabranch/agentfile/pkg/github" "github.com/teabranch/agentfile/pkg/registry" @@ -15,6 +16,7 @@ import ( func newInstallCommand() *cobra.Command { var global bool + var modelOverride string cmd := &cobra.Command{ Use: "install ", @@ -29,17 +31,42 @@ Remote install (from GitHub Releases): agentfile install github.com/owner/repo/agent@1.0.0 By default, installs to .agentfile/bin/ (project-local) and updates .mcp.json. -With --global, installs to /usr/local/bin/ and updates ~/.claude/mcp.json.`, +With --global, installs to /usr/local/bin/ and updates ~/.claude/mcp.json. + +Override settings at install time: + agentfile install --model gpt-5 github.com/owner/repo/agent`, Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { + var agentName string if github.IsRemoteRef(args[0]) { - return runRemoteInstall(args[0], global) + parsed, err := github.ParseRef(args[0]) + if err != nil { + return err + } + agentName = parsed.Agent + if err := runRemoteInstall(args[0], global); err != nil { + return err + } + } else { + agentName = args[0] + if err := runLocalInstall(args[0], global); err != nil { + return err + } + } + + // Write config override if --model was specified. + if modelOverride != "" { + if err := config.WriteField(agentName, "model", modelOverride); err != nil { + return fmt.Errorf("writing model override: %w", err) + } + fmt.Printf("Set model override: %s → %s\n", agentName, modelOverride) } - return runLocalInstall(args[0], global) + return nil }, } cmd.Flags().BoolVarP(&global, "global", "g", false, "Install globally to /usr/local/bin") + cmd.Flags().StringVar(&modelOverride, "model", "", "Override the agent's model in ~/.agentfile//config.yaml") return cmd } diff --git a/docs/concepts.md b/docs/concepts.md index fe1b62a..a0694be 100644 --- a/docs/concepts.md +++ b/docs/concepts.md @@ -15,6 +15,7 @@ Agent Binary (Agentfile) +-- --custom-instructions -> system prompt text +-- run-tool -> execute a tool (CLI or builtin) +-- memory read|write|list|delete|append -> persistent state + +-- config get|set|reset|path -> runtime config overrides +-- serve-mcp -> MCP-over-stdio server +-- validate -> check agent wiring @@ -89,6 +90,7 @@ agent.Execute() Wire Cobra CLI, register tools, init memory +-- cli.NewServeMCPCommand() Add serve-mcp subcommand +-- cli.NewValidateCommand() Add validate subcommand +-- cli.NewMemoryCommand() Add memory subcommand group (if enabled) + +-- cli.NewConfigCommand() Add config subcommand (get/set/reset/path) | v cmd.Execute() Run the Cobra command tree @@ -208,6 +210,28 @@ Within a version, the embedded system prompt is immutable. It is baked into the For development, use the override mechanism: place an `override.md` file at `~/.agentfile//override.md` and it replaces the embedded prompt without rebuilding. See the [Prompts Guide](./guides/prompts.md). +## Runtime Config Overrides + +While the binary ships with compiled defaults, consumers can override certain settings without rebuilding via `~/.agentfile//config.yaml`: + +```bash +./my-agent config set model opus # override the model hint +./my-agent config set tool_timeout 120s # override tool timeout +./my-agent config get # show all (compiled + overrides) +./my-agent config reset model # revert to compiled default +./my-agent config path # print config file location +``` + +Overridable fields: `model`, `tool_timeout`, `memory_limits`, `command_policy`. Overrides are loaded at startup — the `--describe` manifest and MCP server instructions reflect the effective (post-override) values. + +Install-time overrides are also supported: + +```bash +agentfile install --model opus github.com/acme/my-agent +``` + +This writes the override to `config.yaml` during install so it takes effect immediately. + ## When to Use What **Use CLAUDE.md when:** diff --git a/docs/faq.md b/docs/faq.md index 06a7863..c36c3d1 100644 --- a/docs/faq.md +++ b/docs/faq.md @@ -61,6 +61,19 @@ Other options: Since agents compile to static Go binaries, they have no runtime dependencies. +## How do I override agent settings without rebuilding? + +Use the `config` subcommand: + +```bash +./my-agent config set model opus # override the model hint +./my-agent config set tool_timeout 120s # override tool timeout +./my-agent config get # see all settings with source +./my-agent config reset model # revert to compiled default +``` + +Overrides are stored at `~/.agentfile//config.yaml`. You can also set overrides at install time: `agentfile install --model opus github.com/owner/repo/agent`. + ## What about secrets and configuration? Do not embed secrets in the binary. Use environment variables: diff --git a/docs/guides/agentfile-format.md b/docs/guides/agentfile-format.md index b6f42e7..899a695 100644 --- a/docs/guides/agentfile-format.md +++ b/docs/guides/agentfile-format.md @@ -56,7 +56,7 @@ The first frontmatter block identifies the agent to Claude Code: | `name` | yes | Agent name (used as binary name if not overridden by Agentfile) | | `description` | no | Short description shown in Claude Code's agent picker | | `memory` | no | Set to any value (e.g. `project`) to enable persistent memory | -| `model` | no | Hint for Claude Code (not used by the binary) | +| `model` | no | Model hint for the runtime (surfaced in `--describe` and MCP instructions). Can be overridden at install time (`--model`) or via `config set model` | ### Block 2 — Tools and Detailed Description diff --git a/docs/guides/distribution.md b/docs/guides/distribution.md index 4cfa8e1..2879493 100644 --- a/docs/guides/distribution.md +++ b/docs/guides/distribution.md @@ -148,6 +148,16 @@ agentfile install github.com/your-org/private-repo/agent The token is also used for GitHub API rate limiting on public repos. +### Install-Time Config Overrides + +Override settings at install time without editing config files: + +```bash +agentfile install --model opus github.com/owner/repo/agent +``` + +This writes the override to `~/.agentfile//config.yaml`. The agent's `--describe` manifest and MCP instructions reflect the overridden value immediately. You can change it later with ` config set model ` or revert with ` config reset model`. + ### Local Install (unchanged) Local installs from `./build/` continue to work as before, and now also track in the registry: diff --git a/docs/guides/mcp.md b/docs/guides/mcp.md index 4a9bd93..5414092 100644 --- a/docs/guides/mcp.md +++ b/docs/guides/mcp.md @@ -19,6 +19,16 @@ Example tool listing for an agent with `tools: Read, Write` and memory enabled: The system prompt is set as the MCP server's `instructions` field during the initialization handshake. MCP clients that support server instructions receive the prompt automatically. +If a model hint is configured (via the agent definition or a config override), a `## Model Preference` section is appended to the instructions, e.g.: + +``` +## Model Preference + +This agent was designed for model: claude-opus-4-6 +``` + +This is informational — the runtime decides which model to use. + ### Resources (when memory is enabled) - `memory:///` -- JSON array of all memory keys diff --git a/docs/quickstart.md b/docs/quickstart.md index 46db0e3..16f99bc 100644 --- a/docs/quickstart.md +++ b/docs/quickstart.md @@ -110,6 +110,11 @@ See the [Agentfile Format Guide](./guides/agentfile-format.md) for full details. # Validate wiring (checks prompt, tools, memory, version) ./build/my-agent validate + +# Runtime config overrides (no rebuild needed) +./build/my-agent config get # show compiled defaults +./build/my-agent config set model opus # override the model hint +./build/my-agent config reset model # revert to default ``` ## Step 6: Connect to Claude Code diff --git a/docs/reference.md b/docs/reference.md index 52644a8..c158d86 100644 --- a/docs/reference.md +++ b/docs/reference.md @@ -45,6 +45,18 @@ Enables or disables persistent memory. Default: `false`. When enabled, creates a Sets capacity limits for the memory store. Only meaningful when memory is enabled. +### `WithModel(model string) Option` + +Sets the agent's model hint. This is informational metadata — the runtime (Claude Code, etc.) picks its own model. The value is surfaced in `--describe` JSON and as a "Model Preference" hint in MCP server instructions. + +### `WithLazyToolLoading(enabled bool) Option` + +Enables lazy tool loading via the `search_tools` meta-tool. When enabled, the MCP server only registers `search_tools` and `get_instructions` initially; clients discover other tools by searching. + +### `WithConfigPath(path string) Option` + +Overrides the default config.yaml location (`~/.agentfile//config.yaml`). Primarily useful for testing. + ### `WithLogger(logger *slog.Logger) Option` Sets the structured logger. Default: `slog.NewTextHandler(os.Stderr, nil)`. Logs go to stderr so they do not interfere with MCP protocol on stdout. @@ -103,6 +115,43 @@ Commands: delete Delete a key from memory ``` +### `config` + +Inspect and modify runtime configuration overrides stored at `~/.agentfile//config.yaml`. + +``` +Usage: + config [command] + +Commands: + get [field] Show configuration (compiled defaults + overrides) + set Set a config override + reset Remove an override, reverting to compiled default + path Print the config file path +``` + +Examples: + +```bash +./my-agent config get # show all fields with source +./my-agent config get model # show just model +./my-agent config set model opus # set override +./my-agent config set tool_timeout 120s # set timeout override +./my-agent config reset model # revert to compiled default +./my-agent config path # ~/.agentfile/my-agent/config.yaml +``` + +Output format for `get`: + +``` +model: opus (override) +tool_timeout: 30s (compiled) +``` + +Supported fields for `set`/`reset`: `model`, `tool_timeout`. Complex fields (`memory_limits`, `command_policy`) can be set by editing the YAML directly. + +When `reset` removes the last field, the config file is deleted. + ### `serve-mcp` Start an MCP-over-stdio server. @@ -152,6 +201,8 @@ Validation PASSED "name": "string", "version": "string", "description": "string", + "model": "string", + "toolTimeout": "30s", "tools": [ { "name": "string", @@ -177,6 +228,8 @@ Validation PASSED ``` Notes: +- `model` is only present if set (compiled default or config override) +- `toolTimeout` is only present if non-default - `tools` includes both user-registered tools and memory tools (if enabled) - `memoryLimits` is only present when memory is enabled and limits are set - `annotations` is only present when set on the tool definition @@ -394,14 +447,16 @@ func NewBridge(cfg BridgeConfig) *Bridge ```go type BridgeConfig struct { - Name string - Version string - Description string - Registry *tools.Registry - Executor *tools.Executor - Loader *prompt.Loader - Memory *memory.Manager // nil if memory disabled - Logger *slog.Logger // nil disables logging + Name string + Version string + Description string + Model string // model hint, appended to instructions + Registry *tools.Registry + Executor *tools.Executor + Loader *prompt.Loader + Memory *memory.Manager // nil if memory disabled + Logger *slog.Logger // nil disables logging + LazyToolLoading bool // only register search_tools initially } ``` @@ -436,7 +491,8 @@ Usage: agentfile install [flags] Flags: - -g, --global Install globally to /usr/local/bin + -g, --global Install globally to /usr/local/bin + --model string Override the agent's model in ~/.agentfile//config.yaml ``` Installs an agent binary and wires it into MCP. Supports two modes: diff --git a/examples/README.md b/examples/README.md index 7d4836b..a87408e 100644 --- a/examples/README.md +++ b/examples/README.md @@ -44,6 +44,28 @@ agentfile build # -> ./build/golang-pro, ./build/code-reviewer + .mcp Both agents are auto-discovered by Claude Code via the generated `.mcp.json`. +### [`model-override/`](model-override/) + +Demonstrates the runtime config override feature. The agent declares a model hint (`model: claude-opus-4-6`) and consumers can override it at install time or later via the `config` subcommand. + +``` +model-override/ + Agentfile # declares one agent with a model hint + agents/smart-reviewer.md # code reviewer recommending opus +``` + +Build and use: + +```bash +cd model-override +agentfile build # -> ./build/smart-reviewer + .mcp.json +./build/smart-reviewer --describe # shows model in manifest +./build/smart-reviewer config get # shows compiled defaults +./build/smart-reviewer config set model sonnet # override at runtime +./build/smart-reviewer config get model # sonnet (override) +./build/smart-reviewer config reset model # revert to compiled default +``` + ## Creating Your Own 1. Create an `Agentfile` at your project root diff --git a/examples/model-override/Agentfile b/examples/model-override/Agentfile new file mode 100644 index 0000000..2130330 --- /dev/null +++ b/examples/model-override/Agentfile @@ -0,0 +1,5 @@ +version: "1" +agents: + smart-reviewer: + path: agents/smart-reviewer.md + version: 0.1.0 diff --git a/examples/model-override/agents/smart-reviewer.md b/examples/model-override/agents/smart-reviewer.md new file mode 100644 index 0000000..e1edae5 --- /dev/null +++ b/examples/model-override/agents/smart-reviewer.md @@ -0,0 +1,18 @@ +--- +name: smart-reviewer +memory: project +model: claude-opus-4-6 +--- + +--- +description: "A code reviewer that recommends a specific model" +tools: Read, Glob, Grep +--- + +You are a thorough code reviewer. When reviewing changes: +1. Read the modified files to understand the changes +2. Search for related patterns in the codebase +3. Check for bugs, security issues, and style violations +4. Provide actionable feedback with file and line references + +Focus on correctness first, then readability, then style. diff --git a/internal/cli/config.go b/internal/cli/config.go new file mode 100644 index 0000000..ea9479e --- /dev/null +++ b/internal/cli/config.go @@ -0,0 +1,169 @@ +package cli + +import ( + "fmt" + "time" + + "github.com/spf13/cobra" + "github.com/teabranch/agentfile/pkg/config" +) + +// CompiledDefaults captures the compiled-in values before config overrides are applied. +// Used by the config subcommand to show what the binary was built with. +type CompiledDefaults struct { + Model string + ToolTimeout time.Duration +} + +// NewConfigCommand creates the `config` subcommand for inspecting and +// modifying runtime config overrides. +func NewConfigCommand(name string, defaults CompiledDefaults) *cobra.Command { + cmd := &cobra.Command{ + Use: "config", + Short: "Inspect and modify runtime configuration overrides", + } + + cmd.AddCommand(newConfigGetCommand(name, defaults)) + cmd.AddCommand(newConfigSetCommand(name)) + cmd.AddCommand(newConfigResetCommand(name)) + cmd.AddCommand(newConfigPathCommand(name)) + + return cmd +} + +func newConfigGetCommand(name string, defaults CompiledDefaults) *cobra.Command { + return &cobra.Command{ + Use: "get [field]", + Short: "Show configuration (compiled defaults + overrides)", + Args: cobra.MaximumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + cfg, err := config.Load(name) + if err != nil { + return fmt.Errorf("loading config: %w", err) + } + + if len(args) == 1 { + return printField(cmd, args[0], cfg, defaults) + } + + // Show all fields with effective values. + printAllFields(cmd, cfg, defaults) + return nil + }, + } +} + +func newConfigSetCommand(name string) *cobra.Command { + return &cobra.Command{ + Use: "set ", + Short: "Set a config override", + Args: cobra.ExactArgs(2), + RunE: func(cmd *cobra.Command, args []string) error { + field, value := args[0], args[1] + if err := config.WriteField(name, field, value); err != nil { + return err + } + fmt.Fprintf(cmd.OutOrStdout(), "%s = %s (override saved)\n", field, value) + return nil + }, + } +} + +func newConfigResetCommand(name string) *cobra.Command { + return &cobra.Command{ + Use: "reset ", + Short: "Remove a config override, reverting to compiled default", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + field := args[0] + if err := config.ResetField(name, field); err != nil { + return err + } + fmt.Fprintf(cmd.OutOrStdout(), "%s reset to compiled default\n", field) + return nil + }, + } +} + +func newConfigPathCommand(name string) *cobra.Command { + return &cobra.Command{ + Use: "path", + Short: "Print the config file path", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + p := config.Path(name) + if p == "" { + return fmt.Errorf("cannot resolve home directory") + } + fmt.Fprintln(cmd.OutOrStdout(), p) + return nil + }, + } +} + +// printField prints a single field's effective value. +func printField(cmd *cobra.Command, field string, cfg *config.Config, defaults CompiledDefaults) error { + switch field { + case "model": + val := defaults.Model + source := "compiled" + if cfg.Model != nil { + val = *cfg.Model + source = "override" + } + if val == "" { + val = "(not set)" + } + fmt.Fprintf(cmd.OutOrStdout(), "%s (%s)\n", val, source) + case "tool_timeout": + val := defaults.ToolTimeout.String() + source := "compiled" + if cfg.ToolTimeout != nil { + val = *cfg.ToolTimeout + source = "override" + } + fmt.Fprintf(cmd.OutOrStdout(), "%s (%s)\n", val, source) + default: + return fmt.Errorf("unknown field: %s (supported: model, tool_timeout)", field) + } + return nil +} + +// printAllFields prints all fields with their effective values and source. +func printAllFields(cmd *cobra.Command, cfg *config.Config, defaults CompiledDefaults) { + w := cmd.OutOrStdout() + + // model + modelVal := defaults.Model + modelSource := "compiled" + if cfg.Model != nil { + modelVal = *cfg.Model + modelSource = "override" + } + if modelVal == "" { + modelVal = "(not set)" + } + fmt.Fprintf(w, "model: %s (%s)\n", modelVal, modelSource) + + // tool_timeout + timeoutVal := defaults.ToolTimeout.String() + timeoutSource := "compiled" + if cfg.ToolTimeout != nil { + timeoutVal = *cfg.ToolTimeout + timeoutSource = "override" + } + if defaults.ToolTimeout == 0 { + timeoutVal = time.Duration(30 * time.Second).String() + } + fmt.Fprintf(w, "tool_timeout: %s (%s)\n", timeoutVal, timeoutSource) + + // memory_limits + if cfg.MemoryLimits != nil { + fmt.Fprintf(w, "memory_limits: (override)\n") + } + + // command_policy + if cfg.CommandPolicy != nil { + fmt.Fprintf(w, "command_policy: (override)\n") + } +} diff --git a/internal/cli/config_test.go b/internal/cli/config_test.go new file mode 100644 index 0000000..29b3b45 --- /dev/null +++ b/internal/cli/config_test.go @@ -0,0 +1,258 @@ +package cli + +import ( + "bytes" + "os" + "path/filepath" + "strings" + "testing" + "time" +) + +func TestConfigGetAll(t *testing.T) { + dir := t.TempDir() + t.Setenv("HOME", dir) + + defaults := CompiledDefaults{ + Model: "claude-sonnet-4-6", + ToolTimeout: 30 * time.Second, + } + cmd := NewConfigCommand("test-agent", defaults) + + buf := new(bytes.Buffer) + cmd.SetOut(buf) + cmd.SetArgs([]string{"get"}) + + if err := cmd.Execute(); err != nil { + t.Fatalf("unexpected error: %v", err) + } + + out := buf.String() + if !strings.Contains(out, "claude-sonnet-4-6 (compiled)") { + t.Errorf("expected model compiled default, got: %s", out) + } + if !strings.Contains(out, "30s (compiled)") { + t.Errorf("expected tool_timeout compiled default, got: %s", out) + } +} + +func TestConfigGetAllWithOverride(t *testing.T) { + dir := t.TempDir() + t.Setenv("HOME", dir) + + // Write a config override. + agentDir := filepath.Join(dir, ".agentfile", "test-agent") + if err := os.MkdirAll(agentDir, 0o700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(agentDir, "config.yaml"), []byte("model: gpt-5\n"), 0o600); err != nil { + t.Fatal(err) + } + + defaults := CompiledDefaults{ + Model: "claude-sonnet-4-6", + ToolTimeout: 30 * time.Second, + } + cmd := NewConfigCommand("test-agent", defaults) + + buf := new(bytes.Buffer) + cmd.SetOut(buf) + cmd.SetArgs([]string{"get"}) + + if err := cmd.Execute(); err != nil { + t.Fatalf("unexpected error: %v", err) + } + + out := buf.String() + if !strings.Contains(out, "gpt-5 (override)") { + t.Errorf("expected model override, got: %s", out) + } + if !strings.Contains(out, "30s (compiled)") { + t.Errorf("expected tool_timeout compiled, got: %s", out) + } +} + +func TestConfigGetField(t *testing.T) { + dir := t.TempDir() + t.Setenv("HOME", dir) + + defaults := CompiledDefaults{ + Model: "opus", + ToolTimeout: 60 * time.Second, + } + cmd := NewConfigCommand("test-agent", defaults) + + buf := new(bytes.Buffer) + cmd.SetOut(buf) + cmd.SetArgs([]string{"get", "model"}) + + if err := cmd.Execute(); err != nil { + t.Fatalf("unexpected error: %v", err) + } + + out := buf.String() + if !strings.Contains(out, "opus (compiled)") { + t.Errorf("expected 'opus (compiled)', got: %s", out) + } +} + +func TestConfigGetFieldNoModel(t *testing.T) { + dir := t.TempDir() + t.Setenv("HOME", dir) + + defaults := CompiledDefaults{ + ToolTimeout: 30 * time.Second, + } + cmd := NewConfigCommand("test-agent", defaults) + + buf := new(bytes.Buffer) + cmd.SetOut(buf) + cmd.SetArgs([]string{"get", "model"}) + + if err := cmd.Execute(); err != nil { + t.Fatalf("unexpected error: %v", err) + } + + out := buf.String() + if !strings.Contains(out, "(not set)") { + t.Errorf("expected '(not set)', got: %s", out) + } +} + +func TestConfigGetUnknownField(t *testing.T) { + dir := t.TempDir() + t.Setenv("HOME", dir) + + cmd := NewConfigCommand("test-agent", CompiledDefaults{}) + + buf := new(bytes.Buffer) + cmd.SetOut(buf) + cmd.SetErr(buf) + cmd.SetArgs([]string{"get", "nonexistent"}) + + err := cmd.Execute() + if err == nil { + t.Fatal("expected error for unknown field") + } +} + +func TestConfigSet(t *testing.T) { + dir := t.TempDir() + t.Setenv("HOME", dir) + + cmd := NewConfigCommand("test-agent", CompiledDefaults{}) + + buf := new(bytes.Buffer) + cmd.SetOut(buf) + cmd.SetArgs([]string{"set", "model", "gpt-5"}) + + if err := cmd.Execute(); err != nil { + t.Fatalf("unexpected error: %v", err) + } + + out := buf.String() + if !strings.Contains(out, "model = gpt-5") { + t.Errorf("expected confirmation, got: %s", out) + } + + // Verify the file was written. + data, err := os.ReadFile(filepath.Join(dir, ".agentfile", "test-agent", "config.yaml")) + if err != nil { + t.Fatalf("reading config: %v", err) + } + if !strings.Contains(string(data), "gpt-5") { + t.Errorf("config file does not contain gpt-5: %s", data) + } +} + +func TestConfigReset(t *testing.T) { + dir := t.TempDir() + t.Setenv("HOME", dir) + + // Write a config with model override. + agentDir := filepath.Join(dir, ".agentfile", "test-agent") + if err := os.MkdirAll(agentDir, 0o700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(agentDir, "config.yaml"), []byte("model: gpt-5\ntool_timeout: 90s\n"), 0o600); err != nil { + t.Fatal(err) + } + + cmd := NewConfigCommand("test-agent", CompiledDefaults{}) + + buf := new(bytes.Buffer) + cmd.SetOut(buf) + cmd.SetArgs([]string{"reset", "model"}) + + if err := cmd.Execute(); err != nil { + t.Fatalf("unexpected error: %v", err) + } + + out := buf.String() + if !strings.Contains(out, "model reset to compiled default") { + t.Errorf("expected reset confirmation, got: %s", out) + } + + // Verify model was removed but tool_timeout remains. + data, err := os.ReadFile(filepath.Join(agentDir, "config.yaml")) + if err != nil { + t.Fatalf("reading config: %v", err) + } + if strings.Contains(string(data), "gpt-5") { + t.Errorf("config still contains model: %s", data) + } + if !strings.Contains(string(data), "90s") { + t.Errorf("config should still contain tool_timeout: %s", data) + } +} + +func TestConfigResetDeletesFileWhenEmpty(t *testing.T) { + dir := t.TempDir() + t.Setenv("HOME", dir) + + // Write a config with only model. + agentDir := filepath.Join(dir, ".agentfile", "test-agent") + if err := os.MkdirAll(agentDir, 0o700); err != nil { + t.Fatal(err) + } + configPath := filepath.Join(agentDir, "config.yaml") + if err := os.WriteFile(configPath, []byte("model: gpt-5\n"), 0o600); err != nil { + t.Fatal(err) + } + + cmd := NewConfigCommand("test-agent", CompiledDefaults{}) + + buf := new(bytes.Buffer) + cmd.SetOut(buf) + cmd.SetArgs([]string{"reset", "model"}) + + if err := cmd.Execute(); err != nil { + t.Fatalf("unexpected error: %v", err) + } + + // File should be deleted since all fields are nil. + if _, err := os.Stat(configPath); err == nil { + t.Error("config file should have been deleted when all fields reset") + } +} + +func TestConfigPath(t *testing.T) { + dir := t.TempDir() + t.Setenv("HOME", dir) + + cmd := NewConfigCommand("test-agent", CompiledDefaults{}) + + buf := new(bytes.Buffer) + cmd.SetOut(buf) + cmd.SetArgs([]string{"path"}) + + if err := cmd.Execute(); err != nil { + t.Fatalf("unexpected error: %v", err) + } + + out := strings.TrimSpace(buf.String()) + expected := filepath.Join(dir, ".agentfile", "test-agent", "config.yaml") + if out != expected { + t.Errorf("path = %q, want %q", out, expected) + } +} diff --git a/internal/cli/root.go b/internal/cli/root.go index 9655de5..c65cdd1 100644 --- a/internal/cli/root.go +++ b/internal/cli/root.go @@ -7,6 +7,7 @@ import ( "encoding/json" "fmt" "log/slog" + "time" "github.com/spf13/cobra" "github.com/teabranch/agentfile/pkg/memory" @@ -16,14 +17,17 @@ import ( // AgentManifest is the JSON description of an agent, used for discovery. type AgentManifest struct { - SchemaVersion string `json:"schemaVersion"` - Name string `json:"name"` - Version string `json:"version"` - Description string `json:"description"` - PromptChecksum string `json:"promptChecksum,omitempty"` - Tools []ToolManifestEntry `json:"tools,omitempty"` - Memory bool `json:"memory"` - MemoryLimits *memory.Limits `json:"memoryLimits,omitempty"` + SchemaVersion string `json:"schemaVersion"` + Name string `json:"name"` + Version string `json:"version"` + Description string `json:"description"` + Model string `json:"model,omitempty"` + PromptChecksum string `json:"promptChecksum,omitempty"` + Tools []ToolManifestEntry `json:"tools,omitempty"` + Memory bool `json:"memory"` + MemoryLimits *memory.Limits `json:"memoryLimits,omitempty"` + ToolTimeout string `json:"toolTimeout,omitempty"` + CommandPolicy *tools.CommandPolicy `json:"commandPolicy,omitempty"` } // ToolManifestEntry describes a single tool in the manifest. @@ -38,14 +42,17 @@ type ToolManifestEntry struct { // Options configures the root command. type Options struct { - Name string - Version string - Description string - Loader *prompt.Loader - Registry *tools.Registry - Memory bool - MemoryLimits *memory.Limits - Logger *slog.Logger + Name string + Version string + Description string + Model string + Loader *prompt.Loader + Registry *tools.Registry + Memory bool + MemoryLimits *memory.Limits + ToolTimeout time.Duration + CommandPolicy *tools.CommandPolicy + Logger *slog.Logger } // NewRootCommand creates the root Cobra command for an agent binary. @@ -91,8 +98,13 @@ func printManifest(cmd *cobra.Command, opts Options) error { Name: opts.Name, Version: opts.Version, Description: opts.Description, + Model: opts.Model, Memory: opts.Memory, MemoryLimits: opts.MemoryLimits, + CommandPolicy: opts.CommandPolicy, + } + if opts.ToolTimeout > 0 { + manifest.ToolTimeout = opts.ToolTimeout.String() } // Compute prompt checksum. diff --git a/internal/cli/serve_mcp.go b/internal/cli/serve_mcp.go index 526b88f..3ba0c97 100644 --- a/internal/cli/serve_mcp.go +++ b/internal/cli/serve_mcp.go @@ -13,7 +13,7 @@ import ( // NewServeMCPCommand creates the `serve-mcp` subcommand that starts an // MCP-over-stdio server exposing all registered tools. -func NewServeMCPCommand(name, version, description string, registry *tools.Registry, +func NewServeMCPCommand(name, version, description, model string, registry *tools.Registry, timeout time.Duration, loader *prompt.Loader, mgr *memory.Manager, logger *slog.Logger, execOpts ...tools.ExecutorOption) *cobra.Command { return &cobra.Command{ Use: "serve-mcp", @@ -25,6 +25,7 @@ func NewServeMCPCommand(name, version, description string, registry *tools.Regis Name: name, Version: version, Description: description, + Model: model, Registry: registry, Executor: executor, Loader: loader, diff --git a/internal/integration/config_test.go b/internal/integration/config_test.go new file mode 100644 index 0000000..b084254 --- /dev/null +++ b/internal/integration/config_test.go @@ -0,0 +1,247 @@ +//go:build integration + +package integration + +import ( + "context" + "encoding/json" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + "time" + + gomcp "github.com/modelcontextprotocol/go-sdk/mcp" +) + +func TestConfigPath(t *testing.T) { + out := runAgentStdout(t, "config", "path") + trimmed := strings.TrimSpace(out) + if !strings.HasSuffix(trimmed, filepath.Join(".agentfile", "test-agent", "config.yaml")) { + t.Errorf("config path = %q, want to end with .agentfile/test-agent/config.yaml", trimmed) + } +} + +func TestConfigGetDefaults(t *testing.T) { + tmpHome := t.TempDir() + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + cmd := exec.CommandContext(ctx, binaryPath, "config", "get") + cmd.Env = append(os.Environ(), "HOME="+tmpHome) + out, err := cmd.Output() + if err != nil { + if ee, ok := err.(*exec.ExitError); ok { + t.Fatalf("config get failed: %v\nstderr: %s", err, ee.Stderr) + } + t.Fatalf("config get failed: %v", err) + } + + output := string(out) + // Should show compiled defaults. + if !strings.Contains(output, "model:") { + t.Errorf("config get output should contain 'model:', got: %s", output) + } + if !strings.Contains(output, "tool_timeout:") { + t.Errorf("config get output should contain 'tool_timeout:', got: %s", output) + } + if !strings.Contains(output, "(compiled)") { + t.Errorf("config get output should show '(compiled)' for defaults, got: %s", output) + } +} + +func TestConfigSetAndGet(t *testing.T) { + tmpHome := t.TempDir() + setEnv := func(cmd *exec.Cmd) { + cmd.Env = append(os.Environ(), "HOME="+tmpHome) + } + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + // Set model override. + cmd := exec.CommandContext(ctx, binaryPath, "config", "set", "model", "gpt-5") + setEnv(cmd) + out, err := cmd.Output() + if err != nil { + if ee, ok := err.(*exec.ExitError); ok { + t.Fatalf("config set failed: %v\nstderr: %s", err, ee.Stderr) + } + t.Fatalf("config set failed: %v", err) + } + if !strings.Contains(string(out), "model = gpt-5") { + t.Errorf("config set output = %q, want to contain 'model = gpt-5'", string(out)) + } + + // Get model — should show override. + cmd = exec.CommandContext(ctx, binaryPath, "config", "get", "model") + setEnv(cmd) + out, err = cmd.Output() + if err != nil { + t.Fatalf("config get model: %v", err) + } + if !strings.Contains(string(out), "gpt-5 (override)") { + t.Errorf("config get model = %q, want to contain 'gpt-5 (override)'", string(out)) + } + + // Verify --describe reflects the override. + cmd = exec.CommandContext(ctx, binaryPath, "--describe") + setEnv(cmd) + out, err = cmd.Output() + if err != nil { + t.Fatalf("--describe: %v", err) + } + var manifest struct { + Model string `json:"model"` + } + if err := json.Unmarshal(out, &manifest); err != nil { + t.Fatalf("parsing --describe: %v\noutput: %s", err, out) + } + if manifest.Model != "gpt-5" { + t.Errorf("--describe model = %q, want %q", manifest.Model, "gpt-5") + } +} + +func TestConfigReset(t *testing.T) { + tmpHome := t.TempDir() + setEnv := func(cmd *exec.Cmd) { + cmd.Env = append(os.Environ(), "HOME="+tmpHome) + } + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + // Set model override. + cmd := exec.CommandContext(ctx, binaryPath, "config", "set", "model", "gpt-5") + setEnv(cmd) + if out, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("config set: %v\n%s", err, out) + } + + // Also set tool_timeout so file isn't deleted. + cmd = exec.CommandContext(ctx, binaryPath, "config", "set", "tool_timeout", "120s") + setEnv(cmd) + if out, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("config set tool_timeout: %v\n%s", err, out) + } + + // Reset model. + cmd = exec.CommandContext(ctx, binaryPath, "config", "reset", "model") + setEnv(cmd) + out, err := cmd.Output() + if err != nil { + if ee, ok := err.(*exec.ExitError); ok { + t.Fatalf("config reset: %v\nstderr: %s", err, ee.Stderr) + } + t.Fatalf("config reset: %v", err) + } + if !strings.Contains(string(out), "model reset to compiled default") { + t.Errorf("config reset output = %q, want to contain reset message", string(out)) + } + + // Get model — should show compiled default, not override. + cmd = exec.CommandContext(ctx, binaryPath, "config", "get", "model") + setEnv(cmd) + out, err = cmd.Output() + if err != nil { + t.Fatalf("config get model: %v", err) + } + if strings.Contains(string(out), "gpt-5") { + t.Errorf("config get model after reset should not contain gpt-5, got: %s", out) + } + if !strings.Contains(string(out), "(compiled)") { + t.Errorf("config get model after reset should show (compiled), got: %s", out) + } + + // Verify config file still has tool_timeout. + configPath := filepath.Join(tmpHome, ".agentfile", "test-agent", "config.yaml") + data, err := os.ReadFile(configPath) + if err != nil { + t.Fatalf("reading config file: %v", err) + } + if !strings.Contains(string(data), "120s") { + t.Errorf("config file should still have tool_timeout, got: %s", data) + } +} + +func TestConfigResetDeletesEmptyFile(t *testing.T) { + tmpHome := t.TempDir() + setEnv := func(cmd *exec.Cmd) { + cmd.Env = append(os.Environ(), "HOME="+tmpHome) + } + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + // Set only model. + cmd := exec.CommandContext(ctx, binaryPath, "config", "set", "model", "gpt-5") + setEnv(cmd) + if out, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("config set: %v\n%s", err, out) + } + + // Reset model — file should be deleted. + cmd = exec.CommandContext(ctx, binaryPath, "config", "reset", "model") + setEnv(cmd) + if out, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("config reset: %v\n%s", err, out) + } + + configPath := filepath.Join(tmpHome, ".agentfile", "test-agent", "config.yaml") + if _, err := os.Stat(configPath); err == nil { + t.Error("config file should be deleted when all fields are reset") + } +} + +func TestServeMCPModelHint(t *testing.T) { + tmpHome := t.TempDir() + + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + + // Set model override before starting MCP server. + cmd := exec.CommandContext(ctx, binaryPath, "config", "set", "model", "claude-opus-4-6") + cmd.Env = append(os.Environ(), "HOME="+tmpHome) + if out, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("config set: %v\n%s", err, out) + } + + // Start serve-mcp with the override in effect. + mcpCmd := exec.CommandContext(ctx, binaryPath, "serve-mcp") + mcpCmd.Env = append(os.Environ(), "HOME="+tmpHome) + + client := gomcp.NewClient(&gomcp.Implementation{ + Name: "config-integration-test", + Version: "v0.1.0", + }, nil) + + session, err := client.Connect(ctx, &gomcp.CommandTransport{Command: mcpCmd}, nil) + if err != nil { + t.Fatalf("connecting to serve-mcp: %v", err) + } + defer session.Close() + + // Call get_instructions — should contain model hint. + result, err := session.CallTool(ctx, &gomcp.CallToolParams{ + Name: "get_instructions", + }) + if err != nil { + t.Fatalf("call get_instructions: %v", err) + } + + var text string + for _, c := range result.Content { + if tc, ok := c.(*gomcp.TextContent); ok { + text = tc.Text + break + } + } + + if !strings.Contains(text, "claude-opus-4-6") { + t.Errorf("get_instructions should contain model hint 'claude-opus-4-6', got: %s", text) + } + if !strings.Contains(text, "Model Preference") { + t.Errorf("get_instructions should contain 'Model Preference' header, got: %s", text) + } +} diff --git a/pkg/agent/agent.go b/pkg/agent/agent.go index b67a4dc..5667d0e 100644 --- a/pkg/agent/agent.go +++ b/pkg/agent/agent.go @@ -14,6 +14,7 @@ import ( "time" "github.com/teabranch/agentfile/internal/cli" + "github.com/teabranch/agentfile/pkg/config" "github.com/teabranch/agentfile/pkg/memory" "github.com/teabranch/agentfile/pkg/prompt" "github.com/teabranch/agentfile/pkg/tools" @@ -24,6 +25,7 @@ type Agent struct { name string version string description string + model string promptFS *embed.FS promptPath string @@ -37,6 +39,10 @@ type Agent struct { commandPolicy *tools.CommandPolicy executionHook tools.ExecutionHook + lazyToolLoading bool + + configPath string // override config.yaml path (for testing) + logger *slog.Logger } @@ -63,9 +69,80 @@ func New(opts ...Option) (*Agent, error) { a.logger = slog.New(slog.NewTextHandler(os.Stderr, nil)) } + // Load runtime config overrides (after compiled defaults are set). + var cfg *config.Config + var cfgErr error + if a.configPath != "" { + cfg, cfgErr = config.LoadFrom(a.configPath) + } else { + cfg, cfgErr = config.Load(a.name) + } + if cfgErr != nil { + a.logger.Warn("failed to load config override", "error", cfgErr) + } else if !cfg.IsZero() { + a.applyConfigOverrides(cfg) + a.logger.Info("applied config overrides", "path", config.Path(a.name)) + } + return a, nil } +// compiledDefaults captures the compiled-in values before config overrides. +func (a *Agent) compiledDefaults() cli.CompiledDefaults { + return cli.CompiledDefaults{ + Model: a.model, + ToolTimeout: a.toolTimeout, + } +} + +// applyConfigOverrides merges non-nil fields from a Config into the agent. +func (a *Agent) applyConfigOverrides(cfg *config.Config) { + if cfg.Model != nil { + a.model = *cfg.Model + } + if cfg.ToolTimeout != nil { + if d, err := time.ParseDuration(*cfg.ToolTimeout); err == nil { + a.toolTimeout = d + } else { + a.logger.Warn("invalid tool_timeout in config, keeping default", "value", *cfg.ToolTimeout, "error", err) + } + } + if cfg.MemoryLimits != nil { + ml := cfg.MemoryLimits + if ml.MaxKeys != nil { + a.memoryLimits.MaxKeys = *ml.MaxKeys + } + if ml.MaxValueBytes != nil { + a.memoryLimits.MaxValueBytes = *ml.MaxValueBytes + } + if ml.MaxTotalBytes != nil { + a.memoryLimits.MaxTotalBytes = *ml.MaxTotalBytes + } + if ml.TTL != nil { + if d, err := time.ParseDuration(*ml.TTL); err == nil { + a.memoryLimits.TTL = d + } else { + a.logger.Warn("invalid memory TTL in config, keeping default", "value", *ml.TTL, "error", err) + } + } + } + if cfg.CommandPolicy != nil { + cp := cfg.CommandPolicy + if a.commandPolicy == nil { + a.commandPolicy = &tools.CommandPolicy{} + } + if cp.AllowedPrefixes != nil { + a.commandPolicy.AllowedPrefixes = *cp.AllowedPrefixes + } + if cp.DeniedSubstrings != nil { + a.commandPolicy.DeniedSubstrings = *cp.DeniedSubstrings + } + if cp.MaxOutputBytes != nil { + a.commandPolicy.MaxOutputBytes = *cp.MaxOutputBytes + } + } +} + // Execute sets up the CLI and runs the agent binary. Returns an exit code. func (a *Agent) Execute() int { loader := prompt.NewLoader(a.name, *a.promptFS, a.promptPath) @@ -102,13 +179,16 @@ func (a *Agent) Execute() int { // Build root command cliOpts := cli.Options{ - Name: a.name, - Version: a.version, - Description: a.description, - Loader: loader, - Registry: registry, - Memory: a.memoryEnabled, - Logger: a.logger, + Name: a.name, + Version: a.version, + Description: a.description, + Model: a.model, + Loader: loader, + Registry: registry, + Memory: a.memoryEnabled, + ToolTimeout: a.toolTimeout, + CommandPolicy: a.commandPolicy, + Logger: a.logger, } if a.memoryEnabled { cliOpts.MemoryLimits = &a.memoryLimits @@ -126,8 +206,9 @@ func (a *Agent) Execute() int { // Add subcommands cmd.AddCommand(cli.NewRunToolCommand(registry, a.toolTimeout, a.logger, execOpts...)) - cmd.AddCommand(cli.NewServeMCPCommand(a.name, a.version, a.description, registry, a.toolTimeout, loader, mgr, a.logger, execOpts...)) + cmd.AddCommand(cli.NewServeMCPCommand(a.name, a.version, a.description, a.model, registry, a.toolTimeout, loader, mgr, a.logger, execOpts...)) cmd.AddCommand(cli.NewValidateCommand(a.name, a.version, loader, registry, a.memoryEnabled)) + cmd.AddCommand(cli.NewConfigCommand(a.name, a.compiledDefaults())) if mgr != nil { cmd.AddCommand(cli.NewMemoryCommand(mgr)) diff --git a/pkg/agent/options.go b/pkg/agent/options.go index 92edfd3..7782eb6 100644 --- a/pkg/agent/options.go +++ b/pkg/agent/options.go @@ -27,6 +27,11 @@ func WithDescription(desc string) Option { return func(a *Agent) { a.description = desc } } +// WithModel sets the agent's model hint (informational metadata). +func WithModel(model string) Option { + return func(a *Agent) { a.model = model } +} + // WithPromptFS sets the embedded filesystem and path for the system prompt. func WithPromptFS(fs embed.FS, path string) Option { return func(a *Agent) { @@ -71,3 +76,16 @@ func WithExecutionHook(h tools.ExecutionHook) Option { func WithLogger(logger *slog.Logger) Option { return func(a *Agent) { a.logger = logger } } + +// WithLazyToolLoading enables lazy tool loading via the search_tools meta-tool. +// When enabled, the MCP server only registers search_tools initially; +// clients discover tools by searching. +func WithLazyToolLoading(enabled bool) Option { + return func(a *Agent) { a.lazyToolLoading = enabled } +} + +// WithConfigPath overrides the default config.yaml location. +// Primarily useful for testing. +func WithConfigPath(path string) Option { + return func(a *Agent) { a.configPath = path } +} diff --git a/pkg/config/config.go b/pkg/config/config.go new file mode 100644 index 0000000..a91ed06 --- /dev/null +++ b/pkg/config/config.go @@ -0,0 +1,33 @@ +// Package config handles runtime settings overrides for agentfile binaries. +// Each agent can have an optional config.yaml at ~/.agentfile//config.yaml +// that overrides compiled-in defaults without rebuilding. +package config + +// Config represents runtime overrides from ~/.agentfile//config.yaml. +// Nil pointer fields mean "not specified" — the compiled default is kept. +type Config struct { + Model *string `yaml:"model,omitempty"` + ToolTimeout *string `yaml:"tool_timeout,omitempty"` // duration string, e.g. "60s" + MemoryLimits *MemoryLimitsOverride `yaml:"memory_limits,omitempty"` + CommandPolicy *CommandPolicyOverride `yaml:"command_policy,omitempty"` +} + +// MemoryLimitsOverride holds optional overrides for memory capacity limits. +type MemoryLimitsOverride struct { + MaxKeys *int `yaml:"max_keys,omitempty"` + MaxValueBytes *int64 `yaml:"max_value_bytes,omitempty"` + MaxTotalBytes *int64 `yaml:"max_total_bytes,omitempty"` + TTL *string `yaml:"ttl,omitempty"` // duration string, e.g. "72h" +} + +// CommandPolicyOverride holds optional overrides for command execution policy. +type CommandPolicyOverride struct { + AllowedPrefixes *[]string `yaml:"allowed_prefixes,omitempty"` + DeniedSubstrings *[]string `yaml:"denied_substrings,omitempty"` + MaxOutputBytes *int64 `yaml:"max_output_bytes,omitempty"` +} + +// IsZero returns true if no fields are set (all nil). +func (c *Config) IsZero() bool { + return c.Model == nil && c.ToolTimeout == nil && c.MemoryLimits == nil && c.CommandPolicy == nil +} diff --git a/pkg/config/config_test.go b/pkg/config/config_test.go new file mode 100644 index 0000000..95f4128 --- /dev/null +++ b/pkg/config/config_test.go @@ -0,0 +1,310 @@ +package config + +import ( + "os" + "path/filepath" + "testing" +) + +func TestLoadMissing(t *testing.T) { + cfg, err := LoadFrom(filepath.Join(t.TempDir(), "nonexistent.yaml")) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !cfg.IsZero() { + t.Errorf("expected zero config for missing file, got %+v", cfg) + } +} + +func TestLoadFull(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "config.yaml") + content := `model: gpt-5 +tool_timeout: 120s +memory_limits: + max_keys: 500 + max_value_bytes: 1048576 + max_total_bytes: 10485760 + ttl: 72h +command_policy: + allowed_prefixes: + - "go " + - "make " + denied_substrings: + - "rm -rf" + max_output_bytes: 5242880 +` + if err := os.WriteFile(path, []byte(content), 0o600); err != nil { + t.Fatal(err) + } + + cfg, err := LoadFrom(path) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if cfg.Model == nil || *cfg.Model != "gpt-5" { + t.Errorf("model: got %v, want gpt-5", cfg.Model) + } + if cfg.ToolTimeout == nil || *cfg.ToolTimeout != "120s" { + t.Errorf("tool_timeout: got %v, want 120s", cfg.ToolTimeout) + } + + if cfg.MemoryLimits == nil { + t.Fatal("memory_limits is nil") + } + if cfg.MemoryLimits.MaxKeys == nil || *cfg.MemoryLimits.MaxKeys != 500 { + t.Errorf("max_keys: got %v, want 500", cfg.MemoryLimits.MaxKeys) + } + if cfg.MemoryLimits.MaxValueBytes == nil || *cfg.MemoryLimits.MaxValueBytes != 1048576 { + t.Errorf("max_value_bytes: got %v, want 1048576", cfg.MemoryLimits.MaxValueBytes) + } + if cfg.MemoryLimits.MaxTotalBytes == nil || *cfg.MemoryLimits.MaxTotalBytes != 10485760 { + t.Errorf("max_total_bytes: got %v, want 10485760", cfg.MemoryLimits.MaxTotalBytes) + } + if cfg.MemoryLimits.TTL == nil || *cfg.MemoryLimits.TTL != "72h" { + t.Errorf("ttl: got %v, want 72h", cfg.MemoryLimits.TTL) + } + + if cfg.CommandPolicy == nil { + t.Fatal("command_policy is nil") + } + if cfg.CommandPolicy.AllowedPrefixes == nil || len(*cfg.CommandPolicy.AllowedPrefixes) != 2 { + t.Errorf("allowed_prefixes: got %v, want 2 entries", cfg.CommandPolicy.AllowedPrefixes) + } + if cfg.CommandPolicy.DeniedSubstrings == nil || len(*cfg.CommandPolicy.DeniedSubstrings) != 1 { + t.Errorf("denied_substrings: got %v, want 1 entry", cfg.CommandPolicy.DeniedSubstrings) + } + if cfg.CommandPolicy.MaxOutputBytes == nil || *cfg.CommandPolicy.MaxOutputBytes != 5242880 { + t.Errorf("max_output_bytes: got %v, want 5242880", cfg.CommandPolicy.MaxOutputBytes) + } +} + +func TestLoadPartial(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "config.yaml") + if err := os.WriteFile(path, []byte("model: claude-sonnet-4-6\n"), 0o600); err != nil { + t.Fatal(err) + } + + cfg, err := LoadFrom(path) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if cfg.Model == nil || *cfg.Model != "claude-sonnet-4-6" { + t.Errorf("model: got %v, want claude-sonnet-4-6", cfg.Model) + } + if cfg.ToolTimeout != nil { + t.Errorf("tool_timeout should be nil, got %v", *cfg.ToolTimeout) + } + if cfg.MemoryLimits != nil { + t.Errorf("memory_limits should be nil, got %+v", cfg.MemoryLimits) + } + if cfg.CommandPolicy != nil { + t.Errorf("command_policy should be nil, got %+v", cfg.CommandPolicy) + } +} + +func TestLoadInvalidYAML(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "config.yaml") + if err := os.WriteFile(path, []byte(":\ninvalid: [yaml: {broken"), 0o600); err != nil { + t.Fatal(err) + } + + _, err := LoadFrom(path) + if err == nil { + t.Fatal("expected error for invalid YAML") + } +} + +func TestWriteAndRoundTrip(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "agent", "config.yaml") + + model := "o3" + timeout := "60s" + maxKeys := 100 + cfg := &Config{ + Model: &model, + ToolTimeout: &timeout, + MemoryLimits: &MemoryLimitsOverride{ + MaxKeys: &maxKeys, + }, + } + + if err := WriteTo(path, cfg); err != nil { + t.Fatalf("write error: %v", err) + } + + got, err := LoadFrom(path) + if err != nil { + t.Fatalf("load error: %v", err) + } + + if got.Model == nil || *got.Model != "o3" { + t.Errorf("model round-trip: got %v, want o3", got.Model) + } + if got.ToolTimeout == nil || *got.ToolTimeout != "60s" { + t.Errorf("tool_timeout round-trip: got %v, want 60s", got.ToolTimeout) + } + if got.MemoryLimits == nil || got.MemoryLimits.MaxKeys == nil || *got.MemoryLimits.MaxKeys != 100 { + t.Errorf("max_keys round-trip: got %v, want 100", got.MemoryLimits) + } + // Unset fields should remain nil. + if got.CommandPolicy != nil { + t.Errorf("command_policy should be nil after round-trip") + } +} + +func TestWriteField(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "config.yaml") + + // Write model field to new file. + if err := WriteFieldTo(path, "model", "gpt-5"); err != nil { + t.Fatalf("write field error: %v", err) + } + + cfg, err := LoadFrom(path) + if err != nil { + t.Fatalf("load error: %v", err) + } + if cfg.Model == nil || *cfg.Model != "gpt-5" { + t.Errorf("model: got %v, want gpt-5", cfg.Model) + } + + // Write another field — model should be preserved. + if err := WriteFieldTo(path, "tool_timeout", "90s"); err != nil { + t.Fatalf("write field error: %v", err) + } + + cfg, err = LoadFrom(path) + if err != nil { + t.Fatalf("load error: %v", err) + } + if cfg.Model == nil || *cfg.Model != "gpt-5" { + t.Errorf("model should be preserved: got %v", cfg.Model) + } + if cfg.ToolTimeout == nil || *cfg.ToolTimeout != "90s" { + t.Errorf("tool_timeout: got %v, want 90s", cfg.ToolTimeout) + } +} + +func TestWriteFieldUnsupported(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "config.yaml") + + err := WriteFieldTo(path, "name", "bad") + if err == nil { + t.Fatal("expected error for unsupported field") + } +} + +func TestLoadViaAgentName(t *testing.T) { + dir := t.TempDir() + t.Setenv("HOME", dir) + + // Create config at expected path. + agentDir := filepath.Join(dir, ".agentfile", "test-agent") + if err := os.MkdirAll(agentDir, 0o700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(agentDir, "config.yaml"), []byte("model: haiku\n"), 0o600); err != nil { + t.Fatal(err) + } + + cfg, err := Load("test-agent") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if cfg.Model == nil || *cfg.Model != "haiku" { + t.Errorf("model: got %v, want haiku", cfg.Model) + } +} + +func TestResetField(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "config.yaml") + + // Write config with two fields. + model := "gpt-5" + timeout := "90s" + cfg := &Config{Model: &model, ToolTimeout: &timeout} + if err := WriteTo(path, cfg); err != nil { + t.Fatalf("write error: %v", err) + } + + // Reset model — tool_timeout should remain. + if err := ResetFieldTo(path, "model"); err != nil { + t.Fatalf("reset error: %v", err) + } + + got, err := LoadFrom(path) + if err != nil { + t.Fatalf("load error: %v", err) + } + if got.Model != nil { + t.Errorf("model should be nil after reset, got %v", *got.Model) + } + if got.ToolTimeout == nil || *got.ToolTimeout != "90s" { + t.Errorf("tool_timeout should be preserved, got %v", got.ToolTimeout) + } +} + +func TestResetFieldDeletesFileWhenEmpty(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "config.yaml") + + // Write config with only model. + model := "gpt-5" + cfg := &Config{Model: &model} + if err := WriteTo(path, cfg); err != nil { + t.Fatalf("write error: %v", err) + } + + // Reset model — file should be deleted. + if err := ResetFieldTo(path, "model"); err != nil { + t.Fatalf("reset error: %v", err) + } + + if _, err := os.Stat(path); err == nil { + t.Error("config file should be deleted when all fields are nil") + } +} + +func TestResetFieldUnsupported(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "config.yaml") + + if err := os.WriteFile(path, []byte("model: test\n"), 0o600); err != nil { + t.Fatal(err) + } + + err := ResetFieldTo(path, "name") + if err == nil { + t.Fatal("expected error for unsupported field") + } +} + +func TestResetFieldMissingFile(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "nonexistent.yaml") + + // Resetting a field on a missing file should be a no-op (file already absent). + err := ResetFieldTo(path, "model") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestIsZero(t *testing.T) { + if !(&Config{}).IsZero() { + t.Error("empty config should be zero") + } + model := "test" + if (&Config{Model: &model}).IsZero() { + t.Error("config with model should not be zero") + } +} diff --git a/pkg/config/loader.go b/pkg/config/loader.go new file mode 100644 index 0000000..2577903 --- /dev/null +++ b/pkg/config/loader.go @@ -0,0 +1,141 @@ +package config + +import ( + "errors" + "fmt" + "os" + "path/filepath" + + "github.com/teabranch/agentfile/pkg/fsutil" + "gopkg.in/yaml.v3" +) + +// Load reads config.yaml for the named agent from ~/.agentfile//config.yaml. +// Returns a zero Config (all nil fields) if the file does not exist. +func Load(agentName string) (*Config, error) { + p := Path(agentName) + if p == "" { + return &Config{}, nil + } + return LoadFrom(p) +} + +// LoadFrom reads config from a specific file path. +// Returns a zero Config if the file does not exist. +func LoadFrom(path string) (*Config, error) { + data, err := os.ReadFile(path) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + return &Config{}, nil + } + return nil, fmt.Errorf("reading config %s: %w", path, err) + } + + var cfg Config + if err := yaml.Unmarshal(data, &cfg); err != nil { + return nil, fmt.Errorf("parsing config %s: %w", path, err) + } + return &cfg, nil +} + +// Path returns ~/.agentfile//config.yaml, or "" if HOME cannot be resolved. +func Path(agentName string) string { + home, err := os.UserHomeDir() + if err != nil { + return "" + } + return filepath.Join(home, ".agentfile", agentName, "config.yaml") +} + +// Write writes a Config to ~/.agentfile//config.yaml atomically. +// Only non-nil fields are written. +func Write(agentName string, cfg *Config) error { + p := Path(agentName) + if p == "" { + return fmt.Errorf("cannot resolve home directory") + } + return WriteTo(p, cfg) +} + +// WriteTo writes a Config to a specific path atomically. +func WriteTo(path string, cfg *Config) error { + dir := filepath.Dir(path) + if err := os.MkdirAll(dir, 0o700); err != nil { + return fmt.Errorf("creating config directory: %w", err) + } + + data, err := yaml.Marshal(cfg) + if err != nil { + return fmt.Errorf("marshaling config: %w", err) + } + return fsutil.WriteAtomic(path, data, 0o600) +} + +// WriteField writes a single field to config.yaml, merging with any existing config. +func WriteField(agentName, field, value string) error { + p := Path(agentName) + if p == "" { + return fmt.Errorf("cannot resolve home directory") + } + return WriteFieldTo(p, field, value) +} + +// WriteFieldTo writes a single field to a specific config path, merging with existing. +func WriteFieldTo(path, field, value string) error { + cfg, err := LoadFrom(path) + if err != nil { + return err + } + + switch field { + case "model": + cfg.Model = &value + case "tool_timeout": + cfg.ToolTimeout = &value + default: + return fmt.Errorf("unsupported config field: %s (use Write for complex fields)", field) + } + + return WriteTo(path, cfg) +} + +// ResetField removes a single field from the agent's config.yaml, reverting to the compiled default. +// If all fields become nil, the config file is deleted. +func ResetField(agentName, field string) error { + p := Path(agentName) + if p == "" { + return fmt.Errorf("cannot resolve home directory") + } + return ResetFieldTo(p, field) +} + +// ResetFieldTo removes a single field from a specific config path. +// If all fields become nil after reset, the config file is deleted. +func ResetFieldTo(path, field string) error { + cfg, err := LoadFrom(path) + if err != nil { + return err + } + + switch field { + case "model": + cfg.Model = nil + case "tool_timeout": + cfg.ToolTimeout = nil + case "memory_limits": + cfg.MemoryLimits = nil + case "command_policy": + cfg.CommandPolicy = nil + default: + return fmt.Errorf("unsupported config field: %s", field) + } + + if cfg.IsZero() { + if err := os.Remove(path); err != nil && !errors.Is(err, os.ErrNotExist) { + return fmt.Errorf("removing empty config file: %w", err) + } + return nil + } + + return WriteTo(path, cfg) +} diff --git a/pkg/mcp/bridge.go b/pkg/mcp/bridge.go index 31ff80b..fabed0d 100644 --- a/pkg/mcp/bridge.go +++ b/pkg/mcp/bridge.go @@ -23,6 +23,7 @@ type BridgeConfig struct { Name string Version string Description string + Model string // model hint/recommendation for the runtime Registry *tools.Registry Executor *tools.Executor Loader *prompt.Loader @@ -57,6 +58,7 @@ func (b *Bridge) Serve(ctx context.Context) error { func (b *Bridge) ServeTransport(ctx context.Context, transport gomcp.Transport) error { // Load the system prompt for server instructions. instructions, _ := b.cfg.Loader.Load() + instructions = b.appendModelHint(instructions) server := gomcp.NewServer(&gomcp.Implementation{ Name: b.cfg.Name, @@ -164,6 +166,7 @@ func (b *Bridge) addGetInstructionsTool(server *gomcp.Server) { if err != nil { return errorResult(fmt.Sprintf("failed to load instructions: %v", err)), nil } + text = b.appendModelHint(text) return &gomcp.CallToolResult{ Content: []gomcp.Content{&gomcp.TextContent{Text: text}}, }, nil @@ -355,6 +358,14 @@ func (b *Bridge) addPrompts(server *gomcp.Server) { } } +// appendModelHint appends a model preference section to instructions if a model is configured. +func (b *Bridge) appendModelHint(instructions string) string { + if b.cfg.Model == "" { + return instructions + } + return instructions + "\n\n## Model Preference\n\nThis agent was designed for model: " + b.cfg.Model +} + // errorResult creates a CallToolResult with IsError set. func errorResult(msg string) *gomcp.CallToolResult { return &gomcp.CallToolResult{ diff --git a/pkg/mcp/bridge_test.go b/pkg/mcp/bridge_test.go index 0d5002c..6a140e3 100644 --- a/pkg/mcp/bridge_test.go +++ b/pkg/mcp/bridge_test.go @@ -554,6 +554,57 @@ func TestBridgeLazyToolLoadingSearchTools(t *testing.T) { } } +func TestBridgeModelHintInInstructions(t *testing.T) { + registry := tools.NewRegistry() + session, _ := startBridgeWithConfig(t, agentmcp.BridgeConfig{ + Name: "test-agent", + Version: "v0.1.0", + Model: "claude-opus-4-6", + Registry: registry, + Executor: tools.NewExecutor(30*time.Second, nil), + Loader: newTestLoader(t), + }) + ctx := context.Background() + + // get_instructions should include model hint. + result, err := session.CallTool(ctx, &gomcp.CallToolParams{ + Name: "get_instructions", + }) + if err != nil { + t.Fatalf("call get_instructions: %v", err) + } + text := extractText(result) + if !strings.Contains(text, "claude-opus-4-6") { + t.Errorf("instructions should contain model hint, got: %s", text) + } + if !strings.Contains(text, "Model Preference") { + t.Errorf("instructions should contain 'Model Preference' header, got: %s", text) + } +} + +func TestBridgeNoModelHintWhenEmpty(t *testing.T) { + registry := tools.NewRegistry() + session, _ := startBridgeWithConfig(t, agentmcp.BridgeConfig{ + Name: "test-agent", + Version: "v0.1.0", + Registry: registry, + Executor: tools.NewExecutor(30*time.Second, nil), + Loader: newTestLoader(t), + }) + ctx := context.Background() + + result, err := session.CallTool(ctx, &gomcp.CallToolParams{ + Name: "get_instructions", + }) + if err != nil { + t.Fatalf("call get_instructions: %v", err) + } + text := extractText(result) + if strings.Contains(text, "Model Preference") { + t.Errorf("instructions should NOT contain model hint when model is empty, got: %s", text) + } +} + func extractText(result *gomcp.CallToolResult) string { for _, c := range result.Content { if tc, ok := c.(*gomcp.TextContent); ok {