mecha_core/config.rs
1//! Layered configuration.
2//!
3//! Later layers win, field by field:
4//! 1. built-in defaults
5//! 2. `~/.mecha/config.toml`
6//! 3. `./mecha.toml` in the working directory (project-local)
7//! 4. environment variables
8//! 5. CLI flags (applied by the caller, not here)
9
10use crate::message::Effort;
11use anyhow::{Context, Result};
12use serde::{Deserialize, Serialize};
13use std::collections::BTreeMap;
14use std::path::{Path, PathBuf};
15
16#[derive(Debug, Clone, Serialize, Deserialize)]
17#[serde(default, deny_unknown_fields)]
18pub struct Config {
19 /// Which entry in `providers` to use when `--provider` isn't given.
20 pub default_provider: String,
21 pub providers: BTreeMap<String, ProviderConfig>,
22 pub agent: AgentConfig,
23 pub tools: ToolsConfig,
24 pub security: SecurityConfig,
25 /// How `shell` is confined. See [`crate::sandbox`].
26 pub sandbox: crate::sandbox::SandboxConfig,
27 /// MCP servers to connect to at startup.
28 #[serde(rename = "mcp")]
29 pub mcp: Vec<McpServerConfig>,
30 /// Subagents the parent may delegate to, each exposed as one tool.
31 #[serde(rename = "subagent")]
32 pub subagents: Vec<crate::subagent::SubagentProfile>,
33 /// Search backends, in preference order. The chain falls through on
34 /// failure, which is what makes stacking two free tiers viable.
35 #[serde(rename = "search")]
36 pub search: Vec<SearchBackendConfig>,
37 /// User commands run at loop lifecycle points. See [`crate::hooks`].
38 #[serde(rename = "hook")]
39 pub hooks: Vec<HookConfig>,
40 /// Outbound tools staged for user review instead of executed. See
41 /// [`crate::outbox`].
42 pub outbox: OutboxConfig,
43 /// Retention for `~/.mecha/work/`. See [`crate::work`].
44 pub work: WorkConfig,
45}
46
47/// Which tools are outbox-routed, and where staged items live.
48#[derive(Debug, Clone, Default, Serialize, Deserialize)]
49#[serde(default, deny_unknown_fields)]
50pub struct OutboxConfig {
51 /// Registry names (`email__send`, `web__fetch`). A call to one of these
52 /// is staged as a draft the user reviews with `mecha outbox`; the tool
53 /// itself never runs until they release it. Empty means the outbox is
54 /// off, which is the default — routing a tool is a policy decision.
55 pub tools: Vec<String>,
56 /// Where items are staged. Defaults to `~/.mecha/outbox`
57 /// (or `$MECHA_OUTBOX_DIR`).
58 pub dir: Option<PathBuf>,
59 /// Which of the routed names are *publications* rather than messages
60 /// (`factory__bundle_publish`, `factory__bundle_alias`). They stage
61 /// identically; they are **reviewed** differently — the reviewable object
62 /// is the rendered page, `edit` is refused, and the writing-reflection
63 /// miner skips them so a changed directory path never becomes a voice
64 /// rule. See [`crate::outbox::OutboxKind`].
65 ///
66 /// Config's to declare, not the tool's: the loop must not learn what a
67 /// publish is, and a third-party MCP server cannot be trusted to say.
68 pub publish_tools: Vec<String>,
69}
70
71/// How much of a producer's generated output survives a `mecha work clean`.
72///
73/// A policy rather than an intention: the lesson of this project is that
74/// anything without one becomes a pile nobody opens. The number is small on
75/// purpose — the directory is scratch, and what matters is published.
76#[derive(Debug, Clone, Serialize, Deserialize)]
77#[serde(default, deny_unknown_fields)]
78pub struct WorkConfig {
79 /// Entries kept per producer, newest first.
80 pub keep: usize,
81}
82
83impl Default for WorkConfig {
84 fn default() -> Self {
85 WorkConfig {
86 keep: crate::work::DEFAULT_KEEP,
87 }
88 }
89}
90
91/// One hook: a command run at a lifecycle point, with the event payload as
92/// JSON on stdin.
93#[derive(Debug, Clone, Default, Serialize, Deserialize)]
94#[serde(default, deny_unknown_fields)]
95pub struct HookConfig {
96 /// `pre_tool` | `post_tool` | `session_end`. An unknown event is a startup
97 /// error, not a warning — a policy hook that never fires because its event
98 /// name has a typo is the silently-degrading-sandbox mistake again.
99 pub event: String,
100 /// Run via `sh -c`, as the user, in the workspace.
101 pub command: String,
102 /// Only fire for these tools (`pre_tool`/`post_tool`). Empty means all.
103 pub tools: Vec<String>,
104 /// Kill the hook after this long. The default is deliberately short: a
105 /// `pre_tool` hook is on the critical path of every call it matches.
106 pub timeout_secs: Option<u64>,
107}
108
109impl Default for Config {
110 fn default() -> Self {
111 let mut providers = BTreeMap::new();
112 providers.insert(
113 "anthropic".to_string(),
114 ProviderConfig {
115 kind: "anthropic".to_string(),
116 model: Some(crate::provider::anthropic::DEFAULT_MODEL.to_string()),
117 api_key_env: Some("ANTHROPIC_API_KEY".to_string()),
118 api_key: None,
119 base_url: None,
120 input_price_per_mtok: None,
121 output_price_per_mtok: None,
122 temperature: None,
123 seed: None,
124 context_window: None,
125 max_retries: None,
126 retry_after_cap_secs: None,
127 fallbacks: Vec::new(),
128 },
129 );
130 Config {
131 default_provider: "anthropic".to_string(),
132 providers,
133 agent: AgentConfig::default(),
134 tools: ToolsConfig::default(),
135 security: SecurityConfig::default(),
136 sandbox: crate::sandbox::SandboxConfig::default(),
137 mcp: Vec::new(),
138 subagents: Vec::new(),
139 search: Vec::new(),
140 hooks: Vec::new(),
141 outbox: OutboxConfig::default(),
142 work: WorkConfig::default(),
143 }
144 }
145}
146
147#[derive(Debug, Clone, Default, Serialize, Deserialize)]
148#[serde(default, deny_unknown_fields)]
149pub struct ProviderConfig {
150 /// `anthropic` | `openai` | `local`
151 pub kind: String,
152 pub model: Option<String>,
153 /// Environment variable holding the key. Preferred over `api_key`.
154 pub api_key_env: Option<String>,
155 /// Inline key. Convenient, but it lands in a file on disk — prefer the env var.
156 pub api_key: Option<String>,
157 pub base_url: Option<String>,
158 /// Per-million-token prices, so budgets and reporting can be in dollars.
159 /// Leave unset for a local model — the marginal cost really is zero.
160 pub input_price_per_mtok: Option<f64>,
161 pub output_price_per_mtok: Option<f64>,
162 /// Sampling temperature, sent verbatim by providers that accept one. Unset
163 /// means the server's default. Do not reach for 0.0 to get repeatability:
164 /// measured on qwen3.6, greedy decoding walks into verbatim repetition
165 /// loops that sampling noise would have broken. Pin the server's own
166 /// default value and set `seed` instead — same distribution, repeatable
167 /// draws. The Anthropic API rejects the parameter, so setting this on an
168 /// `anthropic` provider is a startup error rather than a silent no-op.
169 pub temperature: Option<f64>,
170 /// Sampling seed, for repeatable draws at a nonzero temperature. Only as
171 /// deterministic as the backend: llama-server repeats exactly when requests
172 /// run one at a time, and does not once concurrent requests share a batch.
173 /// Rejected on `anthropic` for the same reason as `temperature`.
174 pub seed: Option<u64>,
175 /// How many tokens this model's context holds — for a local server, the
176 /// `-c` it was started with.
177 ///
178 /// Nothing here can discover this: a provider reports how many tokens a
179 /// prompt *used*, never how many are left. Without it the compaction
180 /// threshold has to be an absolute number somebody remembers to set, and
181 /// when nobody does, a long session dies on a raw
182 /// `exceed_context_size_error` from the server with the whole run lost.
183 /// With it, [`AgentConfig::compact_at`] derives a threshold and the CLI
184 /// can show how much room is left.
185 pub context_window: Option<u64>,
186 /// Retries per request on transient failures — 429, 5xx, transport. 0
187 /// disables. Unset means 3. Auth, billing, invalid-request and
188 /// context-overflow errors are never retried: the same payload fails the
189 /// same way, and overflow belongs to the compaction path.
190 pub max_retries: Option<u32>,
191 /// A `Retry-After` above this many seconds is surfaced as a failure
192 /// instead of slept through (default 60) — a provider can name a wait
193 /// long enough that the process is simply asleep, and control never
194 /// returns to a layer that could fall back instead.
195 pub retry_after_cap_secs: Option<u64>,
196 /// Provider entries to try, in order, when this one exhausts its retries
197 /// on a *transient* failure. Turn-local: the next turn starts from this
198 /// provider again. Each fallback answers with its own model. Empty —
199 /// the default — means strict: fail rather than silently answer with a
200 /// different model. `mecha eval` never falls back regardless: a
201 /// scorecard grades the model it names.
202 pub fallbacks: Vec<String>,
203}
204
205impl ProviderConfig {
206 /// Prices, if configured. Both halves are required: knowing one is worse
207 /// than knowing neither, because it silently under-reports.
208 pub fn pricing(&self) -> Option<crate::message::Pricing> {
209 match (self.input_price_per_mtok, self.output_price_per_mtok) {
210 (Some(input), Some(output)) => Some(crate::message::Pricing {
211 input_per_mtok: input,
212 output_per_mtok: output,
213 ..Default::default()
214 }),
215 _ => None,
216 }
217 }
218
219 pub fn resolve_api_key(&self) -> Option<String> {
220 if let Some(var) = &self.api_key_env {
221 if let Ok(v) = std::env::var(var) {
222 if !v.is_empty() {
223 return Some(v);
224 }
225 }
226 }
227 self.api_key.clone().filter(|k| !k.is_empty())
228 }
229}
230
231#[derive(Debug, Clone, Serialize, Deserialize)]
232#[serde(default, deny_unknown_fields)]
233pub struct AgentConfig {
234 pub system_prompt: Option<String>,
235 /// Read the system prompt from a file. Wins over `system_prompt`.
236 pub system_prompt_file: Option<PathBuf>,
237 /// Hard stop on runaway loops: how many model turns one run may take.
238 pub max_turns: u32,
239 pub max_tokens: u32,
240 pub effort: Option<Effort>,
241 pub thinking: bool,
242 /// Mark the tools + system prefix as cacheable.
243 pub cache_prompt: bool,
244 /// When the turn budget runs out, spend one more turn with the tools
245 /// removed so the model has to answer with what it has. Without this a
246 /// model that never stops searching returns nothing at all.
247 pub force_final_answer: bool,
248 /// Stop once this many output tokens have been generated in one run.
249 /// `max_turns` bounds the number of round trips; this bounds their size,
250 /// which is what actually runs up a bill.
251 pub max_output_tokens: Option<u64>,
252 /// Stop once one run has cost this much. Requires prices on the provider.
253 pub max_cost_usd: Option<f64>,
254 /// Summarise the middle of the conversation once the prompt passes this
255 /// many tokens.
256 ///
257 /// Measured against what the provider *reported* for the last turn rather
258 /// than an estimate, so it tracks the real prompt including cached tokens.
259 /// Unset by default: compaction is lossy, and silently paraphrasing
260 /// someone's conversation because it got long is a decision they should
261 /// make. Set it to roughly two thirds of the model's context window — or
262 /// set `context_window` on the provider and let
263 /// [`AgentConfig::compact_at`] work it out.
264 pub compact_at_tokens: Option<u64>,
265 /// IANA timezone name for the user, e.g. `America/New_York`. Unset means
266 /// the machine's. See [`AgentConfig::timezone`].
267 pub timezone: Option<String>,
268 /// Turns kept verbatim after a compaction. The recent ones are where the
269 /// work is; a summary of the last two turns is worse than the turns.
270 pub compact_keep_recent: usize,
271 /// Stop a run that repeats an identical tool call, with an identical
272 /// result, right after a compaction (`StopCause::Loop`).
273 ///
274 /// On by default — the asymmetry is deliberate. A general repeated-call
275 /// detector would need a measurement to justify watching all of ordinary
276 /// work; this one exists to escape the specific loop that burns unbounded
277 /// tokens at the largest prompts a run will ever send, and a no-config
278 /// user should get that protection. Identical arguments with a *changing*
279 /// result is polling and never trips it.
280 pub loop_guard: bool,
281 /// Check each summary against the transcript it replaces before
282 /// installing it, and regenerate once with the omissions named.
283 ///
284 /// Summaries fail by *omission* — they preserve what is true and drop
285 /// task-critical specifics — and the producer cannot see its own gaps.
286 /// A separate grounded comparison can: it reads both texts side by side,
287 /// which is a different task from generating either. Measured elsewhere
288 /// (Slipstream) at +6.4–8.8 points on SWE-bench Verified for under 1%
289 /// latency, with ~90% of catches being omissions. Costs one extra
290 /// request per compaction, two when a regeneration is needed.
291 pub compact_validate: bool,
292}
293
294impl Default for AgentConfig {
295 fn default() -> Self {
296 AgentConfig {
297 system_prompt: None,
298 system_prompt_file: None,
299 max_turns: 40,
300 // Streaming is the default, so there's no HTTP-timeout reason to
301 // keep this small; leave room for thinking plus the answer.
302 max_tokens: 64_000,
303 effort: Some(Effort::High),
304 thinking: true,
305 cache_prompt: true,
306 force_final_answer: true,
307 // Unset by default: a ceiling that surprises you mid-task is worse
308 // than no ceiling. Set them once you run things unattended.
309 max_output_tokens: None,
310 max_cost_usd: None,
311 compact_at_tokens: None,
312 timezone: None,
313 compact_keep_recent: 6,
314 loop_guard: true,
315 compact_validate: true,
316 }
317 }
318}
319
320impl AgentConfig {
321 /// The user's IANA timezone (`America/New_York`), when it is not the
322 /// machine's.
323 ///
324 /// A server runs in UTC and the model has no clock, so without this every
325 /// "what's on Thursday" is answered in the wrong zone — and wrongly in a
326 /// way that looks right, since the times are internally consistent. An
327 /// IANA name rather than an offset, because an offset is wrong twice a
328 /// year.
329 pub fn timezone(&self) -> Option<chrono_tz::Tz> {
330 let name = self.timezone.as_deref()?;
331 match name.parse::<chrono_tz::Tz>() {
332 Ok(tz) => Some(tz),
333 Err(_) => {
334 tracing::warn!("unknown [agent] timezone `{name}`; using the machine's");
335 None
336 }
337 }
338 }
339
340 /// Fraction of a known context window at which to start compacting.
341 ///
342 /// Two thirds, because the threshold is checked *between* turns against
343 /// what the last one reported: the next turn still has to fit the model's
344 /// reply, and a burst of parallel tool results can add several thousand
345 /// tokens before anything gets to look again. Leaving a third of the
346 /// window is what makes the reactive check safe.
347 pub const COMPACT_FRACTION: f64 = 0.66;
348
349 /// Where compaction kicks in for a run: the explicit setting if there is
350 /// one, otherwise derived from the provider's context window.
351 ///
352 /// Deriving it is what turns compaction from something you must remember
353 /// to configure into something that just works — and the failure it
354 /// prevents is total, not gradual: one turn over the window and the
355 /// server refuses the request outright.
356 pub fn compact_at(&self, context_window: Option<u64>) -> Option<u64> {
357 self.compact_at_tokens
358 .or_else(|| context_window.map(|w| (w as f64 * Self::COMPACT_FRACTION) as u64))
359 }
360
361 pub fn resolve_system_prompt(&self) -> Result<Option<String>> {
362 if let Some(path) = &self.system_prompt_file {
363 let text = std::fs::read_to_string(path)
364 .with_context(|| format!("reading system_prompt_file {}", path.display()))?;
365 return Ok(Some(text));
366 }
367 Ok(self.system_prompt.clone())
368 }
369}
370
371#[derive(Debug, Clone, Serialize, Deserialize)]
372#[serde(default, deny_unknown_fields)]
373pub struct ToolsConfig {
374 /// Built-in tools to register. Empty means "all of them".
375 pub enabled: Vec<String>,
376 /// Built-in tools to withhold, applied after `enabled`.
377 pub disabled: Vec<String>,
378 /// Filesystem tools refuse to touch anything outside this root.
379 pub workspace: Option<PathBuf>,
380 /// Default answer when nothing is watching to approve a call.
381 pub permission_mode: PermissionMode,
382 pub shell_timeout_secs: u64,
383 /// The byte budget one turn's tool results share, divided across the
384 /// batch. Oversized results are spilled to a file in full and cut in the
385 /// transcript, with the marker naming the path and the line to resume
386 /// from.
387 pub output_budget_bytes: usize,
388}
389
390impl Default for ToolsConfig {
391 fn default() -> Self {
392 ToolsConfig {
393 enabled: Vec::new(),
394 disabled: Vec::new(),
395 workspace: None,
396 permission_mode: PermissionMode::Ask,
397 shell_timeout_secs: 120,
398 output_budget_bytes: 24_000,
399 }
400 }
401}
402
403/// Defenses against the *lethal trifecta*: private data, untrusted content, and
404/// a way to send data out. An agent holding all three can be turned into an
405/// exfiltration tool by instructions hidden in the content it reads — a
406/// calendar invite title, an email footer, a web page.
407///
408/// The mitigation is structural, not a filter: once both private data and
409/// untrusted content have entered a conversation, refuse to let it send.
410#[derive(Debug, Clone, Serialize, Deserialize)]
411#[serde(default, deny_unknown_fields)]
412pub struct SecurityConfig {
413 pub trifecta: TrifectaPolicy,
414 /// Refuse HTTP requests to loopback, private, and link-local addresses.
415 /// Without this, `http_fetch` reaches your LAN and cloud metadata endpoints.
416 pub block_private_ips: bool,
417 /// If non-empty, HTTP requests may only go to these hosts (suffix match).
418 pub allowed_domains: Vec<String>,
419 /// Hosts that are always refused, checked before `allowed_domains`.
420 pub blocked_domains: Vec<String>,
421 /// Wrap third-party content in a marker telling the model to treat it as
422 /// data rather than instructions. Weak on its own — defense in depth.
423 pub mark_untrusted_output: bool,
424 /// Block *every* outbound call once private data is in context, whether or
425 /// not untrusted content has arrived.
426 ///
427 /// This is a different control from `trifecta`, guarding a different
428 /// threat. The trifecta interlock stops an *injection* turning the agent
429 /// into an exfiltration tool; it deliberately allows sends that happen
430 /// before any third-party content exists, because nothing could have
431 /// influenced them yet. That still lets the agent put your private data
432 /// into a search query because you asked it to, or because it judged that
433 /// helpful — an ordinary privacy leak rather than an attack.
434 ///
435 /// Turn this on when private data must not leave at all. It is
436 /// restrictive: it makes "read my notes, then look something up" fail.
437 pub block_sends_after_private: bool,
438}
439
440impl Default for SecurityConfig {
441 fn default() -> Self {
442 SecurityConfig {
443 trifecta: TrifectaPolicy::Block,
444 block_private_ips: true,
445 allowed_domains: Vec::new(),
446 blocked_domains: Vec::new(),
447 mark_untrusted_output: true,
448 // Off by default: it breaks common, legitimate workflows, and the
449 // right answer for most people is capability separation (put
450 // search in a subagent with no filesystem access) rather than a
451 // blanket ban.
452 block_sends_after_private: false,
453 }
454 }
455}
456
457#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
458#[serde(rename_all = "kebab-case")]
459pub enum TrifectaPolicy {
460 /// Refuse the send outright. The default.
461 Block,
462 /// Ask a human. Only meaningful when someone is watching.
463 Ask,
464 /// Allow it. Appropriate only when the "untrusted" content is in fact
465 /// trusted — e.g. an allowlist of internal hosts.
466 Allow,
467}
468
469/// Capabilities to force on a server's tools. Absent flags leave the server's
470/// own declaration alone; there is deliberately no way to switch one off.
471#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize)]
472#[serde(default, deny_unknown_fields)]
473pub struct CapabilityOverride {
474 pub private_data: bool,
475 pub untrusted_input: bool,
476 pub external_send: bool,
477 pub destructive: bool,
478}
479
480impl From<CapabilityOverride> for crate::tool::Capabilities {
481 fn from(o: CapabilityOverride) -> Self {
482 crate::tool::Capabilities {
483 private_data: o.private_data,
484 untrusted_input: o.untrusted_input,
485 external_send: o.external_send,
486 destructive: o.destructive,
487 }
488 }
489}
490
491#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
492#[serde(rename_all = "kebab-case")]
493pub enum PermissionMode {
494 /// Prompt before anything that isn't read-only.
495 Ask,
496 /// Run everything without asking. For trusted, headless work.
497 Allow,
498 /// Read-only tools run; everything else is refused.
499 ReadOnly,
500}
501
502#[derive(Debug, Clone, Default, Serialize, Deserialize)]
503#[serde(default, deny_unknown_fields)]
504pub struct SearchBackendConfig {
505 /// `exa` | `tavily` | `searxng`
506 pub kind: String,
507 /// Environment variable holding the key. Preferred over `api_key`.
508 pub api_key_env: Option<String>,
509 pub api_key: Option<String>,
510 /// Required for `searxng` (your instance); optional override elsewhere.
511 pub base_url: Option<String>,
512 pub disabled: bool,
513}
514
515impl SearchBackendConfig {
516 pub fn resolve_api_key(&self) -> Option<String> {
517 if let Some(var) = &self.api_key_env {
518 if let Ok(v) = std::env::var(var) {
519 if !v.is_empty() {
520 return Some(v);
521 }
522 }
523 }
524 self.api_key.clone().filter(|k| !k.is_empty())
525 }
526}
527
528#[derive(Debug, Clone, Default, Serialize, Deserialize)]
529#[serde(default, deny_unknown_fields)]
530pub struct McpServerConfig {
531 /// Prefixed onto every tool the server exposes, so two servers can both
532 /// have a `search` without colliding.
533 pub name: String,
534 pub command: String,
535 pub args: Vec<String>,
536 /// Values handed to the server explicitly. Use this for a token the server
537 /// needs, so granting it is a decision written down rather than a
538 /// side-effect of what happened to be exported.
539 pub env: BTreeMap<String, String>,
540 /// Variables inherited from mecha's own environment, by name.
541 ///
542 /// Empty by default, and that default is the point: an MCP server is
543 /// third-party code, and a process that inherits your whole environment
544 /// inherits every provider key in it. `PATH`, `HOME`, `LANG`, `LC_ALL` and
545 /// `TZ` always pass through — without them most runtimes cannot start.
546 pub env_passthrough: Vec<String>,
547 /// Confine this server with the configured `[sandbox]` backend.
548 ///
549 /// Off by default because a confined server sees only the workspace and,
550 /// unless allowed, no network — which is wrong for most of the servers
551 /// people actually run. Worth turning on for anything you did not write.
552 pub sandbox: bool,
553 /// Network for this server alone, overriding `[sandbox] network`.
554 ///
555 /// The case this exists for: a third-party server that has to reach its own
556 /// API, confined, while `shell` still has no way off the machine. With one
557 /// shared switch you would have to open `shell` to satisfy the server.
558 pub network: Option<bool>,
559 /// Capabilities forced onto every tool this server exposes, on top of
560 /// whatever it declares for itself.
561 ///
562 /// MCP capability flags come from the server's own `annotations`, which
563 /// means a third-party server decides how much the interlock distrusts it.
564 /// An unannotated tool is treated as private-but-trusted — wrong in the
565 /// dangerous direction for anything that reaches the open world. A Google
566 /// Docs server is the worked example: a document someone shared with you is
567 /// third-party text, and writing into a document an attacker can read is an
568 /// exfiltration channel, so it is all three legs at once and says none of
569 /// them.
570 ///
571 /// Only ever widens — see [`crate::tool::Capabilities::union`].
572 pub capabilities: CapabilityOverride,
573 /// Skip this server without deleting its config.
574 pub disabled: bool,
575}
576
577impl Config {
578 pub fn global_path() -> Option<PathBuf> {
579 crate::work::mecha_home()
580 .ok()
581 .map(|h| h.join("config.toml"))
582 }
583
584 pub const PROJECT_FILE: &'static str = "mecha.toml";
585
586 /// Load defaults, then the global file, then the project file, then env.
587 pub fn load(project_dir: &Path) -> Result<Self> {
588 let mut cfg = Config::default();
589 if let Some(path) = Self::global_path() {
590 if path.exists() {
591 cfg.merge_file(&path)?;
592 }
593 }
594 let project = project_dir.join(Self::PROJECT_FILE);
595 if project.exists() {
596 cfg.merge_file(&project)?;
597 }
598 cfg.merge_env();
599 Ok(cfg)
600 }
601
602 /// Defaults plus `~/.mecha/config.toml` plus env — no project layer.
603 ///
604 /// For runs that must not be configurable by whatever directory they happen
605 /// to start in. A `mecha.toml` arrives with a cloned repository, and it can
606 /// name MCP servers to spawn, hooks to execute and tools to enable; that is
607 /// a reasonable bargain when a person is sitting there having just decided
608 /// to work in that repository, and not one at all for a
609 /// [`crate::trigger`] firing at 03:00 with nobody watching.
610 pub fn load_global() -> Result<Self> {
611 let mut cfg = Config::default();
612 if let Some(path) = Self::global_path() {
613 if path.exists() {
614 cfg.merge_file(&path)?;
615 }
616 }
617 cfg.merge_env();
618 Ok(cfg)
619 }
620
621 fn merge_file(&mut self, path: &Path) -> Result<()> {
622 let text =
623 std::fs::read_to_string(path).with_context(|| format!("reading {}", path.display()))?;
624 let layer: ConfigLayer =
625 toml::from_str(&text).with_context(|| format!("parsing {}", path.display()))?;
626 layer.apply(self);
627 Ok(())
628 }
629
630 fn merge_env(&mut self) {
631 if let Ok(v) = std::env::var("MECHA_PROVIDER") {
632 self.default_provider = v;
633 }
634 if let Ok(v) = std::env::var("MECHA_MODEL") {
635 let name = self.default_provider.clone();
636 if let Some(p) = self.providers.get_mut(&name) {
637 p.model = Some(v);
638 }
639 }
640 if let Ok(v) = std::env::var("MECHA_EFFORT") {
641 if let Ok(e) = v.parse() {
642 self.agent.effort = Some(e);
643 }
644 }
645 }
646
647 pub fn provider(&self, name: Option<&str>) -> Result<(String, &ProviderConfig)> {
648 let name = name.unwrap_or(&self.default_provider).to_string();
649 let cfg = self.providers.get(&name).with_context(|| {
650 format!(
651 "no provider named {name:?}. Configured: {}",
652 self.providers
653 .keys()
654 .cloned()
655 .collect::<Vec<_>>()
656 .join(", ")
657 )
658 })?;
659 Ok((name, cfg))
660 }
661
662 /// Write this config to `path`, creating parent directories.
663 pub fn save(&self, path: &Path) -> Result<()> {
664 if let Some(parent) = path.parent() {
665 std::fs::create_dir_all(parent)?;
666 }
667 let text = toml::to_string_pretty(self)?;
668 std::fs::write(path, text).with_context(|| format!("writing {}", path.display()))?;
669 Ok(())
670 }
671}
672
673/// A partially-specified config file. Every field is optional so a project file
674/// can override one setting without restating the rest.
675#[derive(Debug, Default, Deserialize)]
676#[serde(deny_unknown_fields)]
677struct ConfigLayer {
678 default_provider: Option<String>,
679 providers: Option<BTreeMap<String, ProviderConfig>>,
680 agent: Option<AgentLayer>,
681 tools: Option<ToolsLayer>,
682 security: Option<SecurityLayer>,
683 #[serde(rename = "mcp")]
684 mcp: Option<Vec<McpServerConfig>>,
685 #[serde(rename = "subagent")]
686 subagents: Option<Vec<crate::subagent::SubagentProfile>>,
687 #[serde(rename = "search")]
688 search: Option<Vec<SearchBackendConfig>>,
689 #[serde(rename = "hook")]
690 hooks: Option<Vec<HookConfig>>,
691 sandbox: Option<SandboxLayer>,
692 outbox: Option<OutboxLayer>,
693 work: Option<WorkLayer>,
694}
695
696#[derive(Debug, Default, Deserialize)]
697#[serde(deny_unknown_fields)]
698struct WorkLayer {
699 keep: Option<usize>,
700}
701
702#[derive(Debug, Default, Deserialize)]
703#[serde(deny_unknown_fields)]
704struct OutboxLayer {
705 tools: Option<Vec<String>>,
706 dir: Option<PathBuf>,
707 publish_tools: Option<Vec<String>>,
708}
709
710#[derive(Debug, Default, Deserialize)]
711#[serde(deny_unknown_fields)]
712struct AgentLayer {
713 system_prompt: Option<String>,
714 system_prompt_file: Option<PathBuf>,
715 max_turns: Option<u32>,
716 max_tokens: Option<u32>,
717 effort: Option<Effort>,
718 thinking: Option<bool>,
719 cache_prompt: Option<bool>,
720 force_final_answer: Option<bool>,
721 max_output_tokens: Option<u64>,
722 max_cost_usd: Option<f64>,
723 compact_at_tokens: Option<u64>,
724 compact_keep_recent: Option<usize>,
725 compact_validate: Option<bool>,
726 loop_guard: Option<bool>,
727 timezone: Option<String>,
728}
729
730#[derive(Debug, Default, Deserialize)]
731#[serde(deny_unknown_fields)]
732struct SecurityLayer {
733 trifecta: Option<TrifectaPolicy>,
734 block_private_ips: Option<bool>,
735 allowed_domains: Option<Vec<String>>,
736 blocked_domains: Option<Vec<String>>,
737 mark_untrusted_output: Option<bool>,
738 block_sends_after_private: Option<bool>,
739}
740
741#[derive(Debug, Default, Deserialize)]
742#[serde(deny_unknown_fields)]
743struct SandboxLayer {
744 kind: Option<crate::sandbox::Backend>,
745 network: Option<bool>,
746 writable: Option<Vec<PathBuf>>,
747 readable: Option<Vec<PathBuf>>,
748 env: Option<Vec<String>>,
749 image: Option<String>,
750 memory_mb: Option<u64>,
751 cpus: Option<f64>,
752}
753
754#[derive(Debug, Default, Deserialize)]
755#[serde(deny_unknown_fields)]
756struct ToolsLayer {
757 enabled: Option<Vec<String>>,
758 disabled: Option<Vec<String>>,
759 workspace: Option<PathBuf>,
760 permission_mode: Option<PermissionMode>,
761 shell_timeout_secs: Option<u64>,
762 output_budget_bytes: Option<usize>,
763}
764
765impl ConfigLayer {
766 fn apply(self, cfg: &mut Config) {
767 if let Some(v) = self.default_provider {
768 cfg.default_provider = v;
769 }
770 // Providers merge by key so a project file can add a local endpoint
771 // without redeclaring the Anthropic one.
772 if let Some(providers) = self.providers {
773 cfg.providers.extend(providers);
774 }
775 if let Some(a) = self.agent {
776 let t = &mut cfg.agent;
777 if a.system_prompt.is_some() {
778 t.system_prompt = a.system_prompt;
779 }
780 if a.system_prompt_file.is_some() {
781 t.system_prompt_file = a.system_prompt_file;
782 }
783 if let Some(v) = a.max_turns {
784 t.max_turns = v;
785 }
786 if let Some(v) = a.max_tokens {
787 t.max_tokens = v;
788 }
789 if a.effort.is_some() {
790 t.effort = a.effort;
791 }
792 if let Some(v) = a.thinking {
793 t.thinking = v;
794 }
795 if let Some(v) = a.cache_prompt {
796 t.cache_prompt = v;
797 }
798 if let Some(v) = a.force_final_answer {
799 t.force_final_answer = v;
800 }
801 if a.max_output_tokens.is_some() {
802 t.max_output_tokens = a.max_output_tokens;
803 }
804 if a.max_cost_usd.is_some() {
805 t.max_cost_usd = a.max_cost_usd;
806 }
807 if a.compact_at_tokens.is_some() {
808 t.compact_at_tokens = a.compact_at_tokens;
809 }
810 if let Some(v) = a.compact_keep_recent {
811 t.compact_keep_recent = v;
812 }
813 if let Some(v) = a.compact_validate {
814 t.compact_validate = v;
815 }
816 if let Some(v) = a.loop_guard {
817 t.loop_guard = v;
818 }
819 if a.timezone.is_some() {
820 t.timezone = a.timezone;
821 }
822 }
823 if let Some(x) = self.tools {
824 let t = &mut cfg.tools;
825 if let Some(v) = x.enabled {
826 t.enabled = v;
827 }
828 if let Some(v) = x.disabled {
829 t.disabled = v;
830 }
831 if x.workspace.is_some() {
832 t.workspace = x.workspace;
833 }
834 if let Some(v) = x.permission_mode {
835 t.permission_mode = v;
836 }
837 if let Some(v) = x.shell_timeout_secs {
838 t.shell_timeout_secs = v;
839 }
840 if let Some(v) = x.output_budget_bytes {
841 t.output_budget_bytes = v;
842 }
843 }
844 if let Some(x) = self.security {
845 let t = &mut cfg.security;
846 if let Some(v) = x.trifecta {
847 t.trifecta = v;
848 }
849 if let Some(v) = x.block_private_ips {
850 t.block_private_ips = v;
851 }
852 if let Some(v) = x.allowed_domains {
853 t.allowed_domains = v;
854 }
855 if let Some(v) = x.blocked_domains {
856 t.blocked_domains = v;
857 }
858 if let Some(v) = x.mark_untrusted_output {
859 t.mark_untrusted_output = v;
860 }
861 if let Some(v) = x.block_sends_after_private {
862 t.block_sends_after_private = v;
863 }
864 }
865 if let Some(x) = self.sandbox {
866 let t = &mut cfg.sandbox;
867 if let Some(v) = x.kind {
868 t.kind = v;
869 }
870 if let Some(v) = x.network {
871 t.network = v;
872 }
873 if let Some(v) = x.writable {
874 t.writable = v;
875 }
876 if let Some(v) = x.readable {
877 t.readable = v;
878 }
879 if let Some(v) = x.env {
880 t.env = v;
881 }
882 if let Some(v) = x.image {
883 t.image = v;
884 }
885 if x.memory_mb.is_some() {
886 t.memory_mb = x.memory_mb;
887 }
888 if x.cpus.is_some() {
889 t.cpus = x.cpus;
890 }
891 }
892 // MCP servers replace wholesale — merging lists by name would make it
893 // impossible for a project to turn a global server off.
894 if let Some(v) = self.mcp {
895 cfg.mcp = v;
896 }
897 if let Some(v) = self.subagents {
898 cfg.subagents = v;
899 }
900 if let Some(v) = self.search {
901 cfg.search = v;
902 }
903 // Wholesale, like MCP servers and for the same reason: a project that
904 // cannot turn a global hook off cannot be trusted to run anything.
905 if let Some(v) = self.hooks {
906 cfg.hooks = v;
907 }
908 if let Some(x) = self.outbox {
909 let t = &mut cfg.outbox;
910 // Wholesale: a project must be able to un-route a tool the global
911 // config routes, and vice versa.
912 if let Some(v) = x.tools {
913 t.tools = v;
914 }
915 if x.dir.is_some() {
916 t.dir = x.dir;
917 }
918 if let Some(v) = x.publish_tools {
919 t.publish_tools = v;
920 }
921 }
922 if let Some(x) = self.work {
923 if let Some(v) = x.keep {
924 cfg.work.keep = v;
925 }
926 }
927 }
928}
929
930#[cfg(test)]
931mod tests {
932 use super::*;
933
934 #[test]
935 fn layer_overrides_only_named_fields() {
936 let mut cfg = Config::default();
937 let layer: ConfigLayer = toml::from_str(
938 r#"
939 [agent]
940 max_turns = 5
941 "#,
942 )
943 .unwrap();
944 layer.apply(&mut cfg);
945 assert_eq!(cfg.agent.max_turns, 5);
946 // Untouched fields keep their defaults.
947 assert_eq!(cfg.agent.max_tokens, 64_000);
948 assert_eq!(cfg.default_provider, "anthropic");
949 }
950
951 #[test]
952 fn providers_merge_by_key() {
953 let mut cfg = Config::default();
954 let layer: ConfigLayer = toml::from_str(
955 r#"
956 [providers.local]
957 kind = "local"
958 base_url = "http://127.0.0.1:8080"
959 "#,
960 )
961 .unwrap();
962 layer.apply(&mut cfg);
963 assert!(cfg.providers.contains_key("anthropic"));
964 assert!(cfg.providers.contains_key("local"));
965 }
966
967 #[test]
968 fn hooks_configure_from_a_file() {
969 let mut cfg = Config::default();
970 let layer: ConfigLayer = toml::from_str(
971 r#"
972 [[hook]]
973 event = "pre_tool"
974 tools = ["shell"]
975 command = "policy.sh"
976 "#,
977 )
978 .unwrap();
979 layer.apply(&mut cfg);
980 assert_eq!(cfg.hooks.len(), 1);
981 assert_eq!(cfg.hooks[0].event, "pre_tool");
982 assert_eq!(cfg.hooks[0].tools, ["shell"]);
983 }
984
985 /// An explicit threshold always wins; otherwise a known window derives
986 /// one. The derived value must leave real headroom — the check happens
987 /// *between* turns, so the next request has to fit the reply and whatever
988 /// a burst of parallel tool results adds.
989 #[test]
990 fn the_compaction_threshold_derives_from_a_known_context_window() {
991 let mut cfg = AgentConfig::default();
992 assert_eq!(cfg.compact_at(None), None, "unknowable stays unset");
993
994 // The DGX's llama-server runs -c 32768; two thirds of that.
995 let derived = cfg.compact_at(Some(32768)).unwrap();
996 assert_eq!(derived, 21626);
997 assert!(
998 derived < 32768 - 8192,
999 "must leave room for a reply and a burst of tool results: {derived}"
1000 );
1001
1002 cfg.compact_at_tokens = Some(9000);
1003 assert_eq!(cfg.compact_at(Some(32768)), Some(9000), "explicit wins");
1004 }
1005
1006 /// A `mecha.toml` arrives with a cloned repository, and it can name MCP
1007 /// servers to spawn, hooks to run and tools to enable. That is a reasonable
1008 /// bargain for someone who just decided to work in that repository, and no
1009 /// bargain at all for a trigger firing at 03:00 — so the scheduled path
1010 /// loads the global layer only. Verified as a *difference*, because the
1011 /// same call on a machine with no project file proves nothing.
1012 #[test]
1013 fn the_project_layer_is_reachable_from_load_and_not_from_load_global() {
1014 let dir = std::env::temp_dir().join(format!("mecha-config-scope-{}", std::process::id()));
1015 std::fs::create_dir_all(&dir).unwrap();
1016 std::fs::write(
1017 dir.join(Config::PROJECT_FILE),
1018 "default_provider = \"contributed-by-the-repository\"\n",
1019 )
1020 .unwrap();
1021
1022 let with_project = Config::load(&dir).unwrap();
1023 assert_eq!(
1024 with_project.default_provider,
1025 "contributed-by-the-repository"
1026 );
1027
1028 let global_only = Config::load_global().unwrap();
1029 assert_ne!(
1030 global_only.default_provider, "contributed-by-the-repository",
1031 "a scheduled unattended run must not take its configuration from \
1032 whatever directory it happens to start in"
1033 );
1034
1035 let _ = std::fs::remove_dir_all(&dir);
1036 }
1037
1038 #[test]
1039 fn every_field_of_config_is_reachable_from_a_file() {
1040 // The bug this exists for: `hooks` was added to `Config` and not to
1041 // `ConfigLayer`, so `[[hook]]` in any config file was a hard parse
1042 // error and the whole feature was unreachable — while every unit test
1043 // passed, because they all built the type directly.
1044 //
1045 // Serialising the default config produces one entry per top-level
1046 // field; `ConfigLayer` denies unknown fields, so parsing it back is a
1047 // standing check that the two structs still agree. Any field added to
1048 // one and not the other fails here rather than in someone's config.
1049 let rendered = toml::to_string(&Config::default()).unwrap();
1050 let parsed = toml::from_str::<ConfigLayer>(&rendered);
1051 assert!(
1052 parsed.is_ok(),
1053 "Config has a field ConfigLayer cannot read: {parsed:?}"
1054 );
1055 }
1056}