From cf95ba1a205f59cc599b80a1ff358d90fb0a401f Mon Sep 17 00:00:00 2001 From: Danny Teller Date: Mon, 9 Mar 2026 17:52:29 +0200 Subject: [PATCH 1/4] Bump version to 0.4.0 and add GitHub Packages container publishing - Bump cliVersion to 0.4.0 to trigger auto-release - Add Dockerfile (multi-stage, alpine-based) - Add publish-package job to CI that builds and pushes multi-arch container image to ghcr.io after release --- .github/workflows/ci.yml | 33 +++++++++++++++++++++++++++++++++ Dockerfile | 12 ++++++++++++ 2 files changed, 45 insertions(+) create mode 100644 Dockerfile diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7576592..55dfd0e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -12,6 +12,7 @@ concurrency: permissions: contents: write + packages: write jobs: test: @@ -159,3 +160,35 @@ jobs: --title "agentfile ${VERSION}" \ --generate-notes \ dist/* + + publish-package: + if: github.event_name == 'push' && github.ref == 'refs/heads/main' && needs.check-version.outputs.should_release == 'true' + needs: [release, check-version] + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + steps: + - uses: actions/checkout@v4 + + - name: Log in to GitHub Container Registry + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Build and push container image + uses: docker/build-push-action@v6 + with: + context: . + push: true + platforms: linux/amd64,linux/arm64 + tags: | + ghcr.io/${{ github.repository }}:v${{ needs.check-version.outputs.version }} + ghcr.io/${{ github.repository }}:latest + cache-from: type=gha + cache-to: type=gha,mode=max diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..0f8e7ee --- /dev/null +++ b/Dockerfile @@ -0,0 +1,12 @@ +FROM golang:1.23-alpine AS builder + +WORKDIR /src +COPY go.mod go.sum ./ +RUN go mod download +COPY . . +RUN CGO_ENABLED=0 go build -ldflags "-s -w" -o /agentfile ./cmd/agentfile + +FROM alpine:3.20 +RUN apk add --no-cache ca-certificates git +COPY --from=builder /agentfile /usr/local/bin/agentfile +ENTRYPOINT ["agentfile"] From f1bff3370961f0c2bc6d947f2f60fee05e6e874b Mon Sep 17 00:00:00 2001 From: Danny Teller Date: Wed, 11 Mar 2026 09:41:48 +0200 Subject: [PATCH 2/4] Add config subcommand, model override via MCP, and runtime config reset Introduces ` config get|set|reset|path` for inspecting and modifying runtime overrides without editing YAML. Surfaces model hint in MCP instructions so runtimes can see the configured model preference. Adds ResetField to remove individual config overrides (deletes file when all fields nil). Also brings in prerequisite changes from v0.4.0: config package, install --model flag, applyConfigOverrides, WithModel/WithLazyToolLoading/WithConfigPath options. Co-Authored-By: Claude --- cmd/agentfile/install.go | 33 ++- internal/cli/config.go | 169 +++++++++++++++ internal/cli/config_test.go | 258 +++++++++++++++++++++++ internal/cli/root.go | 44 ++-- internal/cli/serve_mcp.go | 3 +- internal/integration/config_test.go | 247 ++++++++++++++++++++++ pkg/agent/agent.go | 97 ++++++++- pkg/agent/options.go | 18 ++ pkg/config/config.go | 33 +++ pkg/config/config_test.go | 310 ++++++++++++++++++++++++++++ pkg/config/loader.go | 141 +++++++++++++ pkg/mcp/bridge.go | 11 + pkg/mcp/bridge_test.go | 51 +++++ 13 files changed, 1387 insertions(+), 28 deletions(-) create mode 100644 internal/cli/config.go create mode 100644 internal/cli/config_test.go create mode 100644 internal/integration/config_test.go create mode 100644 pkg/config/config.go create mode 100644 pkg/config/config_test.go create mode 100644 pkg/config/loader.go 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/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 { From 8969776165088445474e0c24e062ecde9e770994 Mon Sep 17 00:00:00 2001 From: Danny Teller Date: Wed, 11 Mar 2026 09:41:52 +0200 Subject: [PATCH 3/4] Remove Dockerfile and container publishing from CI Docker is not used in this project. Removes the Dockerfile, the publish-package CI job, and the packages:write permission. Co-Authored-By: Claude --- .github/workflows/ci.yml | 33 --------------------------------- Dockerfile | 12 ------------ 2 files changed, 45 deletions(-) delete mode 100644 Dockerfile diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 55dfd0e..7576592 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -12,7 +12,6 @@ concurrency: permissions: contents: write - packages: write jobs: test: @@ -160,35 +159,3 @@ jobs: --title "agentfile ${VERSION}" \ --generate-notes \ dist/* - - publish-package: - if: github.event_name == 'push' && github.ref == 'refs/heads/main' && needs.check-version.outputs.should_release == 'true' - needs: [release, check-version] - runs-on: ubuntu-latest - permissions: - contents: read - packages: write - steps: - - uses: actions/checkout@v4 - - - name: Log in to GitHub Container Registry - uses: docker/login-action@v3 - with: - registry: ghcr.io - username: ${{ github.actor }} - password: ${{ secrets.GITHUB_TOKEN }} - - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3 - - - name: Build and push container image - uses: docker/build-push-action@v6 - with: - context: . - push: true - platforms: linux/amd64,linux/arm64 - tags: | - ghcr.io/${{ github.repository }}:v${{ needs.check-version.outputs.version }} - ghcr.io/${{ github.repository }}:latest - cache-from: type=gha - cache-to: type=gha,mode=max diff --git a/Dockerfile b/Dockerfile deleted file mode 100644 index 0f8e7ee..0000000 --- a/Dockerfile +++ /dev/null @@ -1,12 +0,0 @@ -FROM golang:1.23-alpine AS builder - -WORKDIR /src -COPY go.mod go.sum ./ -RUN go mod download -COPY . . -RUN CGO_ENABLED=0 go build -ldflags "-s -w" -o /agentfile ./cmd/agentfile - -FROM alpine:3.20 -RUN apk add --no-cache ca-certificates git -COPY --from=builder /agentfile /usr/local/bin/agentfile -ENTRYPOINT ["agentfile"] From 6ebfe4dba328c29f166d28dbba068893d40aada8 Mon Sep 17 00:00:00 2001 From: Danny Teller Date: Wed, 11 Mar 2026 11:32:50 +0200 Subject: [PATCH 4/4] Add model-override example and update docs for config feature - New examples/model-override/ with agent declaring model: claude-opus-4-6 - README: add config to architecture diagram, new Runtime Configuration section - docs/reference.md: add WithModel, WithLazyToolLoading, WithConfigPath options, config subcommand reference, update --describe schema and BridgeConfig, add --model flag to install - docs/concepts.md: add config to lifecycle, new Runtime Config Overrides section - docs/quickstart.md: add config commands to Explore step - docs/faq.md: new "How do I override settings without rebuilding?" entry - docs/guides/distribution.md: install-time config overrides section - docs/guides/mcp.md: document model hint in server instructions - docs/guides/agentfile-format.md: clarify model field is overridable Co-Authored-By: Claude --- README.md | 21 ++++++ docs/concepts.md | 24 ++++++ docs/faq.md | 13 ++++ docs/guides/agentfile-format.md | 2 +- docs/guides/distribution.md | 10 +++ docs/guides/mcp.md | 10 +++ docs/quickstart.md | 5 ++ docs/reference.md | 74 ++++++++++++++++--- examples/README.md | 22 ++++++ examples/model-override/Agentfile | 5 ++ .../model-override/agents/smart-reviewer.md | 18 +++++ 11 files changed, 194 insertions(+), 10 deletions(-) create mode 100644 examples/model-override/Agentfile create mode 100644 examples/model-override/agents/smart-reviewer.md 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/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.