bots-garden/mini-mepublic Fork 0
d72271127802973540c648bfb372176cdaaa8e4f
Commits
Clone
git clone https://git.rickub.com/bots-garden/mini-me.git
git clone ssh://git@rickub.com/bots-garden/mini-me.git

Host key fingerprint (ed25519): SHA256:iycHnxEyq0Q7uyVpB7JlznP0G7JrTPXLYRcAU5CSLhc — verify it before your first connect.

💾 Saved. d722711 · on d72271127802973540c648bfb372176cdaaa8e4f · k33g · 6h ago
config.go · 281 lines · 11.2 KBGo Blame HistoryRaw
  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
// 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 <command> > /tmp/<job>.log 2>&1 & echo $! > /tmp/<job>.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
}