| 💾 Saved. d722711 k33g 7h ago | 1 | // Package config holds every setting of the agent in ONE place. |
| 2 | // |
| 3 | // The values below are the built-in defaults; a YAML file overrides the keys it |
| 4 | // mentions and leaves the others alone. That way the agent still runs with no |
| 5 | // config file at all, and a single `agent.yaml` is enough to change the model, |
| 6 | // the system prompt or the sampling — no recompilation. |
| 7 | package config |
| 8 | |
| 9 | import ( |
| 10 | "errors" |
| 11 | "fmt" |
| 12 | "io/fs" |
| 13 | "os" |
| 14 | "time" |
| 15 | |
| 16 | "gopkg.in/yaml.v3" |
| 17 | ) |
| 18 | |
| 19 | // Config: everything that can be tuned without touching the code. |
| 20 | type Config struct { |
| 21 | // Provider: which LLM server is behind BaseURL — a key of the engine |
| 22 | // registry ("dmr", "llamacpp"). Defaults to "dmr" so that every agent.yaml |
| 23 | // written before this key existed keeps its meaning. |
| 24 | Provider string `yaml:"provider"` |
| 25 | |
| 26 | // Model: identifier of the chat model, in the provider's own naming |
| 27 | // (DMR: "ai/qwen2.5-coder", "hf.co/…"; llama-server: the alias it serves). |
| 28 | Model string `yaml:"model"` |
| 29 | |
| 30 | // BaseURL: the OpenAI-compatible endpoint, tried first. Empty = the |
| 31 | // provider's default, so a llama.cpp config does not inherit DMR's port. |
| 32 | BaseURL string `yaml:"baseUrl"` |
| 33 | |
| 34 | // Fallback: used when BaseURL does not answer — on the host DMR sits on |
| 35 | // localhost, but from inside a container or a sandbox it is reachable as |
| 36 | // host.docker.internal. A pointer, because "absent" and "" differ: absent |
| 37 | // means the provider's default, "" means no fallback at all (the fake |
| 38 | // engine's probe.yaml relies on that to stay pinned to one URL). |
| 39 | Fallback *string `yaml:"fallback"` |
| 40 | |
| 41 | // APIKeyEnv: the NAME of the environment variable holding the key — never |
| 42 | // the key itself, so agent.yaml can be committed. Empty = the provider's |
| 43 | // default variable; servers that ignore the key need none. |
| 44 | APIKeyEnv string `yaml:"apiKeyEnv"` |
| 45 | |
| 46 | // ContextWindow: how many tokens the server actually serves, when the |
| 47 | // operator knows it (llama-server -c, `docker model configure`). 0 = ask the |
| 48 | // server, then unknown. Displayed at start-up; the context-compression work |
| 49 | // reads it. |
| 50 | ContextWindow int `yaml:"contextWindow"` |
| 51 | |
| 52 | // MaxOutput: max number of characters a tool returns to the model |
| 53 | // (context safeguard: large outputs are truncated). |
| 54 | MaxOutput int `yaml:"maxOutput"` |
| 55 | |
| 56 | // MaxTurns: max number of model ↔ tools round trips for one question. |
| 57 | MaxTurns int `yaml:"maxTurns"` |
| 58 | |
| 59 | // PreviewLines: how many lines of a command's output are echoed to the |
| 60 | // terminal. The user asked to SEE something; running the command is not |
| 61 | // showing it. 0 disables the echo. |
| 62 | PreviewLines int `yaml:"previewLines"` |
| 63 | |
| 64 | // DisplayCommands: recap, after the answer, the list of commands the agent |
| 65 | // actually ran. The count alone says HOW MUCH it worked; the list says |
| 66 | // WHAT it did — worth showing on a screen, noise in a log. |
| 67 | DisplayCommands bool `yaml:"displayCommands"` |
| 68 | |
| 69 | // System: the system prompt — what the agent is, and what it may do. |
| 70 | System string `yaml:"system"` |
| 71 | |
| 72 | // SkillsDir: directory of markdown procedures exposed by the `read_skill` |
| 73 | // tool. When it holds no *.md file, the tool is not declared at all. |
| 74 | SkillsDir string `yaml:"skillsDir"` |
| 75 | |
| 76 | // EditTools: declare the built-in file tools (read_file, write_file, |
| 77 | // edit_file). Off, the agent has bash and read_skill only, exactly like |
| 78 | // part 09 — and edits files through the `edit` CLI if it is on the PATH. |
| 79 | // One binary, two set-ups: that is what lets the two be compared. |
| 80 | EditTools bool `yaml:"editTools"` |
| 81 | |
| 82 | // Sampling: generation settings (OpenAI API keys; the plugin converts them.) |
| 83 | Sampling map[string]any `yaml:"sampling"` |
| 84 | |
| 85 | // WatchdogTimeout: how long to wait for new tokens before assuming the connection has hung. |
| 86 | WatchdogTimeout time.Duration `yaml:"watchdogTimeout"` |
| 87 | |
| 88 | // Context: when and how the conversation history is compressed |
| 89 | // (internal/compact). Off unless `context.enabled: true`. The window it |
| 90 | // measures against is the top-level ContextWindow above — or, when that is |
| 91 | // 0, what the provider's probe learned from the server. |
| 92 | Context ContextConfig `yaml:"context"` |
| 93 | } |
| 94 | |
| 95 | // ContextConfig drives the compression of the history (see |
| 96 | // 08-context-compression/CONTEXT_WINDOW.md; the code is the same as 08's). |
| 97 | // |
| 98 | // Off by default: with `enabled: false` this part behaves exactly as before. |
| 99 | // Nothing in the agent shortens the history otherwise — measured with the fake |
| 100 | // engine of part 03, it grew 2 → 5 → 7 → 9 messages over four requests, and on |
| 101 | // a local model the window is fixed at load time. |
| 102 | // |
| 103 | // Unlike 08 there is no window here: `contextWindow` is ONE key, at the top |
| 104 | // level, shared by the banner and the trigger — two keys for the same number |
| 105 | // would drift apart. |
| 106 | type ContextConfig struct { |
| 107 | // Enabled turns the automatic compression on. `/compact` works regardless. |
| 108 | Enabled bool `yaml:"enabled"` |
| 109 | |
| 110 | // Threshold: share of the context window, in percent, beyond which the |
| 111 | // history is compressed before the next question is sent. |
| 112 | Threshold int `yaml:"threshold"` |
| 113 | |
| 114 | // MaxMessages: fallback trigger on the message count, for when the window |
| 115 | // is unknown (no contextWindow, no /props) or the estimate is off. 0 |
| 116 | // disables it. |
| 117 | MaxMessages int `yaml:"maxMessages"` |
| 118 | |
| 119 | // KeepLastTurns: question turns kept raw at the end of the history. |
| 120 | KeepLastTurns int `yaml:"keepLastTurns"` |
| 121 | |
| 122 | // SummaryMaxTokens: max_tokens of the summary request. |
| 123 | SummaryMaxTokens int `yaml:"summaryMaxTokens"` |
| 124 | |
| 125 | // Prompt replaces the built-in summary prompt when not empty. |
| 126 | Prompt string `yaml:"prompt"` |
| 127 | |
| 128 | // ShowStats: print the one-line 🗜️ report after each compression. |
| 129 | ShowStats bool `yaml:"showStats"` |
| 130 | } |
| 131 | |
| 132 | // DefaultPath: the config file looked up when neither the command line nor |
| 133 | // AGENT_CONFIG says otherwise. |
| 134 | const DefaultPath = "agent.yaml" |
| 135 | |
| 136 | // Cfg: the live settings, pre-filled with the built-in defaults. |
| 137 | // |
| 138 | // Defaults suited to a CODING AGENT — low temperature for precise and |
| 139 | // reproducible answers. |
| 140 | var Cfg = Config{ |
| 141 | Provider: "dmr", |
| 142 | Model: "huggingface.co/jetbrains/mellum2-12b-a2.5b-instruct-gguf-q4_k_m:Q4_K_M", |
| 143 | // BaseURL and Fallback are left empty on purpose: the provider fills them |
| 144 | // in (for "dmr": localhost:12434 and host.docker.internal:12434, exactly the |
| 145 | // values that used to be here). |
| 146 | MaxOutput: 16000, |
| 147 | MaxTurns: 10, |
| 148 | PreviewLines: 20, |
| 149 | // Off by default: the recap is a demo/debug aid, not something every run |
| 150 | // needs. `displayCommands: true` in the YAML turns it on. |
| 151 | DisplayCommands: false, |
| 152 | SkillsDir: "skills", |
| 153 | EditTools: true, |
| 154 | System: `You are a coding agent working in a terminal. |
| 155 | You have a "bash" tool to run shell commands. |
| 156 | Use it to explore files, run tests, inspect the repository, etc. |
| 157 | Chain several commands if needed, then answer clearly in English. |
| 158 | |
| 159 | A request often mixes things you answer from yourself ("say hello") with things |
| 160 | only a command can answer ("list the files"). Handle every part, in the order |
| 161 | asked, and run a command for each part that needs one. |
| 162 | Never state the contents of a file, the output of a command, or the state of the |
| 163 | repository unless a command in THIS answer returned it. What you did not read, |
| 164 | you do not know: run the command instead of recalling it. |
| 165 | |
| 166 | BACKGROUND JOBS |
| 167 | Never let a command block the answer. Anything that serves, watches or runs |
| 168 | long goes to the background, with BOTH streams redirected and its pid kept: |
| 169 | |
| 170 | nohup <command> > /tmp/<job>.log 2>&1 & echo $! > /tmp/<job>.pid |
| 171 | |
| 172 | Redirecting only stdout still blocks until the process exits. Read the |
| 173 | "bg-jobs" skill before you wait on, inspect or stop such a job — each has a |
| 174 | rule you cannot guess. Stop every job you started before you finish, and say |
| 175 | which ones you left running.`, |
| 176 | Sampling: map[string]any{ |
| 177 | "temperature": 0.0, |
| 178 | "top_p": 0.9, |
| 179 | "max_tokens": 4096, |
| 180 | }, |
| 181 | WatchdogTimeout: 20 * time.Second, |
| 182 | Context: ContextConfig{ |
| 183 | Enabled: false, |
| 184 | // 75 % leaves a quarter of the window for the next question, the tool |
| 185 | // outputs of its turns and the answer: one `bash` output alone can be |
| 186 | // maxOutput characters, about 4-5k tokens. |
| 187 | Threshold: 75, |
| 188 | // One command costs 2 messages (call + response): 80 is roughly 30 |
| 189 | // commands of history, the point where a 12B local model slows down. |
| 190 | MaxMessages: 80, |
| 191 | // The recent turns are where the model works; summarising them makes |
| 192 | // it re-run what it just did. |
| 193 | KeepLastTurns: 3, |
| 194 | // Seven sections of one line per item fit in far less; the cap stops |
| 195 | // a runaway model from filling the window it was asked to empty. |
| 196 | SummaryMaxTokens: 1200, |
| 197 | ShowStats: true, |
| 198 | }, |
| 199 | } |
| 200 | |
| 201 | // Load reads the YAML file on top of the defaults and returns the path actually |
| 202 | // used ("" when no file was found). |
| 203 | // |
| 204 | // Three places are tried, first one wins: the command line (cliPath, what main |
| 205 | // read from -config or from a lone argument), then AGENT_CONFIG, then |
| 206 | // ./agent.yaml. The command line wins over the environment because it is the |
| 207 | // more local of the two — closer to the run you are making right now. |
| 208 | // |
| 209 | // A path asked for EXPLICITLY (command line or AGENT_CONFIG) that does not |
| 210 | // exist is an error: you named a file, it should be there. A missing |
| 211 | // ./agent.yaml is not an error — the built-in defaults are enough to run. |
| 212 | func Load(cliPath string) (string, error) { |
| 213 | path, explicit := cliPath, true |
| 214 | if path == "" { |
| 215 | path = os.Getenv("AGENT_CONFIG") |
| 216 | } |
| 217 | if path == "" { |
| 218 | path, explicit = DefaultPath, false |
| 219 | } |
| 220 | |
| 221 | data, err := os.ReadFile(path) |
| 222 | if errors.Is(err, fs.ErrNotExist) && !explicit { |
| 223 | return "", nil // no file: the defaults stand |
| 224 | } |
| 225 | if err != nil { |
| 226 | return "", err |
| 227 | } |
| 228 | |
| 229 | // Unmarshalling INTO Cfg: keys absent from the file keep their default. |
| 230 | if err := yaml.Unmarshal(data, &Cfg); err != nil { |
| 231 | return "", fmt.Errorf("%s: %w", path, err) |
| 232 | } |
| 233 | applyEnv() |
| 234 | if err := Cfg.validate(); err != nil { |
| 235 | return "", fmt.Errorf("%s: %w", path, err) |
| 236 | } |
| 237 | return path, nil |
| 238 | } |
| 239 | |
| 240 | // applyEnv lets three variables override the file: AGENT_PROVIDER, AGENT_MODEL, |
| 241 | // and — resolved by the provider, see engine.Resolve — AGENT_BASE_URL. Same |
| 242 | // reasoning as for AGENT_CONFIG: switching engine for one run should not mean |
| 243 | // editing a file that is shown on screen. |
| 244 | func applyEnv() { |
| 245 | if v := os.Getenv("AGENT_PROVIDER"); v != "" { |
| 246 | Cfg.Provider = v |
| 247 | } |
| 248 | if v := os.Getenv("AGENT_MODEL"); v != "" { |
| 249 | Cfg.Model = v |
| 250 | } |
| 251 | } |
| 252 | |
| 253 | // validate catches the settings that would break the agent at runtime. |
| 254 | func (c Config) validate() error { |
| 255 | switch { |
| 256 | case c.Provider == "": |
| 257 | return errors.New("provider must not be empty") |
| 258 | case c.Model == "": |
| 259 | return errors.New("model must not be empty") |
| 260 | case c.ContextWindow < 0: |
| 261 | return errors.New("contextWindow must be >= 0") |
| 262 | case c.MaxOutput <= 0: |
| 263 | return errors.New("maxOutput must be > 0") |
| 264 | case c.MaxTurns <= 0: |
| 265 | return errors.New("maxTurns must be > 0") |
| 266 | case c.PreviewLines < 0: |
| 267 | return errors.New("previewLines must be >= 0") |
| 268 | case c.Context.Threshold < 1 || c.Context.Threshold > 100: |
| 269 | return errors.New("context.threshold must be between 1 and 100") |
| 270 | case c.Context.KeepLastTurns < 1: |
| 271 | return errors.New("context.keepLastTurns must be >= 1") |
| 272 | case c.Context.SummaryMaxTokens <= 0: |
| 273 | return errors.New("context.summaryMaxTokens must be > 0") |
| 274 | case c.Context.MaxMessages < 0: |
| 275 | return errors.New("context.maxMessages must be >= 0") |
| 276 | } |
| 277 | // `enabled: true` with no window and no maxMessages would never trigger — |
| 278 | // but the window may still come from the server's probe at start-up, so |
| 279 | // that case is a warning in main, not an error here. |
| 280 | return nil |
| 281 | } |