Skip to main content

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}