// Package config holds every setting of the agent in ONE place. // // The values below are the built-in defaults; a YAML file overrides the keys it // mentions and leaves the others alone. That way the agent still runs with no // config file at all, and a single `agent.yaml` is enough to change the model, // the system prompt or the sampling — no recompilation. package config import ( "errors" "fmt" "io/fs" "os" "time" "gopkg.in/yaml.v3" ) // Config: everything that can be tuned without touching the code. type Config struct { // Provider: which LLM server is behind BaseURL — a key of the engine // registry ("dmr", "llamacpp"). Defaults to "dmr" so that every agent.yaml // written before this key existed keeps its meaning. Provider string `yaml:"provider"` // Model: identifier of the chat model, in the provider's own naming // (DMR: "ai/qwen2.5-coder", "hf.co/…"; llama-server: the alias it serves). Model string `yaml:"model"` // BaseURL: the OpenAI-compatible endpoint, tried first. Empty = the // provider's default, so a llama.cpp config does not inherit DMR's port. BaseURL string `yaml:"baseUrl"` // Fallback: used when BaseURL does not answer — on the host DMR sits on // localhost, but from inside a container or a sandbox it is reachable as // host.docker.internal. A pointer, because "absent" and "" differ: absent // means the provider's default, "" means no fallback at all (the fake // engine's probe.yaml relies on that to stay pinned to one URL). Fallback *string `yaml:"fallback"` // APIKeyEnv: the NAME of the environment variable holding the key — never // the key itself, so agent.yaml can be committed. Empty = the provider's // default variable; servers that ignore the key need none. APIKeyEnv string `yaml:"apiKeyEnv"` // ContextWindow: how many tokens the server actually serves, when the // operator knows it (llama-server -c, `docker model configure`). 0 = ask the // server, then unknown. Displayed at start-up; the context-compression work // reads it. ContextWindow int `yaml:"contextWindow"` // MaxOutput: max number of characters a tool returns to the model // (context safeguard: large outputs are truncated). MaxOutput int `yaml:"maxOutput"` // MaxTurns: max number of model ↔ tools round trips for one question. MaxTurns int `yaml:"maxTurns"` // PreviewLines: how many lines of a command's output are echoed to the // terminal. The user asked to SEE something; running the command is not // showing it. 0 disables the echo. PreviewLines int `yaml:"previewLines"` // DisplayCommands: recap, after the answer, the list of commands the agent // actually ran. The count alone says HOW MUCH it worked; the list says // WHAT it did — worth showing on a screen, noise in a log. DisplayCommands bool `yaml:"displayCommands"` // System: the system prompt — what the agent is, and what it may do. System string `yaml:"system"` // SkillsDir: directory of markdown procedures exposed by the `read_skill` // tool. When it holds no *.md file, the tool is not declared at all. SkillsDir string `yaml:"skillsDir"` // EditTools: declare the built-in file tools (read_file, write_file, // edit_file). Off, the agent has bash and read_skill only, exactly like // part 09 — and edits files through the `edit` CLI if it is on the PATH. // One binary, two set-ups: that is what lets the two be compared. EditTools bool `yaml:"editTools"` // Sampling: generation settings (OpenAI API keys; the plugin converts them.) Sampling map[string]any `yaml:"sampling"` // WatchdogTimeout: how long to wait for new tokens before assuming the connection has hung. WatchdogTimeout time.Duration `yaml:"watchdogTimeout"` // Context: when and how the conversation history is compressed // (internal/compact). Off unless `context.enabled: true`. The window it // measures against is the top-level ContextWindow above — or, when that is // 0, what the provider's probe learned from the server. Context ContextConfig `yaml:"context"` } // ContextConfig drives the compression of the history (see // 08-context-compression/CONTEXT_WINDOW.md; the code is the same as 08's). // // Off by default: with `enabled: false` this part behaves exactly as before. // Nothing in the agent shortens the history otherwise — measured with the fake // engine of part 03, it grew 2 → 5 → 7 → 9 messages over four requests, and on // a local model the window is fixed at load time. // // Unlike 08 there is no window here: `contextWindow` is ONE key, at the top // level, shared by the banner and the trigger — two keys for the same number // would drift apart. type ContextConfig struct { // Enabled turns the automatic compression on. `/compact` works regardless. Enabled bool `yaml:"enabled"` // Threshold: share of the context window, in percent, beyond which the // history is compressed before the next question is sent. Threshold int `yaml:"threshold"` // MaxMessages: fallback trigger on the message count, for when the window // is unknown (no contextWindow, no /props) or the estimate is off. 0 // disables it. MaxMessages int `yaml:"maxMessages"` // KeepLastTurns: question turns kept raw at the end of the history. KeepLastTurns int `yaml:"keepLastTurns"` // SummaryMaxTokens: max_tokens of the summary request. SummaryMaxTokens int `yaml:"summaryMaxTokens"` // Prompt replaces the built-in summary prompt when not empty. Prompt string `yaml:"prompt"` // ShowStats: print the one-line 🗜️ report after each compression. ShowStats bool `yaml:"showStats"` } // DefaultPath: the config file looked up when neither the command line nor // AGENT_CONFIG says otherwise. const DefaultPath = "agent.yaml" // Cfg: the live settings, pre-filled with the built-in defaults. // // Defaults suited to a CODING AGENT — low temperature for precise and // reproducible answers. var Cfg = Config{ Provider: "dmr", Model: "huggingface.co/jetbrains/mellum2-12b-a2.5b-instruct-gguf-q4_k_m:Q4_K_M", // BaseURL and Fallback are left empty on purpose: the provider fills them // in (for "dmr": localhost:12434 and host.docker.internal:12434, exactly the // values that used to be here). MaxOutput: 16000, MaxTurns: 10, PreviewLines: 20, // Off by default: the recap is a demo/debug aid, not something every run // needs. `displayCommands: true` in the YAML turns it on. DisplayCommands: false, SkillsDir: "skills", EditTools: true, System: `You are a coding agent working in a terminal. You have a "bash" tool to run shell commands. Use it to explore files, run tests, inspect the repository, etc. Chain several commands if needed, then answer clearly in English. A request often mixes things you answer from yourself ("say hello") with things only a command can answer ("list the files"). Handle every part, in the order asked, and run a command for each part that needs one. Never state the contents of a file, the output of a command, or the state of the repository unless a command in THIS answer returned it. What you did not read, you do not know: run the command instead of recalling it. BACKGROUND JOBS Never let a command block the answer. Anything that serves, watches or runs long goes to the background, with BOTH streams redirected and its pid kept: nohup > /tmp/.log 2>&1 & echo $! > /tmp/.pid Redirecting only stdout still blocks until the process exits. Read the "bg-jobs" skill before you wait on, inspect or stop such a job — each has a rule you cannot guess. Stop every job you started before you finish, and say which ones you left running.`, Sampling: map[string]any{ "temperature": 0.0, "top_p": 0.9, "max_tokens": 4096, }, WatchdogTimeout: 20 * time.Second, Context: ContextConfig{ Enabled: false, // 75 % leaves a quarter of the window for the next question, the tool // outputs of its turns and the answer: one `bash` output alone can be // maxOutput characters, about 4-5k tokens. Threshold: 75, // One command costs 2 messages (call + response): 80 is roughly 30 // commands of history, the point where a 12B local model slows down. MaxMessages: 80, // The recent turns are where the model works; summarising them makes // it re-run what it just did. KeepLastTurns: 3, // Seven sections of one line per item fit in far less; the cap stops // a runaway model from filling the window it was asked to empty. SummaryMaxTokens: 1200, ShowStats: true, }, } // Load reads the YAML file on top of the defaults and returns the path actually // used ("" when no file was found). // // Three places are tried, first one wins: the command line (cliPath, what main // read from -config or from a lone argument), then AGENT_CONFIG, then // ./agent.yaml. The command line wins over the environment because it is the // more local of the two — closer to the run you are making right now. // // A path asked for EXPLICITLY (command line or AGENT_CONFIG) that does not // exist is an error: you named a file, it should be there. A missing // ./agent.yaml is not an error — the built-in defaults are enough to run. func Load(cliPath string) (string, error) { path, explicit := cliPath, true if path == "" { path = os.Getenv("AGENT_CONFIG") } if path == "" { path, explicit = DefaultPath, false } data, err := os.ReadFile(path) if errors.Is(err, fs.ErrNotExist) && !explicit { return "", nil // no file: the defaults stand } if err != nil { return "", err } // Unmarshalling INTO Cfg: keys absent from the file keep their default. if err := yaml.Unmarshal(data, &Cfg); err != nil { return "", fmt.Errorf("%s: %w", path, err) } applyEnv() if err := Cfg.validate(); err != nil { return "", fmt.Errorf("%s: %w", path, err) } return path, nil } // applyEnv lets three variables override the file: AGENT_PROVIDER, AGENT_MODEL, // and — resolved by the provider, see engine.Resolve — AGENT_BASE_URL. Same // reasoning as for AGENT_CONFIG: switching engine for one run should not mean // editing a file that is shown on screen. func applyEnv() { if v := os.Getenv("AGENT_PROVIDER"); v != "" { Cfg.Provider = v } if v := os.Getenv("AGENT_MODEL"); v != "" { Cfg.Model = v } } // validate catches the settings that would break the agent at runtime. func (c Config) validate() error { switch { case c.Provider == "": return errors.New("provider must not be empty") case c.Model == "": return errors.New("model must not be empty") case c.ContextWindow < 0: return errors.New("contextWindow must be >= 0") case c.MaxOutput <= 0: return errors.New("maxOutput must be > 0") case c.MaxTurns <= 0: return errors.New("maxTurns must be > 0") case c.PreviewLines < 0: return errors.New("previewLines must be >= 0") case c.Context.Threshold < 1 || c.Context.Threshold > 100: return errors.New("context.threshold must be between 1 and 100") case c.Context.KeepLastTurns < 1: return errors.New("context.keepLastTurns must be >= 1") case c.Context.SummaryMaxTokens <= 0: return errors.New("context.summaryMaxTokens must be > 0") case c.Context.MaxMessages < 0: return errors.New("context.maxMessages must be >= 0") } // `enabled: true` with no window and no maxMessages would never trigger — // but the window may still come from the server's probe at start-up, so // that case is a warning in main, not an error here. return nil }