newt_core/config.rs
1//! Configuration loading for Newt-Agent.
2//!
3//! Base resolution order: `$NEWT_CONFIG` env var, then `./newt.toml`,
4//! `$NEWT_CONFIG_DIR/config.toml` (or `~/.newt/config.toml`), then
5//! `/etc/newt/config.toml`. If none exist the built-in defaults are used
6//! (a single Ollama backend on localhost).
7//!
8//! A project-local `.newt/config.toml` (found by walking up from the current
9//! directory) is then deep-merged **over** that base, so a git repo can pin its
10//! own models, endpoints, rules, and local stdio MCP services without copying
11//! the whole global config. See [`Config::resolve`] and issue #222.
12
13use std::path::{Path, PathBuf};
14
15use serde::{Deserialize, Serialize};
16
17use crate::error::{NewtError, Result};
18use crate::router::Tier;
19
20/// Process-scoped user config root override, set by the CLI's `--config-dir`.
21pub const NEWT_CONFIG_DIR_ENV: &str = "NEWT_CONFIG_DIR";
22
23// ---------------------------------------------------------------------------
24// Config types
25// ---------------------------------------------------------------------------
26
27/// `[scratch]` — the ephemeral-state location (#844). See [`Config::scratch`].
28#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
29#[serde(deny_unknown_fields)]
30pub struct ScratchConfig {
31 /// The scratch dir: relative (under the repo, default `.scratch`) or absolute
32 /// (`/tmp`, a PVC mount) for a read-only checkout. `NEWT_SCRATCH_DIR` wins.
33 #[serde(default, skip_serializing_if = "Option::is_none")]
34 pub dir: Option<String>,
35}
36
37/// Top-level Newt-Agent configuration.
38#[derive(Debug, Clone, Serialize, Deserialize)]
39#[serde(default)]
40pub struct Config {
41 /// Inference backends (Ollama, vLLM, etc.).
42 ///
43 /// Absent `[[backends]]` deserializes to **empty** (not the struct-level
44 /// default's localhost fallback) so a config that defines its backends as
45 /// per-file `~/.newt/backends/*.toml` drop-ins does NOT also pick up a
46 /// spurious synthesized `ollama` entry. The localhost fallback is restored
47 /// in [`Config::resolve`] only if backends are still empty after the disk
48 /// merge (so a truly bare setup still talks to a local Ollama).
49 #[serde(default = "Vec::new")]
50 pub backends: Vec<BackendConfig>,
51
52 /// External provider-plugin definitions.
53 pub providers: Vec<ProviderConfig>,
54
55 /// `[scratch]` — where ephemeral state (crew worktrees, the crew cargo
56 /// target, per-session plans) lives (#844). `dir` may be relative (under the
57 /// repo, default `.scratch`) or absolute (`/tmp`, a k8s PVC mount) for
58 /// read-only checkouts. `NEWT_SCRATCH_DIR` overrides it. Applied in
59 /// [`Config::resolve`] via [`crate::scratch::set_scratch_dir`].
60 #[serde(default)]
61 pub scratch: Option<ScratchConfig>,
62
63 /// Default tier ordering used by the router when no per-backend
64 /// override is specified.
65 pub default_tier_order: Vec<Tier>,
66
67 /// `[lifecycle]` — the repo's build/dev commands per lifecycle phase
68 /// (`format`, `check`, `clean`, …), #880. Overrides the per-ecosystem tooling
69 /// packs. Applied in [`Config::resolve`] via
70 /// [`crate::tooling::set_lifecycle_override`].
71 #[serde(default)]
72 pub lifecycle: Option<crate::tooling::PhaseCommands>,
73
74 /// Optional NVIDIA DGX endpoint-management config powering the
75 /// `newt dgx` command suite. `None` when unconfigured — newt never
76 /// dials a DGX endpoint unless this (or a `NEWT_DGX_*` env var) is set.
77 #[serde(default, skip_serializing_if = "Option::is_none")]
78 pub dgx: Option<crate::dgx::DgxConfig>,
79
80 /// TUI appearance and behaviour. `None` → built-in defaults apply.
81 /// Overridable at runtime via `NEWT_CHAT_STYLE` and `NEWT_PROMPT`.
82 #[serde(default, skip_serializing_if = "Option::is_none")]
83 pub tui: Option<TuiConfig>,
84
85 /// `[shell]` — which engine runs `run_command` (ADR 0005 D2 seam). `None` /
86 /// unset → the `safe-subset` default, except `--full-access` auto-upgrades to
87 /// `host`. Overridable per-session by `--shell-engine`. The L3 backend
88 /// (Landlock/Seatbelt/AppContainer) is a separate, auto-selected axis.
89 #[serde(default, skip_serializing_if = "Option::is_none")]
90 pub shell: Option<ShellConfig>,
91
92 /// `[context]` — context-management strategy selection (Step 24.8, #559).
93 #[serde(default, skip_serializing_if = "Option::is_none")]
94 pub context: Option<ContextConfig>,
95
96 /// `[tools]` — tool-execution behaviour (#726). `None` → built-in defaults
97 /// (notably `max_output_tokens` = 10000). See [`ToolsConfig`].
98 #[serde(default, skip_serializing_if = "Option::is_none")]
99 pub tools: Option<ToolsConfig>,
100
101 /// Inference cost modeling. `None` → built-in rate table only.
102 #[serde(default, skip_serializing_if = "Option::is_none")]
103 pub pricing: Option<crate::pricing::PricingConfig>,
104
105 /// Memory / context-window management. `None` → RollingWindow(20).
106 #[serde(default, skip_serializing_if = "Option::is_none")]
107 pub memory: Option<MemoryConfig>,
108
109 /// Project-instruction loading (`AGENTS.md` / `CLAUDE.md`) into the system
110 /// prompt. Enabled by default. Overridable via `--agents-file` /
111 /// `--no-agents-file`.
112 #[serde(default)]
113 pub agents: AgentsConfig,
114
115 /// newt-native MCP servers (`[[mcp_servers]]`). Merged with the servers
116 /// discovered from Claude Code's config by [`crate::mcp::discover`]; these
117 /// take precedence on a name clash. Empty by default.
118 #[serde(default, skip_serializing_if = "Vec::is_empty")]
119 pub mcp_servers: Vec<crate::mcp::McpServerEntry>,
120
121 /// Usage-log rotation policy. `None` → built-in defaults apply
122 /// (keep last 7 sessions, no size/age limit).
123 #[serde(default, skip_serializing_if = "Option::is_none")]
124 pub logs: Option<LogConfig>,
125
126 /// Skill discovery search path — the ordered list of directories newt
127 /// reads `SKILL.md` folders from. `None` → just `~/.newt/skills`.
128 #[serde(default, skip_serializing_if = "Option::is_none")]
129 pub skills: Option<SkillsConfig>,
130
131 /// Per-model inference tuning overrides (`[[model_tuning]]`).
132 ///
133 /// Each entry locks specific parameters for a named model. Values here
134 /// take precedence over empirically derived values from
135 /// `model-capabilities.json` and over global `[tui]` defaults.
136 ///
137 /// Example `~/.newt/config.toml`:
138 /// ```toml
139 /// [[model_tuning]]
140 /// model = "nemotron3:33b"
141 /// num_ctx = 24576 # explicit Ollama context window
142 /// mid_loop_trim_threshold = 12
143 /// max_tool_rounds = 20
144 /// ```
145 ///
146 /// Human-authored entries are never overwritten by the auto-tuner.
147 /// Auto-tuned entries are **appended** by the harness when
148 /// `tune_confidence` reaches `High`; delete or edit them freely.
149 #[serde(default, skip_serializing_if = "Vec::is_empty")]
150 pub model_tuning: Vec<ModelTuning>,
151
152 /// Durable conversation save/restore policy. `None` uses built-in defaults.
153 #[serde(default, skip_serializing_if = "Option::is_none")]
154 pub conversations: Option<ConversationsConfig>,
155
156 /// How a project-local `.newt/config.toml` is layered over the global
157 /// config (issue #222). `None` → built-in default (arrays replace).
158 #[serde(default, skip_serializing_if = "Option::is_none")]
159 pub merge: Option<MergeConfig>,
160
161 /// Named permission presets (`[permission_presets.<name>]`, issue #307).
162 /// Each maps onto the role-profile caveat mechanism (a
163 /// [`crate::NamedPermissionPreset`]) and, when applied via `/mode`, clamps
164 /// the session's authority as a hard floor. Empty by default — no preset,
165 /// behavior unchanged.
166 #[serde(default, skip_serializing_if = "std::collections::BTreeMap::is_empty")]
167 pub permission_presets: std::collections::BTreeMap<String, crate::NamedPermissionPreset>,
168
169 /// Named modes (`[modes.<name>]`, issue #307) for the `/mode` command. Each
170 /// mode atomically binds a skill body to preload, a permission preset to
171 /// apply as an authority floor, and a one-line system-prompt framing. Empty
172 /// by default. See [`ModeConfig`].
173 #[serde(default, skip_serializing_if = "std::collections::BTreeMap::is_empty")]
174 pub modes: std::collections::BTreeMap<String, ModeConfig>,
175
176 /// Named profiles (`[profiles.<name>]`) — a composition of harness
177 /// *techniques* plus each technique's tunable knob settings (the technique
178 /// library, `docs/design/technique-library.md`). A profile is selected by
179 /// `--profile <name>` and tunes the harness per model family / context.
180 /// Empty by default — no profile, behavior unchanged. See [`ProfileConfig`].
181 #[serde(default, skip_serializing_if = "std::collections::BTreeMap::is_empty")]
182 pub profiles: std::collections::BTreeMap<String, ProfileConfig>,
183
184 /// Named bundles (`[bundles.<name>]`) — the loadable unit of the model support
185 /// kit (`docs/design/model-support-kit.md`). A bundle pins which model families
186 /// it applies to and which profile each resolves to. Selected by `--bundle
187 /// <name>` or inferred from the model via `applies_to`. Empty by default — no
188 /// bundle, behavior unchanged. See [`BundleConfig`].
189 #[serde(default, skip_serializing_if = "std::collections::BTreeMap::is_empty")]
190 pub bundles: std::collections::BTreeMap<String, BundleConfig>,
191
192 /// Named loadouts (`[loadouts.<name>]` or `~/.newt/loadouts/<name>.toml`) — the
193 /// top-level composition of `provider → model → kit → role → settings`
194 /// (`docs/design/loadout-composition.md`). Inert until the resolver is wired
195 /// (Slice 1): this carries the data model + reference validation + `/loadout`
196 /// show. Empty by default. See [`Loadout`].
197 #[serde(default, skip_serializing_if = "std::collections::BTreeMap::is_empty")]
198 pub loadouts: std::collections::BTreeMap<String, Loadout>,
199
200 /// Named crews (`[crews.<name>]` or `crews/<name>.toml`) — role-specialized
201 /// ensembles over the backend pool (`docs/design/crew-loadout.md`). Each role
202 /// names a `[loadouts.<name>]`. Empty by default. See [`Crew`].
203 #[serde(default, skip_serializing_if = "std::collections::BTreeMap::is_empty")]
204 pub crews: std::collections::BTreeMap<String, Crew>,
205
206 /// `[crew]` — crew/team **dispatch policy** (#749). Carries the authority
207 /// *clamp* every dispatched crew is met against, so a crew's effective
208 /// authority is `session ⊓ clamp` — never above the session ceiling, and as
209 /// tight as the operator configures. `None` (and the default clamp) is
210 /// `Caveats::top()`, i.e. the meet is the identity and behavior is unchanged.
211 /// This is the structural tightening point the per-subtask `team_clamp`
212 /// (#749 step 8) plugs into. See [`CrewPolicyConfig`].
213 #[serde(default, skip_serializing_if = "Option::is_none")]
214 pub crew: Option<CrewPolicyConfig>,
215
216 /// `[plan]` — plan-authoring policy. Today: the `[plan.prune]` droppable
217 /// override for the decompose prune's anti-pattern lexicon (#801/#803 →
218 /// #819). `None` = compiled defaults, behavior unchanged. See
219 /// [`PlanPruneConfig`].
220 #[serde(default, skip_serializing_if = "Option::is_none")]
221 pub plan: Option<PlanConfig>,
222}
223
224/// One named mode (`[modes.<name>]`, issue #307): the atomic binding the
225/// `/mode <name>` command applies in a single invocation.
226///
227/// ```toml
228/// [modes.triage]
229/// skill = "oncall-triage" # skill body to preload (use_skill path)
230/// preset = "readonly-triage" # [permission_presets.<name>] to clamp to
231/// framing = "On-call triage: investigate, do not change production."
232/// ```
233///
234/// Every field is optional so a mode can do any subset (e.g. preset-only, or
235/// framing-only). A `skill`/`preset` that names a missing entry is reported as
236/// an error by the command rather than silently ignored — a mode that claims a
237/// clamp it never applied would be a false security claim.
238#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
239pub struct ModeConfig {
240 /// Skill name to preload (the same `use_skill` / `load_body_from` path).
241 /// `None` ⇒ no skill is loaded.
242 #[serde(default, skip_serializing_if = "Option::is_none")]
243 pub skill: Option<String>,
244 /// `[permission_presets.<name>]` to apply as the session authority floor.
245 /// `None` ⇒ authority unchanged.
246 #[serde(default, skip_serializing_if = "Option::is_none")]
247 pub preset: Option<String>,
248 /// One-line framing injected into the system prompt. `None` ⇒ no framing.
249 #[serde(default, skip_serializing_if = "Option::is_none")]
250 pub framing: Option<String>,
251}
252
253/// The known harness techniques a profile may compose — the registry the
254/// validator checks against. A profile naming a technique outside this set is
255/// rejected (an unknown technique a profile claims but cannot apply would be a
256/// false claim). Extend this as techniques land (R3 `fact_preserving_compression`,
257/// R4 `self_grounding`, …).
258pub const KNOWN_TECHNIQUES: &[&str] = &[
259 "knowledge_base", // R1 — inject the authoritative import surface (#74)
260 "verify_gate", // R2 — revert files with fabricated imports (#73)
261 "retry", // revert-retry loop over the gate's revert set
262];
263
264/// One named profile (`[profiles.<name>]`): the harness techniques to compose for
265/// a model family / context, plus each technique's knob settings.
266///
267/// ```toml
268/// [profiles.nemotron]
269/// techniques = ["knowledge_base", "verify_gate", "retry"]
270///
271/// [profiles.nemotron.verify_gate]
272/// surface_match = "exact" # SurfaceMatch — leaf-exact (the complete-gate default)
273///
274/// [profiles.nemotron.retry]
275/// max_retries = 2
276/// ```
277///
278/// A knob table only takes effect when its technique is enabled. An unknown
279/// technique name is an error ([`ProfileConfig::validate`]).
280#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
281pub struct ProfileConfig {
282 /// The ordered set of techniques this profile composes. Empty ⇒ the profile
283 /// applies no techniques (equivalent to the `default`/light profile).
284 #[serde(default, skip_serializing_if = "Vec::is_empty")]
285 pub techniques: Vec<String>,
286 /// Knobs for the `verify_gate` technique (applied iff it is enabled).
287 #[serde(default, skip_serializing_if = "Option::is_none")]
288 pub verify_gate: Option<VerifyGateKnobs>,
289 /// Knobs for the `retry` technique (applied iff it is enabled).
290 #[serde(default, skip_serializing_if = "Option::is_none")]
291 pub retry: Option<RetryKnobs>,
292}
293
294/// Tunable knobs for the `verify_gate` technique.
295#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
296pub struct VerifyGateKnobs {
297 /// How strictly the project surface is matched. Default `Exact` — the
298 /// adversarially-complete setting (the retry-Goodhart finding).
299 #[serde(default)]
300 pub surface_match: crate::verify_gate::SurfaceMatch,
301 /// How strictly the gate ACTS on flagged output — the tier. Default
302 /// `RevertRetry` (today's behavior when the `retry` technique is on); lower
303 /// tiers (`off`/`advisory`/`revert_once`) trade enforcement for latitude.
304 #[serde(default)]
305 pub tier: crate::verify_gate::VerifyTier,
306}
307
308/// Tunable knobs for the `retry` technique.
309#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
310pub struct RetryKnobs {
311 /// Maximum revert-retry attempts. Default 2.
312 #[serde(default = "default_max_retries")]
313 pub max_retries: u32,
314}
315
316const fn default_max_retries() -> u32 {
317 2
318}
319
320impl Default for RetryKnobs {
321 fn default() -> Self {
322 Self {
323 max_retries: default_max_retries(),
324 }
325 }
326}
327
328impl ProfileConfig {
329 /// Validate the profile against the [component registry](crate::kit): every
330 /// named technique must be a known component, and every component's
331 /// `presupposes` must also be enabled (e.g. `retry` presupposes `verify_gate`).
332 /// A presupposition gap is a **load-time** error, not a silent partial apply.
333 ///
334 /// # Errors
335 /// Returns the first unknown-technique or unmet-presupposition as a message.
336 pub fn validate(&self) -> std::result::Result<(), String> {
337 for t in &self.techniques {
338 let Some(entry) = crate::kit::component(t) else {
339 return Err(format!(
340 "unknown technique '{t}' in profile (known: {})",
341 KNOWN_TECHNIQUES.join(", ")
342 ));
343 };
344 for pre in entry.presupposes {
345 if !self.techniques.iter().any(|x| x == pre) {
346 return Err(format!(
347 "technique '{t}' presupposes '{pre}', which the profile does not enable"
348 ));
349 }
350 }
351 }
352 Ok(())
353 }
354
355 /// Whether this profile enables `technique`.
356 #[must_use]
357 pub fn enables(&self, technique: &str) -> bool {
358 self.techniques.iter().any(|t| t == technique)
359 }
360
361 /// The effective `verify_gate` knobs (defaults when unset).
362 #[must_use]
363 pub fn verify_gate_knobs(&self) -> VerifyGateKnobs {
364 self.verify_gate.unwrap_or_default()
365 }
366
367 /// The effective `retry` knobs (defaults when unset).
368 #[must_use]
369 pub fn retry_knobs(&self) -> RetryKnobs {
370 self.retry.unwrap_or_default()
371 }
372}
373
374// ---------------------------------------------------------------------------
375// Project-local config layering (issue #222)
376// ---------------------------------------------------------------------------
377
378/// How arrays (`[[backends]]`, `[[providers]]`, `[[mcp_servers]]`,
379/// `[[model_tuning]]`) are combined when a project-local `.newt/config.toml`
380/// is layered over the global config.
381#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
382#[serde(rename_all = "snake_case")]
383pub enum ArrayMergeStrategy {
384 /// The project array replaces the global array wholesale. Predictable and
385 /// safe — the project fully owns that list. **Default.**
386 #[default]
387 Replace,
388 /// The project array is appended to the global array (global entries first,
389 /// then the project's). Additive — e.g. register an extra local stdio MCP
390 /// server without redefining the global ones.
391 Append,
392}
393
394/// Controls how a project-local `.newt/config.toml` is merged over the global
395/// config. Tables always merge recursively (project keys win); this only
396/// governs array handling. See issue #222.
397///
398/// Example project `.newt/config.toml`:
399/// ```toml
400/// [merge]
401/// arrays = "append" # add to the global lists instead of replacing them
402///
403/// [[mcp_servers]]
404/// name = "project-fs"
405/// command = "mcp-fs"
406/// args = ["--root", "."]
407/// ```
408#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
409#[serde(default)]
410pub struct MergeConfig {
411 /// Array-combination strategy. Default: [`ArrayMergeStrategy::Replace`].
412 #[serde(default)]
413 pub arrays: ArrayMergeStrategy,
414}
415
416// ---------------------------------------------------------------------------
417// Durable conversation config
418// ---------------------------------------------------------------------------
419
420#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
421#[serde(default)]
422pub struct ConversationsConfig {
423 /// Maximum saved conversations per workspace. Default: 100. 0 = no pruning.
424 #[serde(default = "default_conversations_max_per_workspace")]
425 pub max_per_workspace: usize,
426
427 /// Auto-resume this workspace's most recently active conversation at TUI
428 /// session start (Step 17.7, issue #246). "Most recently active" means
429 /// the highest §6 activity tick — never a wall-clock comparison.
430 ///
431 /// **Default: true.** The off-switch:
432 ///
433 /// ```toml
434 /// [conversations]
435 /// resume = false # always start fresh
436 /// ```
437 ///
438 /// Per-session overrides win over this key either way: `--ephemeral`
439 /// (no persistence at all) and `NEWT_CONVERSATION_ID=<id>` (resume
440 /// exactly that conversation).
441 #[serde(default = "default_conversations_resume")]
442 pub resume: bool,
443}
444
445fn default_conversations_max_per_workspace() -> usize {
446 100
447}
448
449fn default_conversations_resume() -> bool {
450 true
451}
452
453impl Default for ConversationsConfig {
454 fn default() -> Self {
455 Self {
456 max_per_workspace: default_conversations_max_per_workspace(),
457 resume: default_conversations_resume(),
458 }
459 }
460}
461
462// ---------------------------------------------------------------------------
463// Skill search path
464// ---------------------------------------------------------------------------
465
466/// The skill discovery **search path**: an ordered list of directories newt
467/// scans for agentskills.io-format `SKILL.md` folders.
468///
469/// A skill is the same folder in every harness, so cross-harness use is just a
470/// matter of *pointing newt at the directories* — list `~/.claude/skills`,
471/// `~/.codex/skills`, a project-local `.skills/`, whatever — and their skills
472/// become visible with no copying. The list is open-ended on purpose: there is
473/// no hard-coded knowledge of any particular harness. Earlier entries win on a
474/// name collision.
475///
476/// Example `~/.newt/config.toml`:
477/// ```toml
478/// [skills]
479/// search = ["~/.newt/skills", "~/.claude/skills", "~/.codex/skills"]
480/// ```
481#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
482#[serde(default)]
483pub struct SkillsConfig {
484 /// Ordered directories to scan for skills. Empty → `~/.newt/skills`.
485 /// `~/` is expanded to `$HOME`.
486 #[serde(default, skip_serializing_if = "Vec::is_empty")]
487 pub search: Vec<String>,
488}
489
490// ---------------------------------------------------------------------------
491// Log rotation config
492// ---------------------------------------------------------------------------
493
494/// Rotation policy for `~/.newt/usage.jsonl`.
495///
496/// All limits default to the values shown. Set a field to `0` to disable
497/// that particular limit. Multiple active limits compose — the most
498/// restrictive one wins after each append.
499///
500/// Example `newt.toml`:
501/// ```toml
502/// [logs]
503/// max_sessions = 100 # keep the last 100 turns
504/// max_size_mb = 5 # also cap at 5 MiB
505/// max_age_days = 14 # and drop anything older than 2 weeks
506/// keep_rotated = 2 # keep usage.jsonl.1 and .2 as backup
507/// ```
508#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
509#[serde(default)]
510pub struct LogConfig {
511 /// Keep at most this many JSONL entries (most recent). Default: 7. 0 = no limit.
512 #[serde(default = "default_log_max_sessions")]
513 pub max_sessions: usize,
514
515 /// Rotate when the file exceeds this size in MiB. Default: 0 (no size limit).
516 #[serde(default)]
517 pub max_size_mb: u64,
518
519 /// Drop entries older than this many days. Default: 0 (no age limit).
520 /// Requires a `recorded_at` field in the log entry; entries without it
521 /// are kept.
522 #[serde(default)]
523 pub max_age_days: u64,
524
525 /// How many rotated copies to keep alongside the live log
526 /// (`usage.jsonl.1`, `.2`, …). Default: 3. 0 = overwrite silently.
527 #[serde(default = "default_log_keep_rotated")]
528 pub keep_rotated: usize,
529}
530
531fn default_log_max_sessions() -> usize {
532 7
533}
534
535fn default_log_keep_rotated() -> usize {
536 3
537}
538
539impl Default for LogConfig {
540 fn default() -> Self {
541 Self {
542 max_sessions: default_log_max_sessions(),
543 max_size_mb: 0,
544 max_age_days: 0,
545 keep_rotated: default_log_keep_rotated(),
546 }
547 }
548}
549
550// ---------------------------------------------------------------------------
551// Memory config
552// ---------------------------------------------------------------------------
553
554/// Memory management stored under `[memory]` in `newt.toml`.
555#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
556#[serde(default)]
557pub struct MemoryConfig {
558 /// Which memory provider to activate.
559 #[serde(default)]
560 pub provider: MemoryProviderKind,
561 /// Turns retained by `RollingWindow`. Default: 20.
562 #[serde(default = "default_memory_window")]
563 pub window: usize,
564 /// Explicit context-token budget for `TokenBudget` / `Summarizing` — a
565 /// deliberate user override that wins over everything else (Step 18.2,
566 /// #247). When unset, the budget derives from the empirical capability
567 /// cache (`max_ok_input` else `safe_context` in
568 /// `model-capabilities.json`); the static default
569 /// (`DEFAULT_CONTEXT_TOKENS`, 8,192) applies only when neither exists.
570 #[serde(default, skip_serializing_if = "Option::is_none")]
571 pub context_tokens: Option<u32>,
572
573 /// Explicit path to a soul file (overrides workspace + global resolution).
574 /// Default: auto-resolve from `.newt/soul.md` → `~/.newt/soul.md`.
575 #[serde(default, skip_serializing_if = "Option::is_none")]
576 pub soul_file: Option<String>,
577
578 /// User turns without an organic `save_note` call before the in-band
579 /// memory nudge is appended to the next user message (Step 19.3, #248).
580 /// `0` disables the nudge. Default: 10.
581 #[serde(default = "default_note_nudge_interval")]
582 pub note_nudge_interval: usize,
583
584 /// End-of-conversation note extraction (Step 19.4, #248): when `true`,
585 /// closing a conversation (`/new` or a clean exit) runs ONE synchronous
586 /// tools-disabled completion that distills at most 3 durable facts into
587 /// NOTES.md through the scanned `save_note` write path. Default: `false`
588 /// — the pass is optional and costs one completion per close.
589 #[serde(default)]
590 pub extract_notes_on_close: bool,
591
592 /// How memory is disclosed to the model (progressive-disclosure memory,
593 /// Workstream A MVP, #319). `Frozen` (the default) is today's behavior
594 /// exactly: NOTES are frozen verbatim into the system prompt and the
595 /// `memory_fetch` tool is not wired. `Index` opts in to the budgeted
596 /// memory INDEX (note titles/ids instead of full bodies) plus the
597 /// `memory_fetch` tool that pulls a body on demand. This is a context-cost
598 /// facet, never an authorization knob.
599 #[serde(default)]
600 pub disclosure: MemoryDisclosure,
601}
602
603/// Memory disclosure mode — the `[memory] disclosure` key (#319).
604#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
605#[serde(rename_all = "snake_case")]
606pub enum MemoryDisclosure {
607 /// Today's behavior: NOTES frozen verbatim into the system prompt, no
608 /// `memory_fetch` tool. The MVP default — inert unless opted in.
609 #[default]
610 Frozen,
611 /// Progressive disclosure: a budgeted memory INDEX in the prompt plus the
612 /// `memory_fetch` tool to pull bodies on demand.
613 Index,
614}
615
616/// Prompt richness — the `[tui] footer` key. Selects the *default* prompt
617/// template when `[tui] prompt` is unset; an explicit `[tui] prompt` always
618/// wins. The rich default folds a timestamp + status into the prompt line
619/// itself (`[<ts> · <model> · <ws> · <mode> ] ❯ `), so the input surface floats
620/// it at the bottom while idle (like cargo's progress line) and it doubles as a
621/// greppable per-turn log marker — no region, no cursor games.
622#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
623#[serde(rename_all = "snake_case")]
624pub enum FooterMode {
625 /// Rich default prompt on a TTY, plain `\w $ ` otherwise (the default).
626 /// The amphibious choice: decorated on a human terminal, bare in pipes /
627 /// `newt worker` / the wyvern deep-cut.
628 #[default]
629 Auto,
630 /// Always use the rich default prompt (even off a TTY — screenshots, tests).
631 On,
632 /// Always use the plain bare prompt. Equivalent to `--plain`.
633 Off,
634}
635
636/// Color / theme mode — the `[tui] color` key and the `--color` CLI flag
637/// (issue #527). Selects whether — and eventually how — ANSI color is emitted
638/// for the interactive prompt and chat surface. The default is `auto`: color on
639/// a TTY, none in pipes / under `NO_COLOR` / `TERM=dumb`.
640///
641/// `dark`/`light`/`inverted`/`minimal` are accepted and parse today; their
642/// palettes are initial mappings (currently the chromatic default) tuned in a
643/// later pass. The terminal-aware *resolution* lives in the TUI layer — newt-core
644/// has no business probing the terminal — so this enum only exposes the pure
645/// pieces ([`from_keyword`](Self::from_keyword) / [`keyword`](Self::keyword) /
646/// [`forced`](Self::forced) / [`is_mono`](Self::is_mono)).
647#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
648#[serde(rename_all = "lowercase")]
649pub enum ColorMode {
650 /// Color on a TTY; none off one or under `NO_COLOR`/`TERM=dumb` (default).
651 #[default]
652 Auto,
653 /// Always emit color — even off a TTY (screenshots, captured logs). An
654 /// explicit `--color=always` also overrides `NO_COLOR` (documented deviation).
655 Always,
656 /// Never emit color.
657 Never,
658 /// Reduced color: structure only, no bright accents. (Initial mapping:
659 /// chromatic; tuned later.)
660 Minimal,
661 /// Swapped foreground/background accents for high-contrast terminals.
662 /// (Initial mapping: chromatic; tuned later.)
663 Inverted,
664 /// Palette tuned for a dark background — the current chromatic default.
665 Dark,
666 /// Palette tuned for a light background. (Initial mapping: chromatic; tuned later.)
667 Light,
668 /// Force monochrome — no color, ASCII glyph fallbacks. Equivalent to `--mono`.
669 Mono,
670}
671
672impl ColorMode {
673 /// Parse a CLI/config keyword (case-insensitive) into a mode. `on`/`off` are
674 /// accepted as aliases of `always`/`never`; `monochrome` aliases `mono`.
675 pub fn from_keyword(s: &str) -> Option<Self> {
676 match s.trim().to_ascii_lowercase().as_str() {
677 "auto" => Some(Self::Auto),
678 "always" | "on" => Some(Self::Always),
679 "never" | "off" => Some(Self::Never),
680 "minimal" => Some(Self::Minimal),
681 "inverted" => Some(Self::Inverted),
682 "dark" => Some(Self::Dark),
683 "light" => Some(Self::Light),
684 "mono" | "monochrome" => Some(Self::Mono),
685 _ => None,
686 }
687 }
688
689 /// The canonical lowercase keyword for this mode (round-trips `from_keyword`
690 /// and matches the serde representation).
691 pub fn keyword(self) -> &'static str {
692 match self {
693 Self::Auto => "auto",
694 Self::Always => "always",
695 Self::Never => "never",
696 Self::Minimal => "minimal",
697 Self::Inverted => "inverted",
698 Self::Dark => "dark",
699 Self::Light => "light",
700 Self::Mono => "mono",
701 }
702 }
703
704 /// Whether this mode forces a color decision regardless of the terminal:
705 /// `Some(true)` = force color on, `Some(false)` = force off, `None` = defer
706 /// to terminal detection (`Auto`).
707 pub fn forced(self) -> Option<bool> {
708 match self {
709 Self::Always | Self::Minimal | Self::Inverted | Self::Dark | Self::Light => Some(true),
710 Self::Never | Self::Mono => Some(false),
711 Self::Auto => None,
712 }
713 }
714
715 /// Whether color is fully disabled in monochrome form. `Mono` additionally
716 /// signals ASCII-glyph fallbacks (`>` for `❯`) to callers; `Never` just
717 /// drops color.
718 pub fn is_mono(self) -> bool {
719 matches!(self, Self::Mono)
720 }
721}
722
723/// Markdown rendering mode — the `[tui] markdown` key and the `/markdown`
724/// command (Step 25.4, #568). `Auto` renders Markdown whenever color is active;
725/// `On`/`Off` force the choice (`On` still needs color to emit ANSI). The
726/// effective decision is `mode.forced().unwrap_or(color_on) && color_on`.
727#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
728#[serde(rename_all = "lowercase")]
729pub enum MarkdownMode {
730 /// Render Markdown whenever color is active (default).
731 #[default]
732 Auto,
733 /// Force Markdown rendering on (still gated by color support).
734 On,
735 /// Disable Markdown rendering — stream raw text.
736 Off,
737}
738
739impl MarkdownMode {
740 /// Parse a CLI/config/command keyword (case-insensitive). `always`/`never`
741 /// alias `on`/`off`.
742 pub fn from_keyword(s: &str) -> Option<Self> {
743 match s.trim().to_ascii_lowercase().as_str() {
744 "auto" => Some(Self::Auto),
745 "on" | "always" => Some(Self::On),
746 "off" | "never" => Some(Self::Off),
747 _ => None,
748 }
749 }
750
751 /// The canonical lowercase keyword (round-trips `from_keyword` + serde).
752 pub fn keyword(self) -> &'static str {
753 match self {
754 Self::Auto => "auto",
755 Self::On => "on",
756 Self::Off => "off",
757 }
758 }
759
760 /// `Some(true)`/`Some(false)` force the decision; `None` (`Auto`) defers to
761 /// color detection.
762 pub fn forced(self) -> Option<bool> {
763 match self {
764 Self::On => Some(true),
765 Self::Off => Some(false),
766 Self::Auto => None,
767 }
768 }
769}
770
771/// Context-management strategy — the `[context] manager` key and the
772/// `/context manager <name>` command (Step 24.8, #559). `standard` is the
773/// current prune → summary → static-marker pipeline. `progressive` and
774/// `distributed` are the retrievable-card managers **owned by #546** and not
775/// yet available — selecting them reports that and stays on `standard`.
776#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
777#[serde(rename_all = "lowercase")]
778pub enum ContextManager {
779 /// Prune → summarize → static-marker (today's behavior). The only one
780 /// implemented; the selector seam for the others.
781 #[default]
782 Standard,
783 /// Leave a lookup marker; retrieve cards on demand (ephemeral → local DB).
784 /// Owned by #546 — not yet available.
785 Progressive,
786 /// Agent-mesh-shared card store across a swarm. Owned by #546 — not yet
787 /// available.
788 Distributed,
789}
790
791impl ContextManager {
792 /// Parse a CLI/config/command keyword (case-insensitive).
793 pub fn from_keyword(s: &str) -> Option<Self> {
794 match s.trim().to_ascii_lowercase().as_str() {
795 "standard" => Some(Self::Standard),
796 "progressive" => Some(Self::Progressive),
797 "distributed" => Some(Self::Distributed),
798 _ => None,
799 }
800 }
801
802 /// The canonical lowercase keyword (round-trips `from_keyword` + serde).
803 pub fn keyword(self) -> &'static str {
804 match self {
805 Self::Standard => "standard",
806 Self::Progressive => "progressive",
807 Self::Distributed => "distributed",
808 }
809 }
810
811 /// Whether this manager is implemented. Only `standard` today; the others
812 /// are owned by #546 (the selector reports "not yet available").
813 pub fn available(self) -> bool {
814 matches!(self, Self::Standard)
815 }
816
817 /// The default feature bundle this preset turns on (Phase 26, #588). A
818 /// preset is a named bundle of composable [`ContextFeature`]s; config and
819 /// `/context feature` overrides layer on top (see [`ContextFeatures`]).
820 /// Every preset currently resolves to the all-off baseline (today's
821 /// `standard` behavior) because no composable feature is implemented yet;
822 /// presets gain features as 26.3–26.6 land.
823 pub fn base_features(self) -> ContextFeatureSet {
824 ContextFeatureSet::default()
825 }
826}
827
828/// A composable context-management feature (Phase 26, #588) — an independent
829/// on/off technique under `[context.features]` and the `/context feature <name>
830/// on|off` command. None are implemented yet (`available()` is false for all);
831/// they land in 26.3–26.6 and report "not yet available" until then (same
832/// pattern as the `ContextManager` presets under #546).
833#[derive(Debug, Clone, Copy, PartialEq, Eq)]
834pub enum ContextFeature {
835 /// Cap oversized tool outputs; spill the full payload to a re-readable store (#584).
836 ToolOffload,
837 /// Structured `<state>` store mutated via tools, kept out of the log (#583).
838 Scratchpad,
839 /// Structure-aligned retrieval of repo evidence (#582).
840 Semantic,
841 /// Provenance-preserving compaction with retrievable handles (#584).
842 Provenance,
843 /// Write-gated cross-task experience memory (#585).
844 Experiential,
845 /// Per-step compiled context view instead of a rolling buffer (#586).
846 Scheduled,
847}
848
849impl ContextFeature {
850 /// Every feature, in display order.
851 pub const ALL: [Self; 6] = [
852 Self::ToolOffload,
853 Self::Scratchpad,
854 Self::Semantic,
855 Self::Provenance,
856 Self::Experiential,
857 Self::Scheduled,
858 ];
859
860 /// Parse a keyword (case-insensitive; `-`/`_` interchangeable; short aliases).
861 pub fn from_keyword(s: &str) -> Option<Self> {
862 match s.trim().to_ascii_lowercase().replace('-', "_").as_str() {
863 "tool_offload" | "tooloffload" | "offload" => Some(Self::ToolOffload),
864 "scratchpad" | "state" => Some(Self::Scratchpad),
865 "semantic" | "retrieval" => Some(Self::Semantic),
866 "provenance" | "handles" => Some(Self::Provenance),
867 "experiential" | "experience" => Some(Self::Experiential),
868 "scheduled" | "compiled" => Some(Self::Scheduled),
869 _ => None,
870 }
871 }
872
873 /// The canonical lowercase keyword (matches the `[context.features]` key).
874 pub fn keyword(self) -> &'static str {
875 match self {
876 Self::ToolOffload => "tool_offload",
877 Self::Scratchpad => "scratchpad",
878 Self::Semantic => "semantic",
879 Self::Provenance => "provenance",
880 Self::Experiential => "experiential",
881 Self::Scheduled => "scheduled",
882 }
883 }
884
885 /// Whether this feature is implemented yet. Flips true per feature as it
886 /// lands (26.3–26.6). `tool_offload` 26.3 (#584); `scratchpad` 26.4 (#583);
887 /// `semantic` 26.5 (#582); `experiential` 26.6a (#585); `scheduled` 26.6b
888 /// (#586). Only `provenance` (#584, a later compaction-handle feature) is
889 /// still pending.
890 pub fn available(self) -> bool {
891 match self {
892 Self::ToolOffload
893 | Self::Scratchpad
894 | Self::Semantic
895 | Self::Experiential
896 | Self::Scheduled => true,
897 Self::Provenance => false,
898 }
899 }
900
901 /// The tracking issue for this feature (cited in "not yet available").
902 pub fn issue(self) -> u32 {
903 match self {
904 Self::ToolOffload | Self::Provenance => 584,
905 Self::Scratchpad => 583,
906 Self::Semantic => 582,
907 Self::Experiential => 585,
908 Self::Scheduled => 586,
909 }
910 }
911}
912
913/// The resolved on/off state of every context feature (Phase 26, #588) — the
914/// effective set after a `manager` preset's defaults and config/session
915/// overrides are applied.
916#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
917pub struct ContextFeatureSet {
918 pub tool_offload: bool,
919 pub scratchpad: bool,
920 pub semantic: bool,
921 pub provenance: bool,
922 pub experiential: bool,
923 pub scheduled: bool,
924}
925
926impl ContextFeatureSet {
927 /// Read one feature's resolved state.
928 pub fn get(&self, f: ContextFeature) -> bool {
929 match f {
930 ContextFeature::ToolOffload => self.tool_offload,
931 ContextFeature::Scratchpad => self.scratchpad,
932 ContextFeature::Semantic => self.semantic,
933 ContextFeature::Provenance => self.provenance,
934 ContextFeature::Experiential => self.experiential,
935 ContextFeature::Scheduled => self.scheduled,
936 }
937 }
938
939 /// Set one feature's resolved state.
940 pub fn set(&mut self, f: ContextFeature, on: bool) {
941 match f {
942 ContextFeature::ToolOffload => self.tool_offload = on,
943 ContextFeature::Scratchpad => self.scratchpad = on,
944 ContextFeature::Semantic => self.semantic = on,
945 ContextFeature::Provenance => self.provenance = on,
946 ContextFeature::Experiential => self.experiential = on,
947 ContextFeature::Scheduled => self.scheduled = on,
948 }
949 }
950
951 /// The features currently on, in display order.
952 pub fn enabled(self) -> Vec<ContextFeature> {
953 ContextFeature::ALL
954 .into_iter()
955 .filter(|&f| self.get(f))
956 .collect()
957 }
958
959 /// The base feature set *before* `[context.features]` / session overrides:
960 /// the `manager` preset's bundle, with `tool_offload` defaulted ON because
961 /// it is local, deterministic spill storage and should not depend on
962 /// semantic embedding availability. Local-assist features (`scratchpad`,
963 /// `semantic`, and `scheduled`) additionally default ON for local
964 /// (`BackendKind::Ollama`) backends. A weak local model needs the `<plan>` /
965 /// `<state>` ledger to carry a checklist across tool-call rounds instead of
966 /// re-deriving state each round (Step 27.4), and semantic retrieval should
967 /// be ready without a live toggle once an embedder is configured. Explicit
968 /// overrides still win — they layer on top via [`ContextFeatures::apply_to`].
969 ///
970 /// Note: a *local* vLLM / llama.cpp server reports as `Openai` (that's the
971 /// wire protocol, not the host), so those users opt in via
972 /// `[context.features]` rather than getting it by default.
973 pub fn base_for(manager: ContextManager, kind: BackendKind) -> Self {
974 let mut base = manager.base_features();
975 base.tool_offload = true;
976 if matches!(kind, BackendKind::Ollama) {
977 base.scratchpad = true;
978 base.semantic = true;
979 base.scheduled = true;
980 }
981 base
982 }
983}
984
985/// Per-feature overrides under `[context.features]` (config) and `/context
986/// feature` (session) (Phase 26, #588). `None` inherits the `manager` preset's
987/// default; `Some(b)` forces the feature on/off.
988#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
989pub struct ContextFeatures {
990 #[serde(default, skip_serializing_if = "Option::is_none")]
991 pub tool_offload: Option<bool>,
992 #[serde(default, skip_serializing_if = "Option::is_none")]
993 pub scratchpad: Option<bool>,
994 #[serde(default, skip_serializing_if = "Option::is_none")]
995 pub semantic: Option<bool>,
996 #[serde(default, skip_serializing_if = "Option::is_none")]
997 pub provenance: Option<bool>,
998 #[serde(default, skip_serializing_if = "Option::is_none")]
999 pub experiential: Option<bool>,
1000 #[serde(default, skip_serializing_if = "Option::is_none")]
1001 pub scheduled: Option<bool>,
1002}
1003
1004impl ContextFeatures {
1005 /// Read one feature's override (`None` = inherit the preset).
1006 pub fn get(&self, f: ContextFeature) -> Option<bool> {
1007 match f {
1008 ContextFeature::ToolOffload => self.tool_offload,
1009 ContextFeature::Scratchpad => self.scratchpad,
1010 ContextFeature::Semantic => self.semantic,
1011 ContextFeature::Provenance => self.provenance,
1012 ContextFeature::Experiential => self.experiential,
1013 ContextFeature::Scheduled => self.scheduled,
1014 }
1015 }
1016
1017 /// Set one feature's override.
1018 pub fn set(&mut self, f: ContextFeature, v: Option<bool>) {
1019 match f {
1020 ContextFeature::ToolOffload => self.tool_offload = v,
1021 ContextFeature::Scratchpad => self.scratchpad = v,
1022 ContextFeature::Semantic => self.semantic = v,
1023 ContextFeature::Provenance => self.provenance = v,
1024 ContextFeature::Experiential => self.experiential = v,
1025 ContextFeature::Scheduled => self.scheduled = v,
1026 }
1027 }
1028
1029 /// Layer these overrides onto a base set (a preset's defaults).
1030 pub fn apply_to(&self, mut base: ContextFeatureSet) -> ContextFeatureSet {
1031 for f in ContextFeature::ALL {
1032 if let Some(v) = self.get(f) {
1033 base.set(f, v);
1034 }
1035 }
1036 base
1037 }
1038}
1039
1040/// `[context]` config section (Step 24.8, #559; features added Phase 26, #588).
1041#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1042pub struct ContextConfig {
1043 /// Context-management strategy preset. Default `standard`.
1044 #[serde(default)]
1045 pub manager: ContextManager,
1046
1047 /// Per-feature overrides under `[context.features]` — each inherits the
1048 /// `manager` preset's default unless explicitly set (Phase 26, #588).
1049 #[serde(default)]
1050 pub features: ContextFeatures,
1051
1052 /// `[context.semantic]` — settings for the `semantic` RAG feature (Step
1053 /// 26.5, #582).
1054 #[serde(default)]
1055 pub semantic: SemanticConfig,
1056
1057 /// `[context.estimation]` — the cheap token-estimation heuristic
1058 /// (`chars_per_token`, default 4). Threaded through the estimators; the
1059 /// per-model calibration ratio scales the result on top.
1060 #[serde(default)]
1061 pub estimation: crate::tokens::TokenEstimation,
1062
1063 /// Floor (chars) for the whole-middle summarizer input cap. The cap is
1064 /// normally the compression budget converted to chars, but a tight budget
1065 /// would starve the summarizer of material — never give it less than this.
1066 #[serde(default = "default_summary_input_cap_floor_chars")]
1067 pub summary_input_cap_floor_chars: usize,
1068
1069 /// `[context.api_surface]` — the workspace-API-surface knowledge_base
1070 /// technique + its pluggable language packs (#669).
1071 #[serde(default)]
1072 pub api_surface: ApiSurfaceConfig,
1073}
1074
1075fn default_summary_input_cap_floor_chars() -> usize {
1076 8_192
1077}
1078
1079impl Default for ContextConfig {
1080 fn default() -> Self {
1081 Self {
1082 manager: ContextManager::default(),
1083 features: ContextFeatures::default(),
1084 semantic: SemanticConfig::default(),
1085 estimation: crate::tokens::TokenEstimation::default(),
1086 summary_input_cap_floor_chars: default_summary_input_cap_floor_chars(),
1087 api_surface: ApiSurfaceConfig::default(),
1088 }
1089 }
1090}
1091
1092/// One symbol-extraction rule in a [`LanguagePack`]: a regex over a single source
1093/// line whose **first capture group is the public symbol's name**, plus a
1094/// free-form kind label. Free-form so a pack is not locked to one language's
1095/// vocabulary (`fn`/`struct` for Rust, `class`/`def` for Python, `func` for Go…).
1096#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1097pub struct SymbolRule {
1098 /// Regex; capture group 1 = the symbol name.
1099 pub pattern: String,
1100 /// Kind shown in the surface (e.g. `"fn"`, `"struct"`, `"class"`, `"func"`).
1101 pub kind: String,
1102}
1103
1104/// A **language pack** for the workspace API surface (#669): how to recognize a
1105/// language's files, which files expose its public API, and how to extract its
1106/// public symbols — entirely as DATA, so a new language is config, not code.
1107///
1108/// Built-in packs cover Rust, Python, Bash, and C/C++. A project ships more by
1109/// dropping a `<name>.toml` into `~/.newt/language-packs/` (global) or
1110/// `.newt/language-packs/` (project-local), or inline under
1111/// `[[context.api_surface.language_packs]]`. Packs merge **by `name`** (a custom
1112/// pack with a built-in's name replaces it), so anyone can add Java, Ruby, Swift,
1113/// Objective-C, … without touching the binary.
1114#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1115pub struct LanguagePack {
1116 /// Stable id (a config pack with a built-in's name replaces that built-in).
1117 pub name: String,
1118 /// File extensions this pack claims, no dot — `["rs"]`, `["h", "hpp", "cpp"]`.
1119 pub extensions: Vec<String>,
1120 /// Entry-point filename globs (the public-API files, listed first in the
1121 /// surface). Supported globs: exact (`lib.rs`), suffix (`*.h`), or all (`*`).
1122 /// Empty ⇒ no file is prioritized for this pack.
1123 #[serde(default)]
1124 pub entry_points: Vec<String>,
1125 /// Public-symbol extraction rules, applied per source line.
1126 pub symbols: Vec<SymbolRule>,
1127}
1128
1129/// `[context.api_surface]` — the workspace-API-surface knowledge_base technique.
1130#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1131pub struct ApiSurfaceConfig {
1132 /// Inline language packs, merged by `name` over the built-ins and the
1133 /// drop-in directories (the highest-precedence layer).
1134 #[serde(default)]
1135 pub language_packs: Vec<LanguagePack>,
1136 /// Char ceiling on the injected surface block (it rides every turn).
1137 #[serde(default = "default_api_surface_max_block_chars")]
1138 pub max_block_chars: usize,
1139 /// Per-file symbol cap, so one huge file can't crowd out the surface.
1140 #[serde(default = "default_api_surface_max_symbols_per_file")]
1141 pub max_symbols_per_file: usize,
1142}
1143
1144impl Default for ApiSurfaceConfig {
1145 fn default() -> Self {
1146 Self {
1147 language_packs: Vec::new(),
1148 max_block_chars: default_api_surface_max_block_chars(),
1149 max_symbols_per_file: default_api_surface_max_symbols_per_file(),
1150 }
1151 }
1152}
1153
1154fn default_api_surface_max_block_chars() -> usize {
1155 3_000
1156}
1157
1158fn default_api_surface_max_symbols_per_file() -> usize {
1159 12
1160}
1161
1162/// `[context.semantic]` — the embedding RAG-for-code feature's settings (Step
1163/// 26.5.4, #582).
1164#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1165pub struct SemanticConfig {
1166 /// Embedding model used to index the repo + embed queries. Default
1167 /// `nomic-embed-text` (the HTTP path). The model must exist on the embeddings
1168 /// endpoint (see `embeddings_endpoint`); when it can't be reached the feature
1169 /// follows `on_embed_failure`.
1170 ///
1171 /// For the **embedded backend** (`embeddings_api = "embedded"`, #720) this is
1172 /// only a label — the model is loaded from `embedding_model_path` — and it
1173 /// should name a **candle-clean standard-BERT** model (e.g.
1174 /// `bge-small-en-v1.5`), NOT `nomic-embed-text`, which candle 0.8 cannot load.
1175 #[serde(default = "default_embedding_model")]
1176 pub embedding_model: String,
1177 /// Local model **directory** for the embedded embedder (#720): a
1178 /// candle-clean standard-BERT model dir holding
1179 /// `config.json` + `tokenizer.json` + `model.safetensors` (e.g. a fetched
1180 /// `BAAI/bge-small-en-v1.5`). `None` (default) ⇒ the embedded path can't
1181 /// load and reports a clear error. When `embeddings_api` and
1182 /// `embeddings_endpoint` are unset, a configured path selects embedded
1183 /// embeddings automatically. Ignored by explicit HTTP embeddings targets.
1184 /// Mirrors the summarizer's `model_path`.
1185 #[serde(default, skip_serializing_if = "Option::is_none")]
1186 pub embedding_model_path: Option<String>,
1187 /// How many code chunks to retrieve per turn. Default 5.
1188 #[serde(default = "default_semantic_top_k")]
1189 pub top_k: usize,
1190 /// Dedicated endpoint that serves embeddings (e.g. an Ollama
1191 /// `http://host:11434`). `None` (default) leaves semantic retrieval on the
1192 /// embedded path unless `embeddings_api` explicitly selects an HTTP protocol.
1193 /// Set this to a real embeddings host when remote/vector-server embeddings
1194 /// are a deliberate performance choice.
1195 #[serde(default, skip_serializing_if = "Option::is_none")]
1196 pub embeddings_endpoint: Option<String>,
1197 /// Wire protocol of `embeddings_endpoint` — `ollama` (`/api/embeddings`) or
1198 /// `openai` (`/v1/embeddings`). `embedded` selects the in-process embedder.
1199 /// `None` (default) selects embedded embeddings when `embeddings_endpoint`
1200 /// is also unset; with an explicit endpoint, `None` assumes `ollama`.
1201 #[serde(default, skip_serializing_if = "Option::is_none")]
1202 pub embeddings_api: Option<BackendKind>,
1203 /// What to do when embedding fails structurally (wrong endpoint / model
1204 /// absent): `disable` (default) stops indexing after the first failure with
1205 /// one actionable message; `warn` logs per-chunk and keeps trying.
1206 #[serde(default)]
1207 pub on_embed_failure: OnEmbedFailure,
1208}
1209
1210impl Default for SemanticConfig {
1211 fn default() -> Self {
1212 Self {
1213 embedding_model: default_embedding_model(),
1214 embedding_model_path: None,
1215 top_k: default_semantic_top_k(),
1216 embeddings_endpoint: None,
1217 embeddings_api: None,
1218 on_embed_failure: OnEmbedFailure::default(),
1219 }
1220 }
1221}
1222
1223/// Policy when an embedding request fails structurally during indexing.
1224#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
1225#[serde(rename_all = "lowercase")]
1226pub enum OnEmbedFailure {
1227 /// Stop indexing on the first failure and log one actionable error — a
1228 /// structural failure (wrong endpoint / missing model) is total, not
1229 /// transient, so degrading per-chunk just produces an empty index quietly.
1230 #[default]
1231 Disable,
1232 /// Log every failed chunk and keep going (the historical behaviour).
1233 Warn,
1234}
1235
1236fn default_embedding_model() -> String {
1237 "nomic-embed-text".to_string()
1238}
1239
1240fn default_semantic_top_k() -> usize {
1241 5
1242}
1243
1244/// How a thinking model's streamed reasoning is surfaced — the `[tui] thinking`
1245/// key. Newt strips `<think>…</think>` from the reply regardless (#385); this
1246/// only controls the live human display.
1247#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
1248#[serde(rename_all = "snake_case")]
1249pub enum ThinkingMode {
1250 /// Cargo-style: reasoning streams as dim scrolled lines (kept in
1251 /// scrollback) with an ephemeral spinner line pinned at the bottom. The
1252 /// default — but only on a TTY; a pipe / `newt worker` shows nothing.
1253 #[default]
1254 Stream,
1255 /// No reasoning display at all (the answer still streams normally).
1256 Off,
1257}
1258
1259fn default_memory_window() -> usize {
1260 20
1261}
1262
1263fn default_note_nudge_interval() -> usize {
1264 10
1265}
1266
1267impl Default for MemoryConfig {
1268 fn default() -> Self {
1269 Self {
1270 provider: MemoryProviderKind::RollingWindow,
1271 window: 20,
1272 context_tokens: None,
1273 soul_file: None,
1274 note_nudge_interval: 10,
1275 extract_notes_on_close: false,
1276 disclosure: MemoryDisclosure::Frozen,
1277 }
1278 }
1279}
1280
1281/// Which built-in memory strategy to use.
1282#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
1283#[serde(rename_all = "snake_case")]
1284pub enum MemoryProviderKind {
1285 #[default]
1286 RollingWindow,
1287 TokenBudget,
1288 Summarizing,
1289}
1290
1291// ---------------------------------------------------------------------------
1292// Project-instruction (AGENTS.md / CLAUDE.md) config
1293// ---------------------------------------------------------------------------
1294
1295/// Project-instruction loading stored under `[agents]` in `newt.toml`.
1296///
1297/// When enabled (the default), newt reads `AGENTS.md` / `CLAUDE.md` from the
1298/// workspace and injects them into the agent's system prompt.
1299#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1300#[serde(default)]
1301pub struct AgentsConfig {
1302 /// Whether to load project instructions into the system prompt. Default: true.
1303 pub enabled: bool,
1304 /// Directory to search for `AGENTS.md` / `CLAUDE.md`, or a specific
1305 /// instructions file. Relative paths are resolved against the workspace.
1306 /// Default: the workspace root.
1307 #[serde(default, skip_serializing_if = "Option::is_none")]
1308 pub path: Option<String>,
1309}
1310
1311impl Default for AgentsConfig {
1312 fn default() -> Self {
1313 Self {
1314 enabled: true,
1315 path: None,
1316 }
1317 }
1318}
1319
1320// ---------------------------------------------------------------------------
1321// Tools config
1322// ---------------------------------------------------------------------------
1323
1324/// Tool-execution behaviour stored under `[tools]` in `newt.toml` (#726).
1325///
1326/// Tool-output knobs: the **token budget** that caps every tool's model-facing
1327/// output, plus the head/tail split for oversized shell output. This bounds
1328/// what a single tool result can add to the context window — applied to both
1329/// `read_file` (its char backstop) and `run_command` (its shell envelope) — so a
1330/// verbose command or a huge file can't saturate a small local model's window
1331/// and abandon the task. Mirrors Codex's `exec_command.max_output_tokens`.
1332/// Distinct from `[tui] tool_output_lines`, which caps the on-screen DISPLAY by
1333/// lines.
1334#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1335#[serde(default)]
1336pub struct ToolsConfig {
1337 /// Maximum tokens of model-facing output any single tool may return before
1338 /// it is truncated (with a marker steering the model to a narrower command /
1339 /// a paginated read). Default: 10000. `0` disables the cap. Tokens are
1340 /// estimated with the shared chars/token heuristic (see [`crate::tokens`]).
1341 #[serde(default = "default_max_output_tokens")]
1342 pub max_output_tokens: usize,
1343
1344 /// Tokens reserved for the head of an oversized `run_command` result. The
1345 /// remaining budget is spent on the tail, so failures and summaries at the
1346 /// end survive by default. `0` is pure-tail; values greater than
1347 /// `max_output_tokens` are clamped to pure-head.
1348 #[serde(default = "default_output_head_tokens")]
1349 pub output_head_tokens: usize,
1350}
1351
1352impl Default for ToolsConfig {
1353 fn default() -> Self {
1354 Self {
1355 max_output_tokens: default_max_output_tokens(),
1356 output_head_tokens: default_output_head_tokens(),
1357 }
1358 }
1359}
1360
1361fn default_max_output_tokens() -> usize {
1362 10_000
1363}
1364
1365fn default_output_head_tokens() -> usize {
1366 1_500
1367}
1368
1369// ---------------------------------------------------------------------------
1370// TUI config
1371// ---------------------------------------------------------------------------
1372
1373/// TUI appearance preferences stored under `[tui]` in `newt.toml`.
1374#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1375#[serde(default)]
1376pub struct TuiConfig {
1377 /// Whether to show "newt" / "you" labels before the carets.
1378 pub chat_style: ChatStyle,
1379
1380 /// PS1-style prompt template.
1381 ///
1382 /// Tokens: `\w` workspace basename, `\W` full path, `\h` hostname,
1383 /// `\v` newt version. Default: `"\\w $ "` (compact) / `"you $ "` (verbose).
1384 #[serde(default, skip_serializing_if = "Option::is_none")]
1385 pub prompt: Option<String>,
1386
1387 /// Skip the full-screen ANSI art splash and show a compact header instead.
1388 /// Equivalent to the `--no-splash` CLI flag.
1389 #[serde(default)]
1390 pub no_splash: bool,
1391
1392 /// Key binding mode for the chat input line.
1393 /// `"emacs"` (default), `"vi"`, or `"nano"`. Also overridable via
1394 /// `NEWT_EDIT_MODE`. (`nano` is emacs-style/modeless — it differs from
1395 /// `emacs` only in label today; the rich-tui surface honors it.)
1396 #[serde(default)]
1397 pub edit_mode: EditMode,
1398
1399 /// Rich-tui (issue #416) input gutter width, in columns. Unset = `auto`
1400 /// (the responsive default: a prompt gutter when it fits under ~1/3 of the
1401 /// width, else a stacked prompt row). `0` turns the gutter off (prompt on
1402 /// its own row, input flush-left); a positive value indents the input that
1403 /// many columns (a value wide enough to hold the prompt renders it inline).
1404 /// No effect on the lean surface.
1405 #[serde(default, skip_serializing_if = "Option::is_none")]
1406 pub gutter: Option<u16>,
1407
1408 /// Input-footer mode: the transient multi-line `❯` input block with a
1409 /// status header. `"auto"` (default) shows it on a TTY and degrades to a
1410 /// plain scroller otherwise; `"on"` always shows it; `"off"` never does
1411 /// (the `--plain` CLI flag, or `NEWT_FOOTER=off`).
1412 #[serde(default)]
1413 pub footer: FooterMode,
1414
1415 /// Color / theme mode (issue #527): `"auto"` (default), `"always"`,
1416 /// `"never"`, `"minimal"`, `"inverted"`, `"dark"`, `"light"`, `"mono"`.
1417 /// The `--color` CLI flag and `--mono` override this; `NO_COLOR` /
1418 /// `TERM=dumb` force it off unless an explicit `--color` is given. This is
1419 /// the on/off + theme *mode*; `[tui.colors]` below is the palette.
1420 #[serde(default)]
1421 pub color: ColorMode,
1422
1423 /// How a thinking model's streamed reasoning is shown: `"stream"` (default
1424 /// — dim reasoning + a cargo-style spinner, TTY only) or `"off"`.
1425 #[serde(default)]
1426 pub thinking: ThinkingMode,
1427
1428 /// Maximum lines of tool output shown inline before offering "show all?".
1429 /// Default: 20. Set to 0 to always show everything.
1430 #[serde(default = "default_tool_output_lines")]
1431 pub tool_output_lines: usize,
1432
1433 /// Maximum number of tool-call rounds the model may take within a single
1434 /// turn before the agent forces a final, tools-disabled completion. Each
1435 /// round is one model response that may emit tool calls; once this many
1436 /// rounds have run without a tool-free answer, newt asks the model once
1437 /// more with tools disabled so the user still gets a real (partial)
1438 /// answer instead of a placeholder. Default: 40 (raised from 25 — a
1439 /// modest safety margin alongside `workflow_grace_rounds` and the
1440 /// workflow-classifier delegate hint; genuinely open-ended diagnostic work
1441 /// should reach for `crew`/`team` delegation rather than depend on an
1442 /// unbounded cap here).
1443 #[serde(default = "default_max_tool_rounds")]
1444 pub max_tool_rounds: usize,
1445
1446 /// Additional progress-aware rounds available after `max_tool_rounds` when
1447 /// an active workflow still has incomplete steps and the recent rounds show
1448 /// repair progress or actionable evidence. Default: 5. Set to 0 to make the
1449 /// normal round cap hard again.
1450 #[serde(default = "default_workflow_grace_rounds")]
1451 pub workflow_grace_rounds: usize,
1452
1453 /// Tool-call permission policy for the interactive TUI: which tools the
1454 /// model may invoke and over which targets. This is a *preset that selects
1455 /// an attenuation* — the host (`newt-identity`) lowers it into a signed,
1456 /// attenuation-only capability that enforcement consults. Default:
1457 /// `WorkspaceDev`.
1458 #[serde(default)]
1459 pub permissions: ToolPermissions,
1460
1461 /// Enable per-round agent-loop diagnostics printed to the TUI. Shows each
1462 /// round's content excerpt, tool-call count, token usage, and flags empty
1463 /// model responses before they become silent failures. Also set via the
1464 /// `NEWT_DEBUG=1` environment variable.
1465 #[serde(default, skip_serializing_if = "Option::is_none")]
1466 pub debug: Option<bool>,
1467
1468 /// Enable deep backend/inference diagnostics. Intended for issue reports
1469 /// and compatibility debugging; also set via `NEWT_TRACE=1`.
1470 #[serde(default, skip_serializing_if = "Option::is_none")]
1471 pub trace: Option<bool>,
1472
1473 /// Shell command to run after every successful file write or edit, to
1474 /// give the agent immediate ground-truth feedback on whether its change
1475 /// compiled / passed basic checks. Output is appended to the tool result
1476 /// so the model sees it without needing to ask.
1477 ///
1478 /// Set this per-workspace in `.newt/config.toml` — not globally — because
1479 /// the right command depends on the project's build system:
1480 ///
1481 /// ```toml
1482 /// [tui]
1483 /// build_check_cmd = "cargo check -q --workspace" # Rust
1484 /// # build_check_cmd = "npm run build --silent" # Node
1485 /// # build_check_cmd = "python -m py_compile" # Python
1486 /// ```
1487 ///
1488 /// `None` (default) disables auto-checking — no extra command is run.
1489 #[serde(default, skip_serializing_if = "Option::is_none")]
1490 pub build_check_cmd: Option<String>,
1491
1492 // -----------------------------------------------------------------------
1493 // DGX / inference endpoint resource management
1494 // -----------------------------------------------------------------------
1495 /// Ollama context-window cap sent as `options.num_ctx` on every request.
1496 /// Limits the KV-cache allocation so a large model can't exhaust VRAM
1497 /// mid-session. `None` → newt trusts the model's declared context window
1498 /// from Ollama `/api/show` and sends ~80 % of it as `num_ctx` (see
1499 /// `real_context_discovery`). Set an explicit cap here (e.g. 8192 / 16384)
1500 /// on VRAM-constrained hosts; this always takes precedence over the
1501 /// declared window.
1502 #[serde(default, skip_serializing_if = "Option::is_none")]
1503 pub num_ctx: Option<u32>,
1504
1505 /// Opt into **empirical** context-window discovery (the conserve-for-tiny-
1506 /// hardware mode). When `false`/unset (the default), newt **trusts the
1507 /// declared** `/api/show` window: `safe_context` tracks ~80 % of it and is
1508 /// raised back to it each session, so a model is never permanently capped
1509 /// by a past overflow. When `true`, newt instead keeps the conservative
1510 /// behaviour — bootstrap `safe_context` once, never auto-raise it, and let
1511 /// runtime context-window 400s ratchet it down and persist (for hardware
1512 /// that genuinely can't serve the full declared window). Overridable
1513 /// per-model via `[[model_tuning]] real_context_discovery`.
1514 #[serde(default, skip_serializing_if = "Option::is_none")]
1515 pub real_context_discovery: Option<bool>,
1516
1517 /// TCP connect timeout in seconds for inference requests (default: 5).
1518 /// A fast failure here means the endpoint is down (connection refused),
1519 /// distinguishing it from a slow-but-alive endpoint that needs the full
1520 /// `inference_timeout_secs` to respond. Keep this short.
1521 #[serde(default = "default_connect_timeout_secs")]
1522 pub connect_timeout_secs: u64,
1523
1524 /// Total inference request timeout in seconds (default: 120). This is the
1525 /// wall-clock budget for the model to generate a complete response —
1526 /// large models on a busy DGX may need the full window.
1527 #[serde(default = "default_inference_timeout_secs")]
1528 pub inference_timeout_secs: u64,
1529
1530 /// How long Ollama keeps a model resident in VRAM after the last request,
1531 /// as an Ollama duration string (e.g. `"5m"`, `"0"`, `"-1"`).
1532 /// Default: `"5m"`. Use `"0"` to unload immediately after each turn
1533 /// (maximum headroom for multi-model or multi-agent workloads at the cost
1534 /// of a reload on each turn). Use `"-1"` to keep forever.
1535 #[serde(default = "default_keep_alive")]
1536 pub keep_alive: String,
1537
1538 // Summarizer knobs (timeout / retries / fallback model) moved to the
1539 // dedicated `~/.newt/summarizer.toml` ([`SummarizerConfig`]) in Step 24.10
1540 // (#559), so the summarizer can run on its own backend. Old `[tui]` keys
1541 // (`summarizer_timeout_secs` / `summarizer_retries` / `summarizer_model`)
1542 // are no longer read — `#[serde(default)]` ignores them in stale configs.
1543 /// Markdown rendering of assistant output (Step 25.4, #568). `auto`
1544 /// (default) renders whenever color is active; `on`/`off` force it. The
1545 /// `/markdown [on|off]` command overrides this for the session.
1546 #[serde(default)]
1547 pub markdown: MarkdownMode,
1548
1549 /// Maximum number of messages in the in-progress tool-call message list
1550 /// before the agent trims the middle to prevent context overflow.
1551 /// Default: 40 (≈ 20 tool-call rounds). Set lower on memory-constrained
1552 /// endpoints or when `num_ctx` is small.
1553 #[serde(default = "default_mid_loop_trim_threshold")]
1554 pub mid_loop_trim_threshold: usize,
1555
1556 /// Estimated-token threshold that triggers a mid-loop context trim,
1557 /// independent of `mid_loop_trim_threshold` (which counts *messages*).
1558 /// A single tool round can return a multi-KB file listing or JSON payload
1559 /// that adds hundreds of thousands of tokens in one message — far below the
1560 /// message-count threshold but well past the model's context window. When
1561 /// set, trimming fires as soon as the estimated token count (chars / 4)
1562 /// exceeds this value. `None` disables token-based trimming.
1563 /// Default: `None` (message-count trimming only). See issue #223.
1564 #[serde(default, skip_serializing_if = "Option::is_none")]
1565 pub mid_loop_trim_tokens: Option<usize>,
1566
1567 /// Normalise hyphens to underscores in MCP server names when advertising
1568 /// tool definitions and routing tool calls. Some API proxies (e.g. those
1569 /// that wrap the Anthropic backend) replace hyphens with underscores in
1570 /// tool names; advertising the sanitised form ensures the model's tool
1571 /// calls round-trip back unchanged and routes correctly. Default: `true`.
1572 /// Set to `false` only when every connected MCP server is behind a proxy
1573 /// that preserves hyphens verbatim.
1574 #[serde(default = "default_sanitize_mcp_server_names")]
1575 pub sanitize_mcp_server_names: bool,
1576
1577 /// Hosts (IP or hostname) for which newt may send an MCP OAuth Bearer token
1578 /// over an UNENCRYPTED (non-`https`) connection. Empty by default: a stored
1579 /// Bearer is sent only over `https` or to loopback (`localhost`/`127.0.0.1`/
1580 /// `::1`). newt WARNs on every non-loopback unencrypted MCP connection
1581 /// regardless; an allow-listed host still warns but the token is sent.
1582 /// This is the explicit opt-out of the secure-by-default transport policy —
1583 /// see `docs/decisions/mcp_transport_security.md`.
1584 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1585 pub mcp_allow_insecure_hosts: Vec<String>,
1586
1587 /// Whether the interactive `!` bang-escape is available — typing `! <cmd>`
1588 /// at the prompt runs a host command with the user's own authority (not the
1589 /// agent's OCAP leash). Default: `true`. Set `false` for locked-down or
1590 /// shared deployments where the human at the keyboard should not have an
1591 /// unconfined host shell-out. The model can never invoke `!` either way;
1592 /// this only governs the human. See `docs/decisions/plain_scroller_tui.md`.
1593 #[serde(default = "default_allow_bang_escape")]
1594 pub allow_bang_escape: bool,
1595}
1596
1597fn default_tool_output_lines() -> usize {
1598 20
1599}
1600
1601fn default_max_tool_rounds() -> usize {
1602 40
1603}
1604
1605fn default_workflow_grace_rounds() -> usize {
1606 5
1607}
1608
1609fn default_connect_timeout_secs() -> u64 {
1610 5
1611}
1612
1613fn default_inference_timeout_secs() -> u64 {
1614 120
1615}
1616
1617fn default_keep_alive() -> String {
1618 "5m".to_string()
1619}
1620
1621fn default_summarizer_timeout_secs() -> u64 {
1622 60
1623}
1624
1625fn default_summarizer_retries() -> u32 {
1626 1
1627}
1628
1629fn default_mid_loop_trim_threshold() -> usize {
1630 40
1631}
1632
1633fn default_sanitize_mcp_server_names() -> bool {
1634 true
1635}
1636
1637fn default_allow_bang_escape() -> bool {
1638 true
1639}
1640
1641// ---------------------------------------------------------------------------
1642// Per-model tuning
1643// ---------------------------------------------------------------------------
1644
1645/// Inference-parameter overrides for a specific model name.
1646///
1647/// Matched against the active model by exact string equality. Add entries
1648/// under `[[model_tuning]]` in `~/.newt/config.toml` to pin parameters
1649/// for models whose defaults cause problems (e.g. context overflow).
1650///
1651/// Human-authored entries are never touched by the auto-tuner. Auto-tuned
1652/// entries are appended (not modified) when the harness gains high confidence
1653/// in its empirical measurements.
1654#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1655pub struct ModelTuning {
1656 /// Model name as it appears in Ollama (e.g. `"nemotron3:33b"`).
1657 pub model: String,
1658
1659 /// Ollama `options.num_ctx` — hard cap on KV-cache allocation.
1660 /// Overrides both the global `[tui].num_ctx` and the empirically
1661 /// derived `safe_context` from `model-capabilities.json`.
1662 #[serde(default, skip_serializing_if = "Option::is_none")]
1663 pub num_ctx: Option<u32>,
1664
1665 /// Per-model override of `[tui].real_context_discovery`. `Some(true)` opts
1666 /// this model into empirical conserve-and-ratchet discovery; `Some(false)`
1667 /// forces trust-the-declared-window even when the global default is
1668 /// empirical; `None` inherits the global setting.
1669 #[serde(default, skip_serializing_if = "Option::is_none")]
1670 pub real_context_discovery: Option<bool>,
1671
1672 /// Per-model `mid_loop_trim_threshold` override.
1673 #[serde(default, skip_serializing_if = "Option::is_none")]
1674 pub mid_loop_trim_threshold: Option<usize>,
1675
1676 /// Per-model `mid_loop_trim_tokens` override (estimated-token trim trigger).
1677 #[serde(default, skip_serializing_if = "Option::is_none")]
1678 pub mid_loop_trim_tokens: Option<usize>,
1679
1680 /// Per-model `max_tool_rounds` override.
1681 #[serde(default, skip_serializing_if = "Option::is_none")]
1682 pub max_tool_rounds: Option<usize>,
1683
1684 /// Per-model `[tui].workflow_grace_rounds` override.
1685 #[serde(default, skip_serializing_if = "Option::is_none")]
1686 pub workflow_grace_rounds: Option<usize>,
1687}
1688
1689impl Config {
1690 /// Find the first `[[model_tuning]]` entry whose `model` field matches
1691 /// `name` exactly. Returns `None` when no entry exists.
1692 pub fn find_model_tuning(&self, name: &str) -> Option<&ModelTuning> {
1693 self.model_tuning.iter().find(|t| t.model == name)
1694 }
1695
1696 /// Look up and validate a named profile (`[profiles.<name>]`). The caller
1697 /// selects it via `--profile <name>` / `NEWT_PROFILE`.
1698 ///
1699 /// # Errors
1700 /// `no such profile` when the name is undefined; the validation error when
1701 /// the profile names an unknown technique — a `--profile` that silently did
1702 /// nothing would be a false claim, so both fail loudly.
1703 pub fn resolve_profile(&self, name: &str) -> std::result::Result<&ProfileConfig, String> {
1704 let profile = self.profiles.get(name).ok_or_else(|| {
1705 let known = if self.profiles.is_empty() {
1706 "none defined".to_string()
1707 } else {
1708 self.profiles.keys().cloned().collect::<Vec<_>>().join(", ")
1709 };
1710 format!("no such profile (known: {known})")
1711 })?;
1712 profile.validate()?;
1713 Ok(profile)
1714 }
1715
1716 /// Look up a named bundle (`[bundles.<name>]`).
1717 ///
1718 /// # Errors
1719 /// `no such bundle` when undefined — a `--bundle` that silently did nothing
1720 /// would be a false claim.
1721 pub fn resolve_bundle(&self, name: &str) -> std::result::Result<&BundleConfig, String> {
1722 self.bundles.get(name).ok_or_else(|| {
1723 let known = if self.bundles.is_empty() {
1724 "none defined".to_string()
1725 } else {
1726 self.bundles.keys().cloned().collect::<Vec<_>>().join(", ")
1727 };
1728 format!("no such bundle (known: {known})")
1729 })
1730 }
1731
1732 /// The profile name `bundle` yields for `model`: the longest-prefix `families`
1733 /// match, else `default_profile`. `None` ⇒ the bundle applies no profile here.
1734 #[must_use]
1735 pub fn bundle_profile_for_model<'a>(
1736 &self,
1737 bundle: &'a BundleConfig,
1738 model: &str,
1739 ) -> Option<&'a str> {
1740 bundle
1741 .families
1742 .iter()
1743 .filter(|(prefix, _)| model.starts_with(prefix.as_str()))
1744 .max_by_key(|(prefix, _)| prefix.len()) // longest-prefix-wins
1745 .map(|(_, p)| p.as_str())
1746 .or(bundle.default_profile.as_deref())
1747 }
1748
1749 /// Infer the bundle for `model` from `applies_to` (longest-prefix-wins). Only
1750 /// bundles with a non-empty `applies_to` participate — a use-case bundle (empty
1751 /// `applies_to`) is never auto-inferred, only chosen explicitly via `--bundle`.
1752 #[must_use]
1753 pub fn infer_bundle(&self, model: &str) -> Option<(&str, &BundleConfig)> {
1754 self.bundles
1755 .iter()
1756 .filter_map(|(name, b)| {
1757 b.applies_to
1758 .iter()
1759 .filter(|p| model.starts_with(p.as_str()))
1760 .map(String::len)
1761 .max()
1762 .map(|best| (best, name.as_str(), b))
1763 })
1764 .max_by_key(|(best, _, _)| *best)
1765 .map(|(_, name, b)| (name, b))
1766 }
1767
1768 /// Resolve the active profile from the selectors + the active `model`:
1769 /// `--profile` (explicit) > `--bundle` (its profile for this model) > an
1770 /// inferred bundle (`applies_to`) > `None` (today's no-profile behavior).
1771 /// Returns the profile NAME + how it was chosen (for the banner).
1772 ///
1773 /// # Errors
1774 /// An unknown explicit `--bundle` is a hard error. An unknown explicit
1775 /// `--profile` is left for the caller's [`resolve_profile`](Self::resolve_profile)
1776 /// so the message stays profile-specific.
1777 pub fn pick_active_profile(
1778 &self,
1779 profile_flag: Option<&str>,
1780 bundle_flag: Option<&str>,
1781 model: &str,
1782 ) -> std::result::Result<Option<ProfilePick>, String> {
1783 if let Some(p) = profile_flag.filter(|s| !s.is_empty()) {
1784 return Ok(Some(ProfilePick {
1785 name: p.to_string(),
1786 via: PickVia::Profile,
1787 }));
1788 }
1789 if let Some(b) = bundle_flag.filter(|s| !s.is_empty()) {
1790 let bundle = self.resolve_bundle(b)?;
1791 return Ok(self
1792 .bundle_profile_for_model(bundle, model)
1793 .map(|p| ProfilePick {
1794 name: p.to_string(),
1795 via: PickVia::Bundle(b.to_string()),
1796 }));
1797 }
1798 if let Some((name, bundle)) = self.infer_bundle(model) {
1799 return Ok(self
1800 .bundle_profile_for_model(bundle, model)
1801 .map(|p| ProfilePick {
1802 name: p.to_string(),
1803 via: PickVia::InferredBundle(name.to_string()),
1804 }));
1805 }
1806 Ok(None)
1807 }
1808}
1809
1810/// One named bundle (`[bundles.<name>]`) — the loadable unit of the model support
1811/// kit. It pins which model families it applies to and which profile each resolves
1812/// to, shipping the `[profiles.*]` it references.
1813///
1814/// ```toml
1815/// [bundles.nemotron]
1816/// about = "Support bundle for the nemotron family"
1817/// applies_to = ["nemotron"] # longest-prefix-wins; "nemotron3:33b" matches
1818/// default_profile = "nemotron"
1819/// families = { "nemotron" = "nemotron", "qwen" = "qwen-coder" }
1820/// ```
1821///
1822/// A bundle carries **no authority** — there is deliberately no caveats/preset
1823/// field; it recombines vetted parts, it cannot grant (`docs/design/model-support-kit.md`).
1824#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
1825pub struct BundleConfig {
1826 /// One-line provenance, shown in the startup banner.
1827 #[serde(default, skip_serializing_if = "Option::is_none")]
1828 pub about: Option<String>,
1829 /// Model-id prefixes this bundle auto-applies to (longest-prefix-wins). Empty ⇒
1830 /// a use-case bundle: chosen only via explicit `--bundle`, never auto-inferred.
1831 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1832 pub applies_to: Vec<String>,
1833 /// Profile applied when this bundle is selected and no `families` entry matches.
1834 /// Must name a key in `[profiles.*]`. `None` ⇒ no profile (the light path).
1835 #[serde(default, skip_serializing_if = "Option::is_none")]
1836 pub default_profile: Option<String>,
1837 /// model-family-prefix → profile name (longest-prefix-wins over `default_profile`).
1838 #[serde(default, skip_serializing_if = "std::collections::BTreeMap::is_empty")]
1839 pub families: std::collections::BTreeMap<String, String>,
1840}
1841
1842/// The active-profile selection + how it was chosen (for honest banner output).
1843#[derive(Debug, Clone, PartialEq, Eq)]
1844pub struct ProfilePick {
1845 /// The chosen profile name (to feed [`Config::resolve_profile`]).
1846 pub name: String,
1847 /// Which selector won.
1848 pub via: PickVia,
1849}
1850
1851/// How a [`ProfilePick`] was selected.
1852#[derive(Debug, Clone, PartialEq, Eq)]
1853pub enum PickVia {
1854 /// An explicit `--profile` / `NEWT_PROFILE`.
1855 Profile,
1856 /// An explicit `--bundle <name>`.
1857 Bundle(String),
1858 /// A bundle inferred from the model via `applies_to`.
1859 InferredBundle(String),
1860}
1861
1862/// One named loadout (`[loadouts.<name>]` / `~/.newt/loadouts/<name>.toml`) — the
1863/// top-level composition the user *loads* (`docs/design/loadout-composition.md`).
1864/// Every field is optional and is a **name reference** into the surface that owns
1865/// that axis; the loadout itself stores nothing but the selection + per-axis
1866/// overrides. It carries **no authority** — `settings` cannot widen caveats.
1867///
1868/// ```toml
1869/// [loadouts.dev-nemotron]
1870/// provider = "dgx" # → the catalog/provider card (#387)
1871/// model = "nemotron@deep"
1872/// kit = "nemotron" # → a [bundles.<name>] (the loadable kit unit)
1873/// profile = "nemotron" # → a [profiles.<name>] (optional; the bundle implies it)
1874/// role = "python-developer" # → ~/.newt/personas/<name>.md
1875/// [loadouts.dev-nemotron.settings]
1876/// num_ctx = 24576
1877/// framing = "Ship small, verify."
1878/// ```
1879#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
1880pub struct Loadout {
1881 /// Provider id (→ the catalog/provider card). Resolution is Slice 2.
1882 #[serde(default, skip_serializing_if = "Option::is_none")]
1883 pub provider: Option<String>,
1884 /// Model id, optionally `model@variant`. Resolution is Slice 2.
1885 #[serde(default, skip_serializing_if = "Option::is_none")]
1886 pub model: Option<String>,
1887 /// Bundle name (the loadable kit unit) — must name a `[bundles.<name>]`.
1888 #[serde(default, skip_serializing_if = "Option::is_none")]
1889 pub kit: Option<String>,
1890 /// Profile name — must name a `[profiles.<name>]`. Omitted ⇒ the bundle/model
1891 /// implies it.
1892 #[serde(default, skip_serializing_if = "Option::is_none")]
1893 pub profile: Option<String>,
1894 /// Role/persona name (`~/.newt/personas/<name>.md`). Not validated against the
1895 /// filesystem here — personas are resolved at session start.
1896 #[serde(default, skip_serializing_if = "Option::is_none")]
1897 pub role: Option<String>,
1898 /// Per-axis overrides (parameters / prompt). Never authority.
1899 #[serde(default, skip_serializing_if = "Option::is_none")]
1900 pub settings: Option<LoadoutSettings>,
1901}
1902
1903/// Per-axis overrides a loadout may pin. **No authority axis** — a loadout cannot
1904/// widen caveats (`docs/design/loadout-composition.md` §Authority safety).
1905#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
1906pub struct LoadoutSettings {
1907 /// Parameter axis: KV-cache window override (top of the `ModelTuning` chain).
1908 #[serde(default, skip_serializing_if = "Option::is_none")]
1909 pub num_ctx: Option<u32>,
1910 /// Prompt axis: a one-line system-prompt framing (the `ModeConfig.framing` shape).
1911 #[serde(default, skip_serializing_if = "Option::is_none")]
1912 pub framing: Option<String>,
1913}
1914
1915impl Loadout {
1916 /// Validate the loadout's name references against `cfg`: a named `kit` must be a
1917 /// known bundle, a named `profile` must be a known, valid profile, and a named
1918 /// `provider` must name a `[backends]` entry (Slice 2 — the provider/model axis).
1919 /// A dangling reference is a hard error — a loadout that silently did nothing
1920 /// would be a false claim. The `@variant` half of `model` and `role` are resolved
1921 /// by their own surfaces later and are not checked here.
1922 ///
1923 /// # Errors
1924 /// The first dangling `kit`, `profile`, or `provider` reference, as a message.
1925 pub fn validate(&self, cfg: &Config) -> std::result::Result<(), String> {
1926 if let Some(kit) = &self.kit {
1927 cfg.resolve_bundle(kit)
1928 .map_err(|e| format!("loadout kit '{kit}': {e}"))?;
1929 }
1930 if let Some(profile) = &self.profile {
1931 cfg.resolve_profile(profile)
1932 .map_err(|e| format!("loadout profile '{profile}': {e}"))?;
1933 }
1934 if let Some(provider) = &self.provider {
1935 if !cfg.backends.iter().any(|b| &b.name == provider) {
1936 let known = if cfg.backends.is_empty() {
1937 "none defined".to_string()
1938 } else {
1939 cfg.backends
1940 .iter()
1941 .map(|b| b.name.as_str())
1942 .collect::<Vec<_>>()
1943 .join(", ")
1944 };
1945 return Err(format!(
1946 "loadout provider '{provider}': no [backends] entry named '{provider}' (known: {known})"
1947 ));
1948 }
1949 }
1950 Ok(())
1951 }
1952}
1953
1954/// A named crew (`[crews.<name>]` or `crews/<name>.toml`): a role-specialized
1955/// ensemble over the heterogeneous backend pool. Each role names a `[loadouts.*]`
1956/// (so a crew is a *composition of loadouts* — the canonical example routes the
1957/// planner/triage to frontier models and bulk work to cheap local inference,
1958/// `docs/design/config-scaling-deployment-and-trust.md`). The harness owns the
1959/// control loop (`run_crew`); these fields pin the workers + budgets.
1960///
1961/// ```toml
1962/// [crews.coder]
1963/// planner = "planner" # → [loadouts.planner] (required)
1964/// navigator = "navigator" # → [loadouts.navigator]
1965/// triage = "triage" # → [loadouts.triage]
1966/// loop = "patch-revise" # control program (default)
1967/// [crews.coder.budgets]
1968/// max_attempts = 4
1969/// ```
1970#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
1971pub struct Crew {
1972 /// Planner/editor role — must name a `[loadouts.<name>]`. Required (a crew
1973 /// with no planner can't make edits).
1974 pub planner: String,
1975 /// Repo-navigator role — names a `[loadouts.<name>]`. Optional.
1976 #[serde(default, skip_serializing_if = "Option::is_none")]
1977 pub navigator: Option<String>,
1978 /// Test-triage role — names a `[loadouts.<name>]`. Optional.
1979 #[serde(default, skip_serializing_if = "Option::is_none")]
1980 pub triage: Option<String>,
1981 /// Control program (e.g. `"patch-revise"`). Omitted ⇒ the default loop.
1982 #[serde(default, rename = "loop", skip_serializing_if = "Option::is_none")]
1983 pub loop_program: Option<String>,
1984 /// Per-role dispatch wall-clock bound, seconds (#698). Omitted ⇒ the
1985 /// env/default (`NEWT_ROLE_TIMEOUT_SECS` → 600s). Widen it here for a slow
1986 /// loadout instead of relying on the env var.
1987 #[serde(default, skip_serializing_if = "Option::is_none")]
1988 pub role_timeout_secs: Option<u64>,
1989 /// Verification command override (e.g. `"just check"`). Omitted ⇒ inferred
1990 /// from the repo (justfile → `just check`, Cargo → `cargo test`, …).
1991 #[serde(default, skip_serializing_if = "Option::is_none")]
1992 pub test: Option<String>,
1993 /// Budgets / safety gates for the control loop.
1994 #[serde(default, skip_serializing_if = "Option::is_none")]
1995 pub budgets: Option<CrewBudgets>,
1996}
1997
1998/// `[crew]` dispatch policy (#749 step 2): the operator's structural tightening
1999/// point for crews/teams the overseer fields.
2000///
2001/// A model that fields a crew is the recursion / Confused-Deputy case. Dispatch
2002/// hands each crew `session ⊓ clamp` (the [`crate::Caveats`] meet), so the crew's
2003/// authority is **always `≤ session`** (the overseer cannot escalate by
2004/// dispatching) and **`≤ clamp`** (the operator's bound). With the default
2005/// `clamp = Caveats::top()` the meet is the identity — today's behavior is
2006/// unchanged — while the seam exists for tighter clamps (and the per-subtask
2007/// `team_clamp`, #749 step 8) to plug into.
2008///
2009/// ```toml
2010/// [crew]
2011/// # crews may reach only this host, even if the session's net grant is wider
2012/// [crew.clamp]
2013/// net = { only = ["registry.internal"] }
2014/// ```
2015/// `[plan]` — plan-authoring policy (#819).
2016#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
2017pub struct PlanConfig {
2018 /// `[plan.prune]` — the decompose-prune lexicon override.
2019 #[serde(default)]
2020 pub prune: PlanPruneConfig,
2021}
2022
2023/// `[plan.prune]` — droppable, three-Cs override for the #801/#803 planner-
2024/// decomposition prune. The compiled `ACTION_MARKERS` lexicon is the default;
2025/// this table composes with it (remove first, then additions), so a new
2026/// anti-pattern — or un-marking a verb your domain uses for real work — is
2027/// CONFIG, not code. The prune itself stays grade-neutral either way: it only
2028/// removes no-diff leaves before any authority grant (#803's n=5 A/B measured
2029/// no grade lift; it removes a failure *mechanism*).
2030///
2031/// ```toml
2032/// [plan.prune]
2033/// disabled = false
2034/// add_inspect = ["scrutinize"] # pruned wherever they lead an instruction
2035/// add_gate = ["smoke"] # pruned only when terminal
2036/// remove = ["review"] # e.g. a repo where "Review X" edits docs
2037/// ```
2038#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
2039pub struct PlanPruneConfig {
2040 /// Turn the prune off entirely (the pre-#803 behavior).
2041 #[serde(default)]
2042 pub disabled: bool,
2043 /// Extra leading verbs classified Inspect (case-insensitive).
2044 #[serde(default)]
2045 pub add_inspect: Vec<String>,
2046 /// Extra leading verbs classified Gate (case-insensitive).
2047 #[serde(default)]
2048 pub add_gate: Vec<String>,
2049 /// Verbs to REMOVE from the effective lexicon (builtin or added).
2050 #[serde(default)]
2051 pub remove: Vec<String>,
2052}
2053
2054#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
2055pub struct CrewPolicyConfig {
2056 /// The authority **clamp** dispatched crews are met against
2057 /// (`child = session ⊓ clamp`). Defaults to `Caveats::top()` (identity meet —
2058 /// behavior unchanged). Tighten an axis here to bound every crew below the
2059 /// session ceiling; later steps (#749 step 8) compose a per-subtask clamp on
2060 /// top of this at the same `dispatch` seam.
2061 #[serde(default)]
2062 pub clamp: crate::caveats::Caveats,
2063}
2064
2065/// Budgets + review gates for a crew's control loop (`crew-loadout.md` §budgets).
2066/// Consumed by the front door; an honest cap-exit at `max_attempts` returns
2067/// `NeedsHumanReview`, never a false success.
2068#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
2069pub struct CrewBudgets {
2070 #[serde(default, skip_serializing_if = "Option::is_none")]
2071 pub max_attempts: Option<u32>,
2072 #[serde(default, skip_serializing_if = "Option::is_none")]
2073 pub max_files_touched: Option<u32>,
2074 #[serde(default, skip_serializing_if = "Option::is_none")]
2075 pub max_lines_changed: Option<u32>,
2076 /// Topics that force a human-review pause (e.g. `["auth","crypto","migrations"]`).
2077 #[serde(default, skip_serializing_if = "Vec::is_empty")]
2078 pub require_human_review_on: Vec<String>,
2079}
2080
2081impl Crew {
2082 /// Validate the crew's role references against `cfg`: each named role
2083 /// (`planner`/`navigator`/`triage`) must name a known `[loadouts.<name>]`,
2084 /// and that loadout must itself validate (so a crew transitively checks the
2085 /// whole `crew → loadout → {backend,bundle,profile}` chain). A dangling role
2086 /// is a hard error — a crew that silently dropped a worker would be a false
2087 /// claim.
2088 ///
2089 /// # Errors
2090 /// The first dangling or invalid role reference, as a message.
2091 pub fn validate(&self, cfg: &Config) -> std::result::Result<(), String> {
2092 let check = |label: &str, name: &str| -> std::result::Result<(), String> {
2093 let loadout = cfg.loadouts.get(name).ok_or_else(|| {
2094 let known = if cfg.loadouts.is_empty() {
2095 "none defined".to_string()
2096 } else {
2097 cfg.loadouts.keys().cloned().collect::<Vec<_>>().join(", ")
2098 };
2099 format!(
2100 "crew {label} '{name}': no [loadouts] entry named '{name}' (known: {known})"
2101 )
2102 })?;
2103 loadout
2104 .validate(cfg)
2105 .map_err(|e| format!("crew {label} '{name}': {e}"))
2106 };
2107 check("planner", &self.planner)?;
2108 if let Some(nav) = &self.navigator {
2109 check("navigator", nav)?;
2110 }
2111 if let Some(tri) = &self.triage {
2112 check("triage", tri)?;
2113 }
2114 Ok(())
2115 }
2116}
2117
2118// ---------------------------------------------------------------------------
2119// Tool permissions — preset policies, lowered to attenuated capabilities
2120// ---------------------------------------------------------------------------
2121
2122/// A named tool-permission preset for the TUI tool loop.
2123///
2124/// Each preset selects a [`crate::Caveats`] *policy* via
2125/// [`ToolPermissions::to_caveats`]; the host (`newt-identity`) then lowers that
2126/// policy into a signed, attenuation-only capability for enforcement. A preset
2127/// is a name-based convenience, **not** a capability itself — the unforgeable
2128/// authority is the signed `AgentKey` delegation. `Custom` means the user has
2129/// added commands beyond a canned preset; it carries `WorkspaceDev` authority
2130/// plus those extras (it does **not** grant full access).
2131#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
2132#[serde(rename_all = "snake_case")]
2133pub enum PermissionPreset {
2134 /// Read files and list dirs only; no writes, no commands.
2135 ReadOnly,
2136 /// Read + write within the workspace; no shell commands.
2137 WorkspaceEdit,
2138 /// Read, write workspace, run a conservative set of dev tools.
2139 /// See [`ToolPermissions::to_caveats`] for the exact allowlist.
2140 #[default]
2141 WorkspaceDev,
2142 /// Unrestricted — `Caveats::top()`. `write_file` still prompts y/N.
2143 FullAccess,
2144 /// User has added commands beyond a canned preset; carries `WorkspaceDev`
2145 /// authority plus those `extra_exec` entries — **not** full access.
2146 Custom,
2147}
2148
2149/// Which shell **engine** interprets `run_command` — the ADR 0005 D2 seam. This
2150/// is the *engine* axis (what parses/runs the command line); the *L3 backend*
2151/// axis (Landlock/Seatbelt/AppContainer, the kernel fence) is auto-selected
2152/// per-OS and is **not** chosen here. "Landlock vs brush" is really "the `host`
2153/// engine (guarantee rests entirely on the kernel fence) vs the `brush` engine
2154/// (L2 interceptor confines in-process, with the fence as an added backstop)."
2155#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
2156#[serde(rename_all = "kebab-case")]
2157pub enum ShellEngine {
2158 /// bridle's argv + safe-subset parser: refuses `$(...)`/backticks/dynamic
2159 /// constructs by design and spawns argv directly. Portable default.
2160 #[default]
2161 SafeSubset,
2162 /// The sandboxed-host engine (ADR 0019): real `/bin/sh -c` with the whole
2163 /// process tree in the L3 jail. Full grammar; the guarantee rests entirely
2164 /// on the kernel fence. Refuses a *restricted* `exec`/`net` grant. Needs a
2165 /// host `/bin/sh`. `--full-access` auto-selects this.
2166 Host,
2167 /// The carried brush engine (bash-in-Rust + the L2 `CommandInterceptor`):
2168 /// full grammar, in-process, cross-platform, and the only engine that also
2169 /// confines a *restricted* `exec`/`net` grant. Requires the `brush` build
2170 /// (agent-bridle#20 / Track 2); until that ships, selecting `brush` falls
2171 /// back to `host` with a warning.
2172 Brush,
2173}
2174
2175impl ShellEngine {
2176 /// The canonical config/flag token (`kebab-case`).
2177 #[must_use]
2178 pub fn as_str(&self) -> &'static str {
2179 match self {
2180 Self::SafeSubset => "safe-subset",
2181 Self::Host => "host",
2182 Self::Brush => "brush",
2183 }
2184 }
2185}
2186
2187impl std::fmt::Display for ShellEngine {
2188 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
2189 f.write_str(self.as_str())
2190 }
2191}
2192
2193impl std::str::FromStr for ShellEngine {
2194 type Err = String;
2195
2196 /// Parse a shell-engine token. Accepts the canonical names plus intuitive
2197 /// aliases (`subset`/`safe`, `sandbox-host`/`landlock`, `brush-ocap`), so a
2198 /// user thinking in "landlock vs brush-ocap" terms still resolves correctly.
2199 fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
2200 match s.trim().to_ascii_lowercase().replace('_', "-").as_str() {
2201 "safe-subset" | "subset" | "safe" => Ok(Self::SafeSubset),
2202 "host" | "sandbox-host" | "landlock" | "seatbelt" => Ok(Self::Host),
2203 "brush" | "brush-ocap" => Ok(Self::Brush),
2204 other => Err(format!(
2205 "unknown shell engine '{other}' (expected one of: safe-subset, host, brush)"
2206 )),
2207 }
2208 }
2209}
2210
2211/// `[shell]` — engine selection for `run_command`. `engine = None` (the field
2212/// unset) is deliberately distinct from an explicit choice: an unset engine lets
2213/// `--full-access` auto-upgrade to `host` (see [`resolve_shell_engine`]).
2214#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
2215#[serde(default)]
2216pub struct ShellConfig {
2217 /// The selected engine, or `None` to accept the context default.
2218 #[serde(default, skip_serializing_if = "Option::is_none")]
2219 pub engine: Option<ShellEngine>,
2220}
2221
2222/// The engine `--full-access` auto-selects when none is set explicitly: `host`
2223/// on unix (a real `/bin/sh` in the kernel jail), but **`brush` on Windows** —
2224/// host-shell spawns `/bin/sh -c`, which Windows lacks, so the cross-platform
2225/// carried brush engine is the full-grammar option there (with a Windows-usage
2226/// warning surfaced at selection).
2227#[must_use]
2228pub fn full_access_default_engine() -> ShellEngine {
2229 #[cfg(windows)]
2230 {
2231 ShellEngine::Brush
2232 }
2233 #[cfg(not(windows))]
2234 {
2235 ShellEngine::Host
2236 }
2237}
2238
2239/// Resolve the effective shell engine in precedence order: an explicit CLI
2240/// `--shell-engine` wins, then the `[shell] engine` config key, then — only when
2241/// neither was set — `--full-access` auto-upgrades (to `host` on unix, `brush`
2242/// on Windows), otherwise the `safe-subset` default. Keeping the auto-upgrade
2243/// *last* means an explicit choice is never silently overridden.
2244#[must_use]
2245pub fn resolve_shell_engine(
2246 cli: Option<ShellEngine>,
2247 configured: Option<ShellEngine>,
2248 full_access: bool,
2249) -> ShellEngine {
2250 cli.or(configured).unwrap_or(if full_access {
2251 full_access_default_engine()
2252 } else {
2253 ShellEngine::SafeSubset
2254 })
2255}
2256
2257/// The OCAP **L3 backend** (the kernel fence) for this platform, and whether it
2258/// is active on this host. This is the axis *separate* from the shell engine
2259/// ([`ShellEngine`]): the engine parses/runs the command line (L2), the backend
2260/// confines it in the kernel (L3). Surfaced by `newt doctor` (#926) so the
2261/// operator can see what actually enforces a restricted grant here. A restricted
2262/// `fs` grant is only real when the backend is available; otherwise it is
2263/// honestly advisory (agent-bridle reports `sandbox_kind = None`).
2264#[must_use]
2265pub fn ocap_l3_backend() -> (&'static str, bool) {
2266 #[cfg(target_os = "linux")]
2267 {
2268 ("Landlock", agent_bridle::landlock_is_supported())
2269 }
2270 #[cfg(target_os = "macos")]
2271 {
2272 ("Seatbelt", agent_bridle::seatbelt_is_supported())
2273 }
2274 #[cfg(target_os = "windows")]
2275 {
2276 ("AppContainer", true)
2277 }
2278 #[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))]
2279 {
2280 ("none", false)
2281 }
2282}
2283
2284#[cfg(test)]
2285mod shell_engine_tests {
2286 use super::{full_access_default_engine, resolve_shell_engine, ShellConfig, ShellEngine};
2287
2288 #[test]
2289 fn from_str_canonical_and_aliases() {
2290 assert_eq!(
2291 "safe-subset".parse::<ShellEngine>().unwrap(),
2292 ShellEngine::SafeSubset
2293 );
2294 assert_eq!(
2295 "subset".parse::<ShellEngine>().unwrap(),
2296 ShellEngine::SafeSubset
2297 );
2298 assert_eq!("host".parse::<ShellEngine>().unwrap(), ShellEngine::Host);
2299 // A user thinking "landlock vs brush" resolves `landlock` → the host
2300 // engine (whose guarantee rests on the Landlock/Seatbelt fence).
2301 assert_eq!(
2302 "landlock".parse::<ShellEngine>().unwrap(),
2303 ShellEngine::Host
2304 );
2305 assert_eq!("BRUSH".parse::<ShellEngine>().unwrap(), ShellEngine::Brush);
2306 assert_eq!(
2307 "brush-ocap".parse::<ShellEngine>().unwrap(),
2308 ShellEngine::Brush
2309 );
2310 assert!("bogus".parse::<ShellEngine>().is_err());
2311 }
2312
2313 #[test]
2314 fn as_str_roundtrips() {
2315 for e in [
2316 ShellEngine::SafeSubset,
2317 ShellEngine::Host,
2318 ShellEngine::Brush,
2319 ] {
2320 assert_eq!(e.as_str().parse::<ShellEngine>().unwrap(), e);
2321 }
2322 }
2323
2324 #[test]
2325 fn resolve_flag_wins_over_everything() {
2326 assert_eq!(
2327 resolve_shell_engine(
2328 Some(ShellEngine::SafeSubset),
2329 Some(ShellEngine::Brush),
2330 true
2331 ),
2332 ShellEngine::SafeSubset,
2333 "explicit --shell-engine wins even over config and --full-access"
2334 );
2335 }
2336
2337 #[test]
2338 fn resolve_config_wins_over_full_access_auto_upgrade() {
2339 assert_eq!(
2340 resolve_shell_engine(None, Some(ShellEngine::SafeSubset), true),
2341 ShellEngine::SafeSubset,
2342 "an explicit [shell] engine is not overridden by --full-access"
2343 );
2344 }
2345
2346 #[test]
2347 fn resolve_full_access_auto_upgrades_when_unset() {
2348 // `host` on unix, `brush` on Windows (host-shell needs `/bin/sh`).
2349 assert_eq!(
2350 resolve_shell_engine(None, None, true),
2351 full_access_default_engine()
2352 );
2353 #[cfg(not(windows))]
2354 assert_eq!(resolve_shell_engine(None, None, true), ShellEngine::Host);
2355 #[cfg(windows)]
2356 assert_eq!(resolve_shell_engine(None, None, true), ShellEngine::Brush);
2357 }
2358
2359 #[test]
2360 fn resolve_defaults_to_safe_subset() {
2361 assert_eq!(
2362 resolve_shell_engine(None, None, false),
2363 ShellEngine::SafeSubset
2364 );
2365 }
2366
2367 #[test]
2368 fn shell_config_deserializes_kebab_case() {
2369 let cfg: ShellConfig = toml::from_str("engine = \"host\"").unwrap();
2370 assert_eq!(cfg.engine, Some(ShellEngine::Host));
2371 let cfg: ShellConfig = toml::from_str("engine = \"safe-subset\"").unwrap();
2372 assert_eq!(cfg.engine, Some(ShellEngine::SafeSubset));
2373 }
2374}
2375
2376impl PermissionPreset {
2377 pub const ALL: [Self; 4] = [
2378 Self::ReadOnly,
2379 Self::WorkspaceEdit,
2380 Self::WorkspaceDev,
2381 Self::FullAccess,
2382 ];
2383
2384 pub fn as_str(&self) -> &'static str {
2385 match self {
2386 Self::ReadOnly => "read_only",
2387 Self::WorkspaceEdit => "workspace_edit",
2388 Self::WorkspaceDev => "workspace_dev",
2389 Self::FullAccess => "full_access",
2390 Self::Custom => "custom",
2391 }
2392 }
2393
2394 /// Cycle through the four user-visible presets (skips `Custom`).
2395 pub fn toggle(&self) -> Self {
2396 let idx = Self::ALL.iter().position(|p| p == self).unwrap_or(2);
2397 Self::ALL[(idx + 1) % Self::ALL.len()].clone()
2398 }
2399
2400 pub fn description(&self) -> &'static str {
2401 match self {
2402 Self::ReadOnly => "read files + list dirs; no writes, no commands",
2403 Self::WorkspaceEdit => "read + write workspace; no shell commands",
2404 Self::WorkspaceDev => "read, write workspace, run: cargo just git grep rg fd ...",
2405 Self::FullAccess => "unrestricted (prompts y/N before each write)",
2406 Self::Custom => "workspace-dev tools plus your extra commands",
2407 }
2408 }
2409}
2410
2411/// Permission configuration stored under `[tui.permissions]` in `newt.toml`.
2412///
2413/// Call [`ToolPermissions::to_caveats`] to obtain the runtime [`crate::Caveats`]
2414/// enforced by every `execute_tool` dispatch.
2415#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
2416#[serde(default)]
2417pub struct ToolPermissions {
2418 /// The active preset.
2419 pub preset: PermissionPreset,
2420
2421 /// Extra commands allowed beyond the `WorkspaceDev` built-in set.
2422 /// Only consulted when `preset == WorkspaceDev` or `Custom`.
2423 /// Stored as leading tokens, e.g. `["bacon", "make"]`.
2424 #[serde(default, skip_serializing_if = "Vec::is_empty")]
2425 pub extra_exec: Vec<String>,
2426
2427 /// Hosts the agent may reach with `web_fetch` (the `net` capability axis).
2428 ///
2429 /// Empty (the default) = **no network** — `web_fetch` is denied. A single
2430 /// `"*"` grants **all** hosts (still SSRF-screened + DNS-rebind-pinned by the
2431 /// web tool). Otherwise an exact host allowlist, e.g.
2432 /// `["docs.rs", "raw.githubusercontent.com"]`. Applies to every preset
2433 /// except `FullAccess` (which is already unrestricted).
2434 #[serde(default, skip_serializing_if = "Vec::is_empty")]
2435 pub net: Vec<String>,
2436
2437 /// Prompt the human when a tool call is denied by the session's caveats
2438 /// (issue #263): allow once / allow for this session / deny. Default
2439 /// **false** — a denial fails the call exactly as before (deny-by-default
2440 /// stays the posture). Interactive TUI only; headless paths (ACP worker,
2441 /// `newt-eval`) never prompt regardless. Also enabled per-run via the
2442 /// `--prompt-for-permissions` CLI flag. Every prompted decision is
2443 /// recorded to `~/.newt/permission-log.jsonl` for later review.
2444 #[serde(default)]
2445 pub prompt: bool,
2446}
2447
2448impl Default for ToolPermissions {
2449 fn default() -> Self {
2450 Self {
2451 preset: PermissionPreset::WorkspaceDev,
2452 extra_exec: Vec::new(),
2453 net: Vec::new(),
2454 prompt: false,
2455 }
2456 }
2457}
2458
2459impl ToolPermissions {
2460 /// Built-in exec allowlist for the `WorkspaceDev` preset.
2461 const WORKSPACE_DEV_EXEC: &'static [&'static str] = &[
2462 "cargo",
2463 // rustc must be here: cargo spawns it as a subprocess to compile and
2464 // test. Without it, `cargo test` fails with "could not execute rustc".
2465 // rustfmt and clippy-driver are already present; this was an oversight.
2466 "rustc",
2467 "just",
2468 "git",
2469 "grep",
2470 "rg",
2471 "ripgrep",
2472 "fd",
2473 "find",
2474 "cat",
2475 "ls",
2476 "echo",
2477 "pwd",
2478 "true",
2479 "false",
2480 "head",
2481 "tail",
2482 "wc",
2483 "sort",
2484 "uniq",
2485 "diff",
2486 "patch",
2487 "rustfmt",
2488 "clippy-driver",
2489 "rustup",
2490 // Polyglot dev tools reached for routinely in a mixed workspace. Same
2491 // risk tier as cargo/git — WorkspaceDev already grants workspace write
2492 // and the full Rust toolchain. Anything outside this set can still be
2493 // opted in per-config via `[tui.permissions] extra_exec = [...]`.
2494 "gh",
2495 "python",
2496 "python3",
2497 "pip",
2498 "npm",
2499 "node",
2500 "make",
2501 "jq",
2502 "curl",
2503 "awk",
2504 "sed",
2505 "cut",
2506 "xargs",
2507 "which",
2508 "env",
2509 ];
2510
2511 /// Build the runtime `Caveats` for this permission configuration.
2512 ///
2513 /// `workspace` is the absolute path to the current workspace directory;
2514 /// it is stored in `Scope::Only` so the TUI enforcement layer can do
2515 /// prefix matching (path within workspace → permitted).
2516 ///
2517 /// Note: the `Caveats` lattice uses exact-set semantics; prefix matching
2518 /// is the responsibility of the enforcement site (`tui_permits_path` in
2519 /// newt-tui), not this algebra. This is an intentional layer separation.
2520 pub fn to_caveats(&self, workspace: &str) -> crate::caveats::Caveats {
2521 use crate::caveats::{Caveats, CountBound, Scope};
2522
2523 let ws = workspace.to_string();
2524 let net = self.net_scope();
2525
2526 match self.preset {
2527 PermissionPreset::ReadOnly => Caveats {
2528 fs_read: Scope::All,
2529 fs_write: Scope::none(),
2530 exec: Scope::none(),
2531 net,
2532 max_calls: CountBound::Unlimited,
2533 valid_for_generation: Scope::All,
2534 },
2535
2536 PermissionPreset::WorkspaceEdit => Caveats {
2537 fs_read: Scope::All,
2538 fs_write: Scope::only([ws]),
2539 exec: Scope::none(),
2540 net,
2541 max_calls: CountBound::Unlimited,
2542 valid_for_generation: Scope::All,
2543 },
2544
2545 // `Custom` shares this arm: editing `extra_exec` keeps WorkspaceDev
2546 // authority plus the added commands. It must NOT escalate to
2547 // `top()` — adding one command to an allowlist should never grant
2548 // full access.
2549 PermissionPreset::WorkspaceDev | PermissionPreset::Custom => {
2550 let mut allowed: std::collections::BTreeSet<String> = Self::WORKSPACE_DEV_EXEC
2551 .iter()
2552 .map(|s| s.to_string())
2553 .collect();
2554 for cmd in &self.extra_exec {
2555 allowed.insert(cmd.clone());
2556 }
2557 Caveats {
2558 fs_read: Scope::All,
2559 fs_write: Scope::only([ws]),
2560 exec: Scope::Only(allowed),
2561 net,
2562 max_calls: CountBound::Unlimited,
2563 valid_for_generation: Scope::All,
2564 }
2565 }
2566
2567 PermissionPreset::FullAccess => Caveats::top(),
2568 }
2569 }
2570
2571 /// Lower the configured `net` allowlist into a capability [`Scope`].
2572 ///
2573 /// Empty → `none` (no network). A `"*"` entry → `All` (every host, still
2574 /// SSRF-screened by the web tool). Otherwise an exact host allowlist.
2575 fn net_scope(&self) -> crate::caveats::Scope<String> {
2576 use crate::caveats::Scope;
2577 if self.net.is_empty() {
2578 Scope::none()
2579 } else if self.net.iter().any(|h| h == "*") {
2580 Scope::All
2581 } else {
2582 Scope::only(self.net.iter().cloned())
2583 }
2584 }
2585}
2586
2587/// Key binding style for the chat REPL input line.
2588#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
2589#[serde(rename_all = "lowercase")]
2590pub enum EditMode {
2591 /// Readline / emacs-style bindings.
2592 Emacs,
2593 /// Vi / vim-style bindings — Esc for normal mode, i for insert.
2594 Vi,
2595 /// Nano-style: modeless, emacs-like bindings (the **default** — the most
2596 /// broadly approachable). Behaves like `Emacs` on the lean surface; it is a
2597 /// distinct, selectable label, and the rich-tui surface shows the nano `^G`
2598 /// help hint for it.
2599 #[default]
2600 Nano,
2601}
2602
2603impl EditMode {
2604 pub fn as_str(&self) -> &'static str {
2605 match self {
2606 Self::Emacs => "emacs",
2607 Self::Vi => "vi",
2608 Self::Nano => "nano",
2609 }
2610 }
2611
2612 /// Cycle through the modes (used by a single-key toggle): emacs → vi →
2613 /// nano → emacs.
2614 pub fn toggle(&self) -> Self {
2615 match self {
2616 Self::Emacs => Self::Vi,
2617 Self::Vi => Self::Nano,
2618 Self::Nano => Self::Emacs,
2619 }
2620 }
2621}
2622
2623impl Default for TuiConfig {
2624 fn default() -> Self {
2625 Self {
2626 chat_style: ChatStyle::Compact,
2627 prompt: None,
2628 no_splash: false,
2629 edit_mode: EditMode::Nano,
2630 gutter: None,
2631 footer: FooterMode::Auto,
2632 color: ColorMode::Auto,
2633 thinking: ThinkingMode::Stream,
2634 tool_output_lines: default_tool_output_lines(),
2635 max_tool_rounds: default_max_tool_rounds(),
2636 workflow_grace_rounds: default_workflow_grace_rounds(),
2637 permissions: ToolPermissions::default(),
2638 debug: None,
2639 trace: None,
2640 build_check_cmd: None,
2641 num_ctx: None,
2642 real_context_discovery: None,
2643 connect_timeout_secs: default_connect_timeout_secs(),
2644 inference_timeout_secs: default_inference_timeout_secs(),
2645 keep_alive: default_keep_alive(),
2646 markdown: MarkdownMode::default(),
2647 mid_loop_trim_threshold: default_mid_loop_trim_threshold(),
2648 mid_loop_trim_tokens: None,
2649 sanitize_mcp_server_names: default_sanitize_mcp_server_names(),
2650 mcp_allow_insecure_hosts: Vec::new(),
2651 allow_bang_escape: default_allow_bang_escape(),
2652 }
2653 }
2654}
2655
2656/// Chat REPL display density.
2657#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
2658#[serde(rename_all = "lowercase")]
2659pub enum ChatStyle {
2660 /// Just the caret symbol — no "newt" / "you" labels.
2661 #[default]
2662 Compact,
2663 /// Full "newt ▸" / "you $" labels before each message.
2664 Verbose,
2665}
2666
2667impl ChatStyle {
2668 pub fn as_str(&self) -> &'static str {
2669 match self {
2670 Self::Compact => "compact",
2671 Self::Verbose => "verbose",
2672 }
2673 }
2674
2675 pub fn toggle(&self) -> Self {
2676 match self {
2677 Self::Compact => Self::Verbose,
2678 Self::Verbose => Self::Compact,
2679 }
2680 }
2681}
2682
2683/// The wire protocol an inference backend speaks.
2684#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
2685#[serde(rename_all = "lowercase")]
2686pub enum BackendKind {
2687 /// Ollama's native `POST /api/chat` API (the historical default).
2688 #[default]
2689 Ollama,
2690 /// An OpenAI-compatible HTTP API (`POST /v1/chat/completions`,
2691 /// `GET /v1/models`): vLLM, llama.cpp's server, or any hosted
2692 /// OpenAI-compatible endpoint. Optionally authenticated with a
2693 /// bearer token (see [`BackendConfig::api_key_file`] /
2694 /// [`BackendConfig::api_key_env`]).
2695 #[serde(alias = "vllm", alias = "openai-compatible")]
2696 Openai,
2697 /// An **in-process** inference backend — no HTTP, no external server. Loads a
2698 /// small quantized (GGUF) model and runs it in-tree (Metal-accelerated on
2699 /// Apple Silicon). Opt-in behind the `embedded` cargo feature (default-off);
2700 /// when the feature is absent, selecting it is a clear build-time-off error,
2701 /// never a silent fallback. Intended for the summarizer + small auxiliary
2702 /// calls so they never contend with the primary model (#639).
2703 Embedded,
2704}
2705
2706impl BackendKind {
2707 /// Short human label for the wire protocol — shown in the ready preamble and
2708 /// the `/backends` list. Note newt models the *protocol*, so vLLM, llama.cpp,
2709 /// and hosted OpenAI all read as `openai` (vLLM has no distinct wire form).
2710 pub fn label(self) -> &'static str {
2711 match self {
2712 Self::Ollama => "ollama",
2713 Self::Openai => "openai",
2714 Self::Embedded => "embedded",
2715 }
2716 }
2717}
2718
2719/// Which OpenAI HTTP surface a `kind = "openai"` backend speaks.
2720///
2721/// `chat_completions` (the default) is the classic `POST /v1/chat/completions`.
2722/// `responses` is the newer `POST /v1/responses` — required by models that
2723/// OpenAI serves *only* there (e.g. `gpt-5-codex`, which 404s on
2724/// chat/completions with "only supported in v1/responses"). Ignored for
2725/// `kind = "ollama"`.
2726#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
2727#[serde(rename_all = "snake_case")]
2728pub enum OpenAiApi {
2729 /// `POST /v1/chat/completions` (the historical default).
2730 #[default]
2731 #[serde(alias = "chat", alias = "completions")]
2732 ChatCompletions,
2733 /// `POST /v1/responses` (the newer Responses API).
2734 Responses,
2735}
2736
2737/// A single inference backend entry.
2738///
2739/// Two ways to define one: an inline `[[backends]]` array element in
2740/// `config.toml`, or a per-file drop-in `~/.newt/backends/<name>.toml` (the
2741/// filename stem is the `name`, so a drop-in omits it). `name` and `tiers`
2742/// therefore default — a minimal drop-in is just `endpoint` + `model`.
2743#[derive(Debug, Clone, Serialize, Deserialize)]
2744pub struct BackendConfig {
2745 /// Backend name. For a per-file drop-in this is overwritten by the filename
2746 /// stem, so the file body may omit it.
2747 #[serde(default)]
2748 pub name: String,
2749 /// HTTP endpoint URL (Ollama / OpenAI). Defaulted so a `kind = "embedded"`
2750 /// backend — which runs in-process and has no URL — can omit it.
2751 #[serde(default)]
2752 pub endpoint: String,
2753 pub model: String,
2754 /// For `kind = "embedded"`: the local GGUF model file (the in-process engine
2755 /// has no `endpoint`). `~/` is expanded at use. Ignored for HTTP backends.
2756 #[serde(default, skip_serializing_if = "Option::is_none")]
2757 pub model_path: Option<String>,
2758 #[serde(default)]
2759 pub tiers: Vec<Tier>,
2760 /// Which wire protocol this backend speaks. Defaults to `ollama`
2761 /// so configs written before this field existed keep working.
2762 #[serde(default)]
2763 pub kind: BackendKind,
2764 /// For `kind = "openai"`: which OpenAI HTTP surface to use
2765 /// (`chat_completions` default, or `responses` for models served only on
2766 /// `/v1/responses`). Ignored for Ollama. The agent loop also auto-falls-back
2767 /// to `responses` if chat/completions 404s with the responses-only error.
2768 #[serde(default)]
2769 pub api: OpenAiApi,
2770 /// Optional path to a file whose first non-empty line is a bearer
2771 /// token, sent as `Authorization: Bearer <token>` by
2772 /// OpenAI-compatible backends. A leading `~/` is expanded to the
2773 /// home directory. Keeping the secret in a file (rather than inline
2774 /// in the config) keeps tokens out of version control.
2775 #[serde(default, skip_serializing_if = "Option::is_none")]
2776 pub api_key_file: Option<String>,
2777 /// Optional environment variable name holding a bearer token. Takes
2778 /// precedence over [`api_key_file`](Self::api_key_file) when both
2779 /// resolve to a non-empty value.
2780 #[serde(default, skip_serializing_if = "Option::is_none")]
2781 pub api_key_env: Option<String>,
2782}
2783
2784impl BackendConfig {
2785 /// Resolve this backend's bearer token, if any.
2786 ///
2787 /// Checks [`api_key_env`](Self::api_key_env) first (environment
2788 /// variable), then [`api_key_file`](Self::api_key_file) (first
2789 /// non-empty line of the file, trimmed). Returns `None` when neither
2790 /// is configured or neither resolves to a non-empty value.
2791 pub fn resolve_api_key(&self) -> Option<String> {
2792 if let Some(var) = &self.api_key_env {
2793 if let Ok(val) = std::env::var(var) {
2794 let val = val.trim();
2795 if !val.is_empty() {
2796 return Some(val.to_string());
2797 }
2798 }
2799 }
2800 if let Some(path) = &self.api_key_file {
2801 let expanded = expand_tilde(path);
2802 if let Ok(contents) = std::fs::read_to_string(&expanded) {
2803 if let Some(token) = contents.lines().map(str::trim).find(|l| !l.is_empty()) {
2804 return Some(token.to_string());
2805 }
2806 }
2807 }
2808 None
2809 }
2810}
2811
2812/// Dedicated configuration for the compression summarizer, loaded from
2813/// `~/.newt/summarizer.toml` (Step 24.10, #559). An absent file means
2814/// `SummarizerConfig::default()` — every field falls back to the session
2815/// backend, so behavior is unchanged from "summarizer reuses the session
2816/// model".
2817///
2818/// The point of the separate file is the **own-backend** fields
2819/// (`endpoint`/`model`/`kind`/`api_key_file`): a summarizer can run on a
2820/// different, fast box than the session model instead of contending with it
2821/// (the #548 field incident — a slow primary summarizer stalled ~189s before
2822/// the static marker). `timeout_secs` / `retries` / `fallback_model` are the
2823/// knobs that used to live under `[tui]` (moved here in 24.10).
2824///
2825/// Example `~/.newt/summarizer.toml`:
2826/// ```toml
2827/// endpoint = "http://REDACTED-HOST:11434" # default: session backend URL
2828/// model = "qwen2.5-coder:3b" # default: session model
2829/// kind = "ollama" # "ollama" | "openai"
2830/// timeout_secs = 45
2831/// retries = 1
2832/// fallback_model = "nemotron-mini:4b" # else preference-list auto-pick (24.9)
2833/// ```
2834#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
2835#[serde(default)]
2836pub struct SummarizerConfig {
2837 /// Summarizer endpoint URL. `None` ⇒ reuse the session backend's URL.
2838 pub endpoint: Option<String>,
2839 /// Summarizer model. `None` ⇒ reuse the session backend's model.
2840 pub model: Option<String>,
2841 /// Backend protocol. `None` ⇒ reuse the session backend's kind.
2842 pub kind: Option<BackendKind>,
2843 /// For `kind = "embedded"` (#661 group C): the local GGUF model file for the
2844 /// in-process candle summarizer. Ignored for HTTP backends.
2845 #[serde(default, skip_serializing_if = "Option::is_none")]
2846 pub model_path: Option<String>,
2847 /// Bearer-token file (first non-empty line). `None` ⇒ reuse the session key.
2848 pub api_key_file: Option<String>,
2849 /// Bearer-token environment variable (checked before `api_key_file`).
2850 pub api_key_env: Option<String>,
2851 /// Per-request timeout (seconds). Default 60 — cold-loading a big model can
2852 /// legitimately exceed it; raise on a slow box that falls back to the marker.
2853 #[serde(default = "default_summarizer_timeout_secs")]
2854 pub timeout_secs: u64,
2855 /// Retry attempts before the static marker. Default 1 — each attempt can
2856 /// cost the full `timeout_secs` (the #548 189s incident was 3 × 60s).
2857 #[serde(default = "default_summarizer_retries")]
2858 pub retries: u32,
2859 /// Explicit fallback model. `None` ⇒ for an Ollama summarizer backend, the
2860 /// first installed small-model-preference-list entry is auto-picked (24.9).
2861 pub fallback_model: Option<String>,
2862 /// `keep_alive` for the warm + summary requests. `None` ⇒ inherit
2863 /// `[tui].keep_alive`.
2864 pub keep_alive: Option<String>,
2865}
2866
2867impl Default for SummarizerConfig {
2868 fn default() -> Self {
2869 Self {
2870 endpoint: None,
2871 model: None,
2872 kind: None,
2873 model_path: None,
2874 api_key_file: None,
2875 api_key_env: None,
2876 timeout_secs: default_summarizer_timeout_secs(),
2877 retries: default_summarizer_retries(),
2878 fallback_model: None,
2879 keep_alive: None,
2880 }
2881 }
2882}
2883
2884impl SummarizerConfig {
2885 /// Parse a `summarizer.toml` body. Pure — fully unit-testable without disk.
2886 pub fn from_toml_str(text: &str) -> Result<Self> {
2887 toml::from_str(text).map_err(|e| NewtError::Config(e.to_string()))
2888 }
2889
2890 /// Load `~/.newt/summarizer.toml` (or `$NEWT_SUMMARIZER_CONFIG`). A missing
2891 /// file is not an error — it yields [`SummarizerConfig::default`] (reuse the
2892 /// session backend). Only a present-but-malformed file errors.
2893 pub fn resolve() -> Result<Self> {
2894 for path in Self::candidate_paths() {
2895 if path.is_file() {
2896 let text = std::fs::read_to_string(&path)?;
2897 return Self::from_toml_str(&text);
2898 }
2899 }
2900 Ok(Self::default())
2901 }
2902
2903 /// Ordered candidate paths for `summarizer.toml`.
2904 fn candidate_paths() -> Vec<PathBuf> {
2905 let mut paths = Vec::new();
2906 if let Ok(p) = std::env::var("NEWT_SUMMARIZER_CONFIG") {
2907 paths.push(PathBuf::from(p));
2908 }
2909 if let Some(dir) = Config::user_config_dir() {
2910 paths.push(dir.join("summarizer.toml"));
2911 }
2912 paths
2913 }
2914
2915 /// Resolve this summarizer's bearer token (env var first, then file), or
2916 /// `None` — mirrors [`BackendConfig::resolve_api_key`].
2917 pub fn resolve_api_key(&self) -> Option<String> {
2918 if let Some(var) = &self.api_key_env {
2919 if let Ok(val) = std::env::var(var) {
2920 let val = val.trim();
2921 if !val.is_empty() {
2922 return Some(val.to_string());
2923 }
2924 }
2925 }
2926 if let Some(path) = &self.api_key_file {
2927 let expanded = expand_tilde(path);
2928 if let Ok(contents) = std::fs::read_to_string(&expanded) {
2929 if let Some(token) = contents.lines().map(str::trim).find(|l| !l.is_empty()) {
2930 return Some(token.to_string());
2931 }
2932 }
2933 }
2934 None
2935 }
2936}
2937
2938/// A subprocess provider-plugin entry.
2939#[derive(Debug, Clone, Serialize, Deserialize)]
2940pub struct ProviderConfig {
2941 pub name: String,
2942 pub command: String,
2943 #[serde(default, skip_serializing_if = "Option::is_none")]
2944 pub model: Option<String>,
2945 #[serde(default)]
2946 pub env_pass: Vec<String>,
2947 pub tiers: Vec<Tier>,
2948}
2949
2950// ---------------------------------------------------------------------------
2951// Default
2952// ---------------------------------------------------------------------------
2953
2954/// The last-resort localhost Ollama backend: used both as `Config::default()`'s
2955/// sole backend (no config file at all) and as the [`Config::resolve`] fallback
2956/// when neither inline `[[backends]]` nor per-file drop-ins supply any, so a
2957/// bare install still talks to a local Ollama.
2958fn fallback_localhost_backend() -> BackendConfig {
2959 BackendConfig {
2960 name: "ollama".into(),
2961 endpoint: "http://127.0.0.1:11434".into(),
2962 model: "llama3.1:8b".into(),
2963 model_path: None,
2964 tiers: vec![Tier::Fast, Tier::Standard, Tier::Complex, Tier::Review],
2965 kind: BackendKind::Ollama,
2966 api: Default::default(),
2967 api_key_file: None,
2968 api_key_env: None,
2969 }
2970}
2971
2972impl Default for Config {
2973 fn default() -> Self {
2974 Self {
2975 backends: vec![fallback_localhost_backend()],
2976 providers: Vec::new(),
2977 scratch: None,
2978 default_tier_order: vec![Tier::Fast, Tier::Standard, Tier::Complex, Tier::Review],
2979 lifecycle: None,
2980 dgx: None,
2981 tui: None,
2982 shell: None,
2983 context: None,
2984 tools: None,
2985 pricing: None,
2986 memory: None,
2987 agents: AgentsConfig::default(),
2988 mcp_servers: Vec::new(),
2989 logs: None,
2990 skills: None,
2991 model_tuning: Vec::new(),
2992 conversations: None,
2993 merge: None,
2994 permission_presets: std::collections::BTreeMap::new(),
2995 modes: std::collections::BTreeMap::new(),
2996 profiles: std::collections::BTreeMap::new(),
2997 bundles: std::collections::BTreeMap::new(),
2998 loadouts: std::collections::BTreeMap::new(),
2999 crews: std::collections::BTreeMap::new(),
3000 crew: None,
3001 plan: None,
3002 }
3003 }
3004}
3005
3006// ---------------------------------------------------------------------------
3007// Loading
3008// ---------------------------------------------------------------------------
3009
3010impl Config {
3011 /// Load configuration from an explicit file path.
3012 pub fn load(path: &Path) -> Result<Self> {
3013 let text = std::fs::read_to_string(path)?;
3014 toml::from_str(&text).map_err(|e| NewtError::Config(e.to_string()))
3015 }
3016
3017 /// Resolve configuration by searching well-known locations, then layering a
3018 /// project-local override on top.
3019 ///
3020 /// Base search order (first match wins):
3021 /// 1. `$NEWT_CONFIG` environment variable
3022 /// 2. `./newt.toml`
3023 /// 3. `$NEWT_CONFIG_DIR/config.toml` or `~/.newt/config.toml`
3024 /// 4. `/etc/newt/config.toml`
3025 ///
3026 /// Then, if a project-local `.newt/config.toml` is found by walking up from
3027 /// the current directory (see [`Config::project_config_path`]), it is
3028 /// deep-merged **over** the base so a repo can pin its own models, endpoints,
3029 /// rules, and local stdio MCP services without copying the whole global
3030 /// config. Tables merge recursively (project keys win) and scalars are
3031 /// replaced by the project value. Arrays follow `[merge] arrays` —
3032 /// `"replace"` (default) or `"append"` (see [`ArrayMergeStrategy`]). The
3033 /// project config's `[merge]` setting takes precedence, then the base's.
3034 /// See issue #222.
3035 ///
3036 /// When no project override exists this is byte-for-byte the legacy
3037 /// first-match behavior. Returns `Config::default()` if nothing is found.
3038 pub fn resolve() -> Result<Self> {
3039 let base_path = Self::candidate_paths().into_iter().find(|p| p.is_file());
3040 // A project-local config that *is* the base (e.g. cwd is the project and
3041 // its `.newt/config.toml` already matched) must not be merged onto itself.
3042 let project_path =
3043 Self::project_config_path().filter(|p| Some(p.as_path()) != base_path.as_deref());
3044
3045 let mut cfg = match (&base_path, &project_path) {
3046 // Fast path: no project override → exact legacy behavior.
3047 (Some(p), None) => Self::load(p)?,
3048 (None, None) => Self::default(),
3049 // Project override present → layer it over the base (or the default
3050 // config when there is no base file).
3051 (base, Some(proj)) => {
3052 let mut merged = match base {
3053 Some(p) => Self::load_value(p)?,
3054 None => toml::Value::try_from(Self::default())
3055 .map_err(|e| NewtError::Config(e.to_string()))?,
3056 };
3057 let project_val = Self::load_value(proj)?;
3058 // The merge strategy is itself config: the project declares how
3059 // it wants to be merged (`[merge] arrays = ...`), else the global
3060 // config's setting, else the built-in default (Replace).
3061 let strategy = array_merge_strategy(&project_val, &merged);
3062 merge_toml(&mut merged, project_val, strategy);
3063 merged
3064 .try_into()
3065 .map_err(|e| NewtError::Config(e.to_string()))?
3066 }
3067 };
3068 // Per-file backends (the endpoint control surface): drop a
3069 // `~/.newt/backends/<name>.toml` to add/override a backend — no
3070 // `config.toml` edit, and no overlapping inline `[[backends]]` to
3071 // hand-deconflict. Runs first so disk loadouts/crews can name a disk
3072 // backend's provider.
3073 cfg.merge_disk_backends();
3074 // Localhost fallback: a config that declared no inline `[[backends]]`
3075 // deserializes to empty (see the field doc); if no drop-in supplied one
3076 // either, restore the bare-install localhost Ollama so newt still has a
3077 // backend to talk to.
3078 if cfg.backends.is_empty() {
3079 cfg.backends.push(fallback_localhost_backend());
3080 }
3081 // Per-file bundles (the model-support-kit control surface): drop a
3082 // `~/.newt/bundles/<name>.toml` to add a bundle — no `config.toml` edit.
3083 cfg.merge_disk_bundles();
3084 // Per-file loadouts (the shareable composition control surface): drop a
3085 // `~/.newt/loadouts/<name>.toml` to add a loadout — no `config.toml` edit.
3086 // Runs after bundles so a disk loadout may name a disk bundle.
3087 cfg.merge_disk_loadouts();
3088 // Per-file crews (the role-ensemble control surface): drop a
3089 // `~/.newt/crews/<name>.toml` to add a crew — no `config.toml` edit.
3090 // Runs after loadouts so a disk crew may name a disk loadout.
3091 cfg.merge_disk_crews();
3092 // Per-file DGX nodes (the per-host control surface): drop a
3093 // `~/.newt/dgx/<name>.toml` to add/override a DGX node — each host its
3094 // own file, no inline `[[dgx.nodes]]`. The active selection
3095 // (active_node/active_endpoint/active_model) stays in `[dgx]`.
3096 cfg.merge_disk_dgx_nodes();
3097 // #726: push the resolved `[tools] max_output_tokens` into the
3098 // process-wide model-facing output budget. `Config::resolve` is the
3099 // single canonical config-application entry, so every consumer (TUI,
3100 // cowork driver, eval) gets the override here without threading a new
3101 // `usize` through `ChatCtx` + `execute_tool` + every call site. Idempotent.
3102 crate::agentic::set_max_output_tokens(cfg.max_output_tokens());
3103 crate::agentic::set_output_head_tokens(cfg.output_head_tokens());
3104 // #880: publish the repo `[lifecycle]` overrides the same way — the single
3105 // canonical config-application entry — so the crew's normalize (and future
3106 // phase consumers) honor `.newt/config.toml`.
3107 if let Some(lc) = &cfg.lifecycle {
3108 crate::tooling::set_lifecycle_override(lc.clone());
3109 }
3110 // #844: publish `[scratch] dir` the same way — so crew worktrees / the crew
3111 // target / session plans honor it. `NEWT_SCRATCH_DIR` still overrides.
3112 if let Some(dir) = cfg.scratch.as_ref().and_then(|s| s.dir.as_deref()) {
3113 crate::scratch::set_scratch_dir(dir);
3114 }
3115 Ok(cfg)
3116 }
3117
3118 /// The configured model-facing output token budget (`[tools]
3119 /// max_output_tokens`), or the built-in default when `[tools]` is absent
3120 /// (#726). `0` means "no cap". See [`ToolsConfig`].
3121 pub fn max_output_tokens(&self) -> usize {
3122 self.tools
3123 .as_ref()
3124 .map(|t| t.max_output_tokens)
3125 .unwrap_or_else(default_max_output_tokens)
3126 }
3127
3128 /// The configured head allocation for oversized `run_command` output
3129 /// (`[tools] output_head_tokens`), or the built-in tail-biased default.
3130 pub fn output_head_tokens(&self) -> usize {
3131 self.tools
3132 .as_ref()
3133 .map(|t| t.output_head_tokens)
3134 .unwrap_or_else(default_output_head_tokens)
3135 }
3136
3137 /// Merge per-file backends from the `backends/` dirs next to the config:
3138 /// `~/.newt/backends/*.toml` first, then the project `.newt/backends/` (so
3139 /// project overrides home overrides inline `[[backends]]`). Filename stem =
3140 /// backend name. A malformed drop-in is skipped with a warning; it must not
3141 /// break startup.
3142 fn merge_disk_backends(&mut self) {
3143 if let Some(dir) = Self::user_config_dir() {
3144 self.merge_backends_from_dir(&dir.join("backends"));
3145 }
3146 if let Some(proj) = Self::project_config_path() {
3147 if let Some(parent) = proj.parent() {
3148 self.merge_backends_from_dir(&parent.join("backends"));
3149 }
3150 }
3151 }
3152
3153 /// Load `<dir>/*.toml` as backends (filename stem = name) into
3154 /// `self.backends`. A drop-in **replaces** an existing backend of the same
3155 /// name (last-wins), else it is appended — so a `dgx1.toml` file supersedes
3156 /// an inline `[[backends]]` named `dgx1` without a duplicate. A malformed
3157 /// file is skipped with a warning.
3158 fn merge_backends_from_dir(&mut self, dir: &Path) {
3159 let Ok(entries) = std::fs::read_dir(dir) else {
3160 return; // no backends dir — fine
3161 };
3162 let mut paths: Vec<PathBuf> = entries
3163 .flatten()
3164 .map(|e| e.path())
3165 .filter(|p| p.extension().is_some_and(|x| x == "toml"))
3166 .collect();
3167 paths.sort();
3168 for path in paths {
3169 let Some(stem) = path.file_stem().and_then(|s| s.to_str()) else {
3170 continue;
3171 };
3172 match std::fs::read_to_string(&path).map(|t| toml::from_str::<BackendConfig>(&t)) {
3173 Ok(Ok(mut backend)) => {
3174 // A backend needs a destination: an HTTP `endpoint`, or — for
3175 // `kind = "embedded"` — a local `model_path`. Skip those with
3176 // neither (the "malformed → skip, not fatal" contract; before
3177 // `endpoint` became defaultable, the missing-endpoint case was
3178 // a parse error).
3179 if backend.endpoint.is_empty() && backend.model_path.is_none() {
3180 tracing::warn!(
3181 path = %path.display(),
3182 "skipping backend with neither endpoint nor model_path"
3183 );
3184 continue;
3185 }
3186 // The filename is authoritative for the name (collision-free).
3187 backend.name = stem.to_string();
3188 match self.backends.iter_mut().find(|b| b.name == backend.name) {
3189 Some(existing) => *existing = backend,
3190 None => self.backends.push(backend),
3191 }
3192 }
3193 Ok(Err(e)) => {
3194 tracing::warn!(path = %path.display(), error = %e, "skipping malformed backend file");
3195 }
3196 Err(_) => {}
3197 }
3198 }
3199 }
3200
3201 /// Merge per-file DGX nodes from the `dgx/` dirs next to the config:
3202 /// `~/.newt/dgx/*.toml` first, then the project `.newt/dgx/` (so project
3203 /// overrides home overrides inline `[[dgx.nodes]]`). Filename stem = node
3204 /// name. A malformed drop-in is skipped with a warning; it must not break
3205 /// startup.
3206 fn merge_disk_dgx_nodes(&mut self) {
3207 if let Some(dir) = Self::user_config_dir() {
3208 self.merge_dgx_nodes_from_dir(&dir.join("dgx"));
3209 }
3210 if let Some(proj) = Self::project_config_path() {
3211 if let Some(parent) = proj.parent() {
3212 self.merge_dgx_nodes_from_dir(&parent.join("dgx"));
3213 }
3214 }
3215 }
3216
3217 /// Load `<dir>/*.toml` as DGX nodes (filename stem = name) into
3218 /// `self.dgx.nodes`. A drop-in **replaces** an existing node of the same
3219 /// name (last-wins), else it is appended — so a `dgx1.toml` file supersedes
3220 /// an inline `[[dgx.nodes]]` named `dgx1` without a duplicate. The `[dgx]`
3221 /// table is created (default selection) if it was absent. A malformed file
3222 /// is skipped with a warning.
3223 fn merge_dgx_nodes_from_dir(&mut self, dir: &Path) {
3224 let Ok(entries) = std::fs::read_dir(dir) else {
3225 return; // no dgx dir — fine
3226 };
3227 let mut paths: Vec<PathBuf> = entries
3228 .flatten()
3229 .map(|e| e.path())
3230 .filter(|p| p.extension().is_some_and(|x| x == "toml"))
3231 .collect();
3232 paths.sort();
3233 for path in paths {
3234 let Some(stem) = path.file_stem().and_then(|s| s.to_str()) else {
3235 continue;
3236 };
3237 match std::fs::read_to_string(&path).map(|t| toml::from_str::<crate::dgx::DgxNode>(&t))
3238 {
3239 Ok(Ok(mut node)) => {
3240 // The filename is authoritative for the name (collision-free).
3241 node.name = stem.to_string();
3242 let dgx = self.dgx.get_or_insert_with(Default::default);
3243 match dgx.nodes.iter_mut().find(|n| n.name == node.name) {
3244 Some(existing) => *existing = node,
3245 None => dgx.nodes.push(node),
3246 }
3247 }
3248 Ok(Err(e)) => {
3249 tracing::warn!(path = %path.display(), error = %e, "skipping malformed dgx node file");
3250 }
3251 Err(_) => {}
3252 }
3253 }
3254 }
3255
3256 /// Merge per-file crews from the `crews/` dirs next to the config:
3257 /// `~/.newt/crews/*.toml` first, then the project `.newt/crews/` (so project
3258 /// overrides home overrides inline `[crews.*]`). Filename stem = crew name. A
3259 /// malformed drop-in is skipped with a warning; references inside a crew are
3260 /// validated when it is selected (`newt crew --crew <name>`), mirroring the
3261 /// inline `[crews.*]` and disk-loadout paths.
3262 fn merge_disk_crews(&mut self) {
3263 if let Some(dir) = Self::user_config_dir() {
3264 self.merge_crews_from_dir(&dir.join("crews"));
3265 }
3266 if let Some(proj) = Self::project_config_path() {
3267 if let Some(parent) = proj.parent() {
3268 self.merge_crews_from_dir(&parent.join("crews"));
3269 }
3270 }
3271 }
3272
3273 /// Load `<dir>/*.toml` as crews (filename stem = name) into `self.crews`,
3274 /// last-wins on a name clash. A malformed file is skipped with a warning.
3275 fn merge_crews_from_dir(&mut self, dir: &Path) {
3276 let Ok(entries) = std::fs::read_dir(dir) else {
3277 return; // no crews dir — fine
3278 };
3279 let mut paths: Vec<PathBuf> = entries
3280 .flatten()
3281 .map(|e| e.path())
3282 .filter(|p| p.extension().is_some_and(|x| x == "toml"))
3283 .collect();
3284 paths.sort();
3285 for path in paths {
3286 let Some(stem) = path.file_stem().and_then(|s| s.to_str()) else {
3287 continue;
3288 };
3289 match std::fs::read_to_string(&path).map(|t| toml::from_str::<Crew>(&t)) {
3290 Ok(Ok(crew)) => {
3291 self.crews.insert(stem.to_string(), crew);
3292 }
3293 Ok(Err(e)) => {
3294 tracing::warn!(path = %path.display(), error = %e, "skipping malformed crew file");
3295 }
3296 Err(_) => {}
3297 }
3298 }
3299 }
3300
3301 /// Merge per-file bundles from the well-known `bundles/` dirs next to the
3302 /// config: `~/.newt/bundles/*.toml` first, then the project `.newt/bundles/`
3303 /// (so project overrides home overrides inline `[bundles.*]`). The filename
3304 /// stem is the bundle name. A malformed drop-in is skipped with a warning — it
3305 /// must not break startup.
3306 fn merge_disk_bundles(&mut self) {
3307 if let Some(dir) = Self::user_config_dir() {
3308 self.merge_bundles_from_dir(&dir.join("bundles"));
3309 }
3310 if let Some(proj) = Self::project_config_path() {
3311 if let Some(parent) = proj.parent() {
3312 self.merge_bundles_from_dir(&parent.join("bundles"));
3313 }
3314 }
3315 }
3316
3317 /// Load `<dir>/*.toml` as bundles (filename stem = name) into `self.bundles`,
3318 /// last-wins on a name clash. A malformed file is skipped with a warning.
3319 fn merge_bundles_from_dir(&mut self, dir: &Path) {
3320 let Ok(entries) = std::fs::read_dir(dir) else {
3321 return; // no bundles dir — fine
3322 };
3323 let mut paths: Vec<PathBuf> = entries
3324 .flatten()
3325 .map(|e| e.path())
3326 .filter(|p| p.extension().is_some_and(|x| x == "toml"))
3327 .collect();
3328 paths.sort();
3329 for path in paths {
3330 let Some(stem) = path.file_stem().and_then(|s| s.to_str()) else {
3331 continue;
3332 };
3333 match std::fs::read_to_string(&path).map(|t| toml::from_str::<BundleConfig>(&t)) {
3334 Ok(Ok(bundle)) => {
3335 self.bundles.insert(stem.to_string(), bundle);
3336 }
3337 Ok(Err(e)) => {
3338 tracing::warn!(path = %path.display(), error = %e, "skipping malformed bundle file");
3339 }
3340 Err(_) => {}
3341 }
3342 }
3343 }
3344
3345 /// Merge per-file loadouts from the well-known `loadouts/` dirs next to the
3346 /// config: `~/.newt/loadouts/*.toml` first, then the project `.newt/loadouts/`
3347 /// (so project overrides home overrides inline `[loadouts.*]`). The filename
3348 /// stem is the loadout name. A malformed drop-in is skipped with a warning — it
3349 /// must not break startup. References *inside* a loadout are validated when it
3350 /// is selected (`--loadout`), not at load, mirroring the inline `[loadouts.*]`
3351 /// path.
3352 fn merge_disk_loadouts(&mut self) {
3353 if let Some(dir) = Self::user_config_dir() {
3354 self.merge_loadouts_from_dir(&dir.join("loadouts"));
3355 }
3356 if let Some(proj) = Self::project_config_path() {
3357 if let Some(parent) = proj.parent() {
3358 self.merge_loadouts_from_dir(&parent.join("loadouts"));
3359 }
3360 }
3361 }
3362
3363 /// Load `<dir>/*.toml` as loadouts (filename stem = name) into `self.loadouts`,
3364 /// last-wins on a name clash. A malformed file is skipped with a warning.
3365 fn merge_loadouts_from_dir(&mut self, dir: &Path) {
3366 let Ok(entries) = std::fs::read_dir(dir) else {
3367 return; // no loadouts dir — fine
3368 };
3369 let mut paths: Vec<PathBuf> = entries
3370 .flatten()
3371 .map(|e| e.path())
3372 .filter(|p| p.extension().is_some_and(|x| x == "toml"))
3373 .collect();
3374 paths.sort();
3375 for path in paths {
3376 let Some(stem) = path.file_stem().and_then(|s| s.to_str()) else {
3377 continue;
3378 };
3379 match std::fs::read_to_string(&path).map(|t| toml::from_str::<Loadout>(&t)) {
3380 Ok(Ok(loadout)) => {
3381 self.loadouts.insert(stem.to_string(), loadout);
3382 }
3383 Ok(Err(e)) => {
3384 tracing::warn!(path = %path.display(), error = %e, "skipping malformed loadout file");
3385 }
3386 Err(_) => {}
3387 }
3388 }
3389 }
3390
3391 /// Load a config file as a raw `toml::Value` (for layered merging).
3392 fn load_value(path: &Path) -> Result<toml::Value> {
3393 let text = std::fs::read_to_string(path)?;
3394 toml::from_str(&text).map_err(|e| NewtError::Config(e.to_string()))
3395 }
3396
3397 /// Locate a project-local `.newt/config.toml` by walking up from the current
3398 /// directory toward the filesystem root, stopping before `$HOME` so the
3399 /// global `~/.newt/config.toml` is never mistaken for a project override.
3400 /// Returns the nearest match (innermost project wins). See issue #222.
3401 pub fn project_config_path() -> Option<PathBuf> {
3402 let cwd = std::env::current_dir().ok()?;
3403 find_project_config_from(&cwd, home_dir().as_deref())
3404 }
3405
3406 /// The user-writable config root: `$NEWT_CONFIG_DIR` or `~/.newt`.
3407 pub fn user_config_dir() -> Option<PathBuf> {
3408 if let Some(path) = std::env::var_os(NEWT_CONFIG_DIR_ENV)
3409 .filter(|value| !value.is_empty())
3410 .map(PathBuf::from)
3411 {
3412 return Some(path);
3413 }
3414 home_dir().map(|h| h.join(".newt"))
3415 }
3416
3417 /// The user-writable config path: `$NEWT_CONFIG_DIR/config.toml` or
3418 /// `~/.newt/config.toml`.
3419 /// This is the first path `resolve()` reads and the target for `save()`.
3420 pub fn user_config_path() -> Option<PathBuf> {
3421 Self::user_config_dir().map(|dir| dir.join("config.toml"))
3422 }
3423
3424 /// Serialize the config to pretty TOML for **audit**, with inline secret
3425 /// material redacted. The values of every `[[mcp_servers]]` `env` and
3426 /// `headers` entry are replaced with [`Self::REDACTED`] — those maps are the
3427 /// only place `Config` can carry a raw secret inline (e.g. an
3428 /// `Authorization: Bearer …` header or an `API_KEY=…` child env var). Keys
3429 /// are kept so an auditor sees *which* variables/headers are set without the
3430 /// values. Secret *references* (`api_key_file` / `api_key_env`) are left as-is
3431 /// — they name where a secret lives, not the secret itself.
3432 ///
3433 /// # Errors
3434 /// A TOML serialization failure (should not happen for a valid `Config`).
3435 pub fn to_redacted_toml(&self) -> Result<String> {
3436 let mut redacted = self.clone();
3437 for server in &mut redacted.mcp_servers {
3438 for v in server.env.values_mut() {
3439 *v = Self::REDACTED.to_string();
3440 }
3441 for v in server.headers.values_mut() {
3442 *v = Self::REDACTED.to_string();
3443 }
3444 }
3445 toml::to_string_pretty(&redacted).map_err(|e| NewtError::Config(e.to_string()))
3446 }
3447
3448 /// Placeholder substituted for redacted secret values in [`Self::to_redacted_toml`].
3449 pub const REDACTED: &'static str = "<redacted>";
3450
3451 /// The ordered skill-discovery search path, with `~/` expanded.
3452 ///
3453 /// Resolves `[skills].search` when configured; otherwise defaults to the
3454 /// single host-scoped `~/.newt/skills`. Order is preserved — earlier
3455 /// directories win on a name collision (see `newt_skills::discover_paths`).
3456 /// The default falls back to a relative `.newt/skills` only when `$HOME`
3457 /// can't be resolved, so the list is never empty.
3458 #[must_use]
3459 pub fn skill_search_dirs(&self) -> Vec<PathBuf> {
3460 let configured = self
3461 .skills
3462 .as_ref()
3463 .map(|s| s.search.as_slice())
3464 .unwrap_or(&[]);
3465 if configured.is_empty() {
3466 let default = Self::user_config_dir()
3467 .map(|dir| dir.join("skills"))
3468 .unwrap_or_else(|| PathBuf::from(".newt/skills"));
3469 return vec![default];
3470 }
3471 configured.iter().map(|s| expand_tilde(s)).collect()
3472 }
3473
3474 /// Serialize this config and write it to `path`, creating parent dirs if needed.
3475 pub fn save(&self, path: &Path) -> Result<()> {
3476 if let Some(parent) = path.parent() {
3477 std::fs::create_dir_all(parent).map_err(NewtError::Io)?;
3478 }
3479 let text = toml::to_string_pretty(self).map_err(|e| NewtError::Config(e.to_string()))?;
3480 std::fs::write(path, text).map_err(NewtError::Io)
3481 }
3482
3483 /// #904: append `host` to `[tui.permissions] net` in the TOML `text`,
3484 /// **preserving comments and formatting** — unlike [`Config::save`], which
3485 /// re-serializes the whole typed struct and drops the user's comments,
3486 /// ordering, and any keys newt does not model. Creates the
3487 /// `[tui.permissions]` table and its `net` array if absent; a no-op if the
3488 /// host is already listed. PURE (no I/O), so it unit-tests without a
3489 /// filesystem. This is the durable "allow permanently" grant path — it is
3490 /// only ever driven by an explicit human keypress at the permission prompt.
3491 pub fn with_net_host(text: &str, host: &str) -> Result<String> {
3492 let mut doc = text
3493 .parse::<toml_edit::DocumentMut>()
3494 .map_err(|e| NewtError::Config(format!("config is not valid TOML: {e}")))?;
3495 let tui = doc
3496 .as_table_mut()
3497 .entry("tui")
3498 .or_insert(toml_edit::Item::Table(toml_edit::Table::new()));
3499 let tui_tbl = tui
3500 .as_table_mut()
3501 .ok_or_else(|| NewtError::Config("[tui] is not a table".to_string()))?;
3502 let perms = tui_tbl
3503 .entry("permissions")
3504 .or_insert(toml_edit::Item::Table(toml_edit::Table::new()));
3505 let perms_tbl = perms
3506 .as_table_mut()
3507 .ok_or_else(|| NewtError::Config("[tui.permissions] is not a table".to_string()))?;
3508 let net =
3509 perms_tbl
3510 .entry("net")
3511 .or_insert(toml_edit::Item::Value(toml_edit::Value::Array(
3512 toml_edit::Array::new(),
3513 )));
3514 let arr = net.as_array_mut().ok_or_else(|| {
3515 NewtError::Config("[tui.permissions] net is not an array".to_string())
3516 })?;
3517 if !arr.iter().any(|v| v.as_str() == Some(host)) {
3518 arr.push(host);
3519 }
3520 Ok(doc.to_string())
3521 }
3522
3523 /// Durably grant a net host by appending it to `[tui.permissions] net` in the
3524 /// config file at `path`, comment-preserving (see [`Config::with_net_host`]).
3525 /// A missing file is treated as empty (the table is created). Creates parent
3526 /// dirs as needed. Used by the interactive gate's "allow permanently" choice.
3527 pub fn append_permission_net_host(path: &Path, host: &str) -> Result<()> {
3528 let text = std::fs::read_to_string(path).unwrap_or_default();
3529 let updated = Self::with_net_host(&text, host)?;
3530 if let Some(parent) = path.parent() {
3531 std::fs::create_dir_all(parent).map_err(NewtError::Io)?;
3532 }
3533 std::fs::write(path, updated).map_err(NewtError::Io)
3534 }
3535
3536 /// Build the ordered list of candidate config file paths.
3537 fn candidate_paths() -> Vec<PathBuf> {
3538 let mut paths = Vec::new();
3539
3540 if let Ok(p) = std::env::var("NEWT_CONFIG") {
3541 paths.push(PathBuf::from(p));
3542 }
3543
3544 paths.push(PathBuf::from("./newt.toml"));
3545
3546 if let Some(path) = Self::user_config_path() {
3547 paths.push(path);
3548 }
3549
3550 paths.push(PathBuf::from("/etc/newt/config.toml"));
3551 paths
3552 }
3553}
3554
3555// ---------------------------------------------------------------------------
3556// Helpers
3557// ---------------------------------------------------------------------------
3558
3559/// Best-effort home directory lookup without pulling in the `dirs` crate.
3560pub(crate) fn home_dir() -> Option<PathBuf> {
3561 std::env::var("HOME")
3562 .or_else(|_| std::env::var("USERPROFILE"))
3563 .ok()
3564 .map(PathBuf::from)
3565}
3566
3567/// Deep-merge `overlay` into `base`. Tables always merge recursively (overlay
3568/// keys win on collision). Arrays follow `arrays`: [`ArrayMergeStrategy::Replace`]
3569/// swaps the base array for the overlay's, [`ArrayMergeStrategy::Append`]
3570/// concatenates (base entries first). Scalars are always replaced by the
3571/// overlay. Used to layer a project-local `.newt/config.toml` over the global
3572/// config. See issue #222.
3573pub(crate) fn merge_toml(base: &mut toml::Value, overlay: toml::Value, arrays: ArrayMergeStrategy) {
3574 match (base, overlay) {
3575 (toml::Value::Table(base_tbl), toml::Value::Table(overlay_tbl)) => {
3576 for (key, val) in overlay_tbl {
3577 match base_tbl.get_mut(&key) {
3578 Some(existing) => merge_toml(existing, val, arrays),
3579 None => {
3580 base_tbl.insert(key, val);
3581 }
3582 }
3583 }
3584 }
3585 // Append mode: concatenate two arrays (global entries first).
3586 (toml::Value::Array(base_arr), toml::Value::Array(overlay_arr))
3587 if arrays == ArrayMergeStrategy::Append =>
3588 {
3589 base_arr.extend(overlay_arr);
3590 }
3591 // Replace mode (and any scalar): the overlay replaces the base outright.
3592 (slot, overlay) => *slot = overlay,
3593 }
3594}
3595
3596/// Determine the array-merge strategy from the raw config values, before they
3597/// are deserialized. The project config expresses how *it* wants to be merged,
3598/// so it is consulted first; then the base config; else the built-in default.
3599fn array_merge_strategy(project: &toml::Value, base: &toml::Value) -> ArrayMergeStrategy {
3600 read_array_strategy(project)
3601 .or_else(|| read_array_strategy(base))
3602 .unwrap_or_default()
3603}
3604
3605/// Read `[merge] arrays = "replace" | "append"` from a raw config value.
3606/// Returns `None` when the key is absent or unrecognized (caller falls back).
3607fn read_array_strategy(value: &toml::Value) -> Option<ArrayMergeStrategy> {
3608 match value.get("merge")?.get("arrays")?.as_str()? {
3609 "append" => Some(ArrayMergeStrategy::Append),
3610 "replace" => Some(ArrayMergeStrategy::Replace),
3611 _ => None,
3612 }
3613}
3614
3615/// Walk up from `start` looking for a project-local `.newt/config.toml`,
3616/// stopping before `home` (so the global `~/.newt/config.toml` is never
3617/// returned) and at the filesystem root. Returns the innermost match.
3618///
3619/// Split out from [`Config::project_config_path`] so it can be unit-tested
3620/// against temp directories without mutating the process environment.
3621pub(crate) fn find_project_config_from(start: &Path, home: Option<&Path>) -> Option<PathBuf> {
3622 let mut dir = Some(start);
3623 while let Some(current) = dir {
3624 // Never treat the home directory's `.newt` as a project override.
3625 if home == Some(current) {
3626 break;
3627 }
3628 let candidate = current.join(".newt").join("config.toml");
3629 if candidate.is_file() {
3630 return Some(candidate);
3631 }
3632 dir = current.parent();
3633 }
3634 None
3635}
3636
3637/// Expand a leading `~/` (or a bare `~`) to the home directory. Paths
3638/// without a leading tilde are returned unchanged.
3639pub(crate) fn expand_tilde(path: &str) -> PathBuf {
3640 if let Some(rest) = path.strip_prefix("~/") {
3641 if let Some(home) = home_dir() {
3642 return home.join(rest);
3643 }
3644 } else if path == "~" {
3645 if let Some(home) = home_dir() {
3646 return home;
3647 }
3648 }
3649 PathBuf::from(path)
3650}
3651
3652// ---------------------------------------------------------------------------
3653// Tests
3654// ---------------------------------------------------------------------------
3655
3656#[cfg(test)]
3657mod tests {
3658 use super::*;
3659 // The `permits_*` adaptors live on `CaveatsExt` (post-#95 the
3660 // upstream `agent-mesh-protocol::Caveats` ships algebra only).
3661 use crate::caveats::CaveatsExt;
3662 use std::io::Write;
3663
3664 // ── input-footer mode ──────────────────────────────────────────────
3665
3666 #[test]
3667 fn footer_mode_defaults_to_auto_and_round_trips() {
3668 // Absent key → Auto (the amphibious default).
3669 let cfg: TuiConfig = toml::from_str("").unwrap();
3670 assert_eq!(cfg.footer, FooterMode::Auto);
3671 // Each variant parses from its snake_case key.
3672 for (key, want) in [
3673 ("auto", FooterMode::Auto),
3674 ("on", FooterMode::On),
3675 ("off", FooterMode::Off),
3676 ] {
3677 let cfg: TuiConfig = toml::from_str(&format!("footer = \"{key}\"")).unwrap();
3678 assert_eq!(cfg.footer, want, "footer = {key}");
3679 }
3680 }
3681
3682 // ── color / theme mode (issue #527) ─────────────────────────────────
3683
3684 #[test]
3685 fn color_mode_defaults_to_auto_and_round_trips() {
3686 // Absent key → Auto (color on a TTY, none off one).
3687 let cfg: TuiConfig = toml::from_str("").unwrap();
3688 assert_eq!(cfg.color, ColorMode::Auto);
3689 // Every keyword parses from its serde (lowercase) key.
3690 for (key, want) in [
3691 ("auto", ColorMode::Auto),
3692 ("always", ColorMode::Always),
3693 ("never", ColorMode::Never),
3694 ("minimal", ColorMode::Minimal),
3695 ("inverted", ColorMode::Inverted),
3696 ("dark", ColorMode::Dark),
3697 ("light", ColorMode::Light),
3698 ("mono", ColorMode::Mono),
3699 ] {
3700 let cfg: TuiConfig = toml::from_str(&format!("color = \"{key}\"")).unwrap();
3701 assert_eq!(cfg.color, want, "color = {key}");
3702 }
3703 }
3704
3705 #[test]
3706 fn color_mode_keyword_round_trips_and_aliases_parse() {
3707 // keyword() is the inverse of from_keyword() for every canonical variant.
3708 for m in [
3709 ColorMode::Auto,
3710 ColorMode::Always,
3711 ColorMode::Never,
3712 ColorMode::Minimal,
3713 ColorMode::Inverted,
3714 ColorMode::Dark,
3715 ColorMode::Light,
3716 ColorMode::Mono,
3717 ] {
3718 assert_eq!(ColorMode::from_keyword(m.keyword()), Some(m));
3719 }
3720 // Case-insensitive + aliases.
3721 assert_eq!(ColorMode::from_keyword("ALWAYS"), Some(ColorMode::Always));
3722 assert_eq!(ColorMode::from_keyword(" on "), Some(ColorMode::Always));
3723 assert_eq!(ColorMode::from_keyword("off"), Some(ColorMode::Never));
3724 assert_eq!(ColorMode::from_keyword("monochrome"), Some(ColorMode::Mono));
3725 // Unknown keyword is rejected (the CLI value_parser surfaces this).
3726 assert_eq!(ColorMode::from_keyword("rainbow"), None);
3727 }
3728
3729 #[test]
3730 fn color_mode_forced_and_is_mono() {
3731 // forced(): Some(true) = color on, Some(false) = off, None = defer to TTY.
3732 assert_eq!(ColorMode::Always.forced(), Some(true));
3733 assert_eq!(ColorMode::Dark.forced(), Some(true));
3734 assert_eq!(ColorMode::Light.forced(), Some(true));
3735 assert_eq!(ColorMode::Inverted.forced(), Some(true));
3736 assert_eq!(ColorMode::Minimal.forced(), Some(true));
3737 assert_eq!(ColorMode::Never.forced(), Some(false));
3738 assert_eq!(ColorMode::Mono.forced(), Some(false));
3739 assert_eq!(ColorMode::Auto.forced(), None);
3740 // is_mono distinguishes the ASCII-fallback mode from plain Never.
3741 assert!(ColorMode::Mono.is_mono());
3742 assert!(!ColorMode::Never.is_mono());
3743 assert!(!ColorMode::Auto.is_mono());
3744 }
3745
3746 #[test]
3747 fn markdown_mode_defaults_to_auto_round_trips_and_forces() {
3748 assert_eq!(MarkdownMode::default(), MarkdownMode::Auto);
3749 for m in [MarkdownMode::Auto, MarkdownMode::On, MarkdownMode::Off] {
3750 assert_eq!(MarkdownMode::from_keyword(m.keyword()), Some(m));
3751 }
3752 // Case-insensitive + always/never aliases.
3753 assert_eq!(MarkdownMode::from_keyword("ON"), Some(MarkdownMode::On));
3754 assert_eq!(
3755 MarkdownMode::from_keyword(" always "),
3756 Some(MarkdownMode::On)
3757 );
3758 assert_eq!(MarkdownMode::from_keyword("never"), Some(MarkdownMode::Off));
3759 assert_eq!(MarkdownMode::from_keyword("rainbow"), None);
3760 // forced(): On = Some(true), Off = Some(false), Auto = defer.
3761 assert_eq!(MarkdownMode::On.forced(), Some(true));
3762 assert_eq!(MarkdownMode::Off.forced(), Some(false));
3763 assert_eq!(MarkdownMode::Auto.forced(), None);
3764 }
3765
3766 #[test]
3767 fn tui_markdown_parses_from_toml_and_defaults_to_auto() {
3768 let cfg: TuiConfig = toml::from_str("markdown = \"off\"").unwrap();
3769 assert_eq!(cfg.markdown, MarkdownMode::Off);
3770 let default: TuiConfig = toml::from_str("").unwrap();
3771 assert_eq!(default.markdown, MarkdownMode::Auto);
3772 }
3773
3774 /// Step 24.10 (#559): summarizer knobs live in `summarizer.toml` now.
3775 /// Defaults (absent file) reuse the session backend; timeout 60 / retries 1.
3776 #[test]
3777 fn backend_kind_embedded_parses_and_labels() {
3778 // #639: the config accepts `kind = "embedded"` so the summarizer (and a
3779 // backend) can select the in-process backend.
3780 #[derive(serde::Deserialize)]
3781 struct K {
3782 kind: BackendKind,
3783 }
3784 let k: K = toml::from_str("kind = \"embedded\"").unwrap();
3785 assert_eq!(k.kind, BackendKind::Embedded);
3786 assert_eq!(k.kind.label(), "embedded");
3787 }
3788
3789 #[test]
3790 fn summarizer_config_defaults_and_parse() {
3791 let d = SummarizerConfig::default();
3792 assert_eq!(d.endpoint, None);
3793 assert_eq!(d.model, None);
3794 assert_eq!(d.kind, None);
3795 assert_eq!(d.timeout_secs, 60);
3796 assert_eq!(d.retries, 1);
3797 assert_eq!(d.fallback_model, None);
3798
3799 let cfg = SummarizerConfig::from_toml_str(
3800 "endpoint = \"http://REDACTED-HOST:11434\"\n\
3801 model = \"qwen2.5-coder:3b\"\n\
3802 kind = \"openai\"\n\
3803 timeout_secs = 45\n\
3804 retries = 2\n\
3805 fallback_model = \"nemotron-mini:4b\"\n\
3806 keep_alive = \"10m\"",
3807 )
3808 .unwrap();
3809 assert_eq!(cfg.endpoint.as_deref(), Some("http://REDACTED-HOST:11434"));
3810 assert_eq!(cfg.model.as_deref(), Some("qwen2.5-coder:3b"));
3811 assert_eq!(cfg.kind, Some(BackendKind::Openai));
3812 assert_eq!(cfg.timeout_secs, 45);
3813 assert_eq!(cfg.retries, 2);
3814 assert_eq!(cfg.fallback_model.as_deref(), Some("nemotron-mini:4b"));
3815 assert_eq!(cfg.keep_alive.as_deref(), Some("10m"));
3816 }
3817
3818 /// A partial file fills only the keys present; the rest stay at defaults
3819 /// (so an `endpoint`-only file reuses the session model but a fast box).
3820 #[test]
3821 fn summarizer_config_partial_keeps_defaults() {
3822 let cfg = SummarizerConfig::from_toml_str("endpoint = \"http://fast.box:11434\"").unwrap();
3823 assert_eq!(cfg.endpoint.as_deref(), Some("http://fast.box:11434"));
3824 assert_eq!(cfg.model, None); // reuse session model
3825 assert_eq!(cfg.timeout_secs, 60); // default
3826 assert_eq!(cfg.retries, 1); // default
3827 }
3828
3829 #[test]
3830 fn context_manager_keyword_roundtrip_and_availability() {
3831 for m in [
3832 ContextManager::Standard,
3833 ContextManager::Progressive,
3834 ContextManager::Distributed,
3835 ] {
3836 assert_eq!(ContextManager::from_keyword(m.keyword()), Some(m));
3837 }
3838 assert_eq!(
3839 ContextManager::from_keyword(" STANDARD "),
3840 Some(ContextManager::Standard),
3841 "case/space-insensitive"
3842 );
3843 assert_eq!(ContextManager::from_keyword("nope"), None);
3844 // Only standard is implemented today; the others are pending #546.
3845 assert!(ContextManager::Standard.available());
3846 assert!(!ContextManager::Progressive.available());
3847 assert!(!ContextManager::Distributed.available());
3848 assert_eq!(ContextManager::default(), ContextManager::Standard);
3849 }
3850
3851 #[test]
3852 fn context_section_defaults_and_parses() {
3853 // Absent [context] → None on Config; the resolver falls back to standard.
3854 let cfg: Config = toml::from_str("").unwrap();
3855 assert!(cfg.context.is_none());
3856 let c: ContextConfig = toml::from_str("manager = \"progressive\"").unwrap();
3857 assert_eq!(c.manager, ContextManager::Progressive);
3858 assert_eq!(ContextConfig::default().manager, ContextManager::Standard);
3859 }
3860
3861 #[test]
3862 fn scratch_section_defaults_and_parses() {
3863 // #844: `[scratch] dir` parses onto Config; absent → None (the `.scratch`
3864 // default applies at resolution). Uses `from_str` (not `resolve`) so this
3865 // does NOT publish a process-global scratch dir.
3866 let bare: Config = toml::from_str("").unwrap();
3867 assert!(bare.scratch.is_none());
3868 let cfg: Config = toml::from_str("[scratch]\ndir = \"/tmp/newt-scratch\"\n").unwrap();
3869 assert_eq!(
3870 cfg.scratch.and_then(|s| s.dir).as_deref(),
3871 Some("/tmp/newt-scratch")
3872 );
3873 }
3874
3875 #[test]
3876 fn semantic_config_defaults_and_parses() {
3877 // Defaults (Step 26.5.4): nomic-embed-text, top_k 5, no decoupled
3878 // endpoint, and on_embed_failure = disable (the safe default).
3879 let d = SemanticConfig::default();
3880 assert_eq!(d.embedding_model, "nomic-embed-text");
3881 assert_eq!(d.top_k, 5);
3882 assert_eq!(d.embeddings_endpoint, None);
3883 assert_eq!(d.embeddings_api, None);
3884 assert_eq!(d.on_embed_failure, OnEmbedFailure::Disable);
3885 // #720: the embedded-embedder local model dir defaults to None.
3886 assert_eq!(d.embedding_model_path, None);
3887 // `[context.semantic]` parses + overrides, incl. the new fields.
3888 let c: ContextConfig = toml::from_str(
3889 "[semantic]\nembedding_model = \"mxbai-embed-large\"\ntop_k = 8\n\
3890 embedding_model_path = \"/models/bge-small-en-v1.5\"\n\
3891 embeddings_endpoint = \"http://REDACTED-HOST:11434\"\n\
3892 embeddings_api = \"ollama\"\non_embed_failure = \"warn\"",
3893 )
3894 .unwrap();
3895 assert_eq!(c.semantic.embedding_model, "mxbai-embed-large");
3896 assert_eq!(
3897 c.semantic.embedding_model_path.as_deref(),
3898 Some("/models/bge-small-en-v1.5")
3899 );
3900 assert_eq!(c.semantic.top_k, 8);
3901 assert_eq!(
3902 c.semantic.embeddings_endpoint.as_deref(),
3903 Some("http://REDACTED-HOST:11434")
3904 );
3905 assert_eq!(c.semantic.embeddings_api, Some(BackendKind::Ollama));
3906 assert_eq!(c.semantic.on_embed_failure, OnEmbedFailure::Warn);
3907 // `embeddings_api = "vllm"` aliases to the OpenAI protocol.
3908 let v: ContextConfig = toml::from_str("[semantic]\nembeddings_api = \"vllm\"").unwrap();
3909 assert_eq!(v.semantic.embeddings_api, Some(BackendKind::Openai));
3910 // an absent [context.semantic] still yields the defaults
3911 let bare: ContextConfig = toml::from_str("manager = \"standard\"").unwrap();
3912 assert_eq!(bare.semantic, SemanticConfig::default());
3913 }
3914
3915 #[test]
3916 fn context_feature_keyword_alias_availability_and_issue() {
3917 // canonical keyword round-trips
3918 for f in ContextFeature::ALL {
3919 assert_eq!(ContextFeature::from_keyword(f.keyword()), Some(f));
3920 }
3921 // aliases + hyphen/underscore/case
3922 assert_eq!(
3923 ContextFeature::from_keyword("TOOL-OFFLOAD"),
3924 Some(ContextFeature::ToolOffload)
3925 );
3926 assert_eq!(
3927 ContextFeature::from_keyword("offload"),
3928 Some(ContextFeature::ToolOffload)
3929 );
3930 assert_eq!(
3931 ContextFeature::from_keyword(" state "),
3932 Some(ContextFeature::Scratchpad)
3933 );
3934 assert_eq!(ContextFeature::from_keyword("nope"), None);
3935 // tool_offload (26.3), scratchpad (26.4), semantic (26.5), experiential
3936 // (26.6a), scheduled (26.6b) shipped; only provenance is still pending.
3937 assert!(ContextFeature::ToolOffload.available());
3938 assert!(ContextFeature::Scratchpad.available());
3939 assert!(ContextFeature::Semantic.available());
3940 assert!(ContextFeature::Experiential.available());
3941 assert!(ContextFeature::Scheduled.available());
3942 assert!(
3943 !ContextFeature::Provenance.available(),
3944 "provenance still pending"
3945 );
3946 assert!(ContextFeature::ALL
3947 .iter()
3948 .filter(|f| !matches!(f, ContextFeature::Provenance))
3949 .all(|f| f.available()));
3950 // issues route to the right tracking ticket
3951 assert_eq!(ContextFeature::Semantic.issue(), 582);
3952 assert_eq!(ContextFeature::Scratchpad.issue(), 583);
3953 assert_eq!(ContextFeature::ToolOffload.issue(), 584);
3954 assert_eq!(ContextFeature::Provenance.issue(), 584);
3955 assert_eq!(ContextFeature::Experiential.issue(), 585);
3956 assert_eq!(ContextFeature::Scheduled.issue(), 586);
3957 }
3958
3959 #[test]
3960 fn context_features_override_layering_and_parse() {
3961 use ContextFeature as F;
3962 // Every preset resolves to all-off today (standard behavior).
3963 let base = ContextManager::Standard.base_features();
3964 assert!(base.enabled().is_empty());
3965 // An override layers on top of the base, leaving others untouched.
3966 let mut ov = ContextFeatures::default();
3967 ov.set(F::Scratchpad, Some(true));
3968 let resolved = ov.apply_to(base);
3969 assert!(resolved.get(F::Scratchpad));
3970 assert!(!resolved.get(F::Semantic));
3971 assert_eq!(resolved.enabled(), vec![F::Scratchpad]);
3972 // None override = inherit (no change); Some(false) = force off.
3973 let mut ov2 = ContextFeatures::default();
3974 ov2.set(F::Scratchpad, Some(false));
3975 assert!(!ov2.apply_to(resolved).get(F::Scratchpad));
3976 // [context.features] parses keyed by canonical keyword.
3977 let c: ContextConfig = toml::from_str(
3978 "manager = \"standard\"\n[features]\nsemantic = true\nscratchpad = false",
3979 )
3980 .unwrap();
3981 assert_eq!(c.features.get(F::Semantic), Some(true));
3982 assert_eq!(c.features.get(F::Scratchpad), Some(false));
3983 assert_eq!(c.features.get(F::ToolOffload), None);
3984 }
3985
3986 #[test]
3987 fn base_for_defaults_tool_offload_on_and_local_assist_on_for_ollama() {
3988 use ContextFeature as F;
3989 // #945: tool offload is local spill storage and defaults ON for every
3990 // backend. Step 27.4: local (Ollama) backends additionally default
3991 // scratchpad + scheduled ON; semantic also defaults ON but degrades to a
3992 // one-shot no-op until an embedder is configured.
3993 let local = ContextFeatureSet::base_for(ContextManager::Standard, BackendKind::Ollama);
3994 assert!(local.get(F::ToolOffload));
3995 assert!(local.get(F::Scratchpad));
3996 assert!(local.get(F::Semantic));
3997 assert!(local.get(F::Scheduled));
3998 // Cloud (OpenAI-compatible) gets offload, but not local-assist features.
3999 let cloud = ContextFeatureSet::base_for(ContextManager::Standard, BackendKind::Openai);
4000 assert!(cloud.get(F::ToolOffload));
4001 assert!(!cloud.get(F::Scratchpad));
4002 assert!(!cloud.get(F::Semantic));
4003 assert!(!cloud.get(F::Scheduled));
4004 // An explicit override still wins over the local default (force off).
4005 let mut ov = ContextFeatures::default();
4006 ov.set(F::Scheduled, Some(false));
4007 ov.set(F::ToolOffload, Some(false));
4008 assert!(!ov.apply_to(local).get(F::Scheduled));
4009 assert!(!ov.apply_to(local).get(F::ToolOffload));
4010 assert!(ov.apply_to(local).get(F::Scratchpad)); // untouched feature stays on
4011 }
4012
4013 #[test]
4014 fn allow_bang_escape_defaults_to_true_and_round_trips() {
4015 // Absent key → enabled (the human's host shell-out is on by default).
4016 let cfg: TuiConfig = toml::from_str("").unwrap();
4017 assert!(cfg.allow_bang_escape);
4018 // Explicit opt-out parses.
4019 let cfg: TuiConfig = toml::from_str("allow_bang_escape = false").unwrap();
4020 assert!(!cfg.allow_bang_escape);
4021 }
4022
4023 #[test]
4024 fn thinking_mode_defaults_to_stream_and_round_trips() {
4025 let cfg: TuiConfig = toml::from_str("").unwrap();
4026 assert_eq!(cfg.thinking, ThinkingMode::Stream);
4027 let cfg: TuiConfig = toml::from_str("thinking = \"off\"").unwrap();
4028 assert_eq!(cfg.thinking, ThinkingMode::Off);
4029 let cfg: TuiConfig = toml::from_str("thinking = \"stream\"").unwrap();
4030 assert_eq!(cfg.thinking, ThinkingMode::Stream);
4031 }
4032
4033 // ── profile composition (technique library) ────────────────────────
4034
4035 #[test]
4036 fn profile_parses_techniques_and_knobs() {
4037 let cfg: Config = toml::from_str(
4038 r#"
4039 [profiles.nemotron]
4040 techniques = ["knowledge_base", "verify_gate", "retry"]
4041
4042 [profiles.nemotron.verify_gate]
4043 surface_match = "exact"
4044
4045 [profiles.nemotron.retry]
4046 max_retries = 3
4047 "#,
4048 )
4049 .unwrap();
4050 let p = &cfg.profiles["nemotron"];
4051 assert!(p.validate().is_ok());
4052 assert!(p.enables("verify_gate") && p.enables("retry"));
4053 assert_eq!(
4054 p.verify_gate_knobs().surface_match,
4055 crate::verify_gate::SurfaceMatch::Exact
4056 );
4057 assert_eq!(p.retry_knobs().max_retries, 3);
4058 }
4059
4060 #[test]
4061 fn profile_knobs_default_when_unset() {
4062 // techniques named but no knob tables → defaults apply
4063 let p: ProfileConfig = toml::from_str("techniques = [\"verify_gate\", \"retry\"]").unwrap();
4064 assert_eq!(
4065 p.verify_gate_knobs().surface_match,
4066 crate::verify_gate::SurfaceMatch::Exact // the complete-gate default
4067 );
4068 assert_eq!(p.retry_knobs().max_retries, 2);
4069 }
4070
4071 #[test]
4072 fn profile_rejects_unknown_technique() {
4073 let p: ProfileConfig =
4074 toml::from_str("techniques = [\"knowledge_base\", \"teleport\"]").unwrap();
4075 let err = p.validate().unwrap_err();
4076 assert!(err.contains("teleport"), "err: {err}");
4077 }
4078
4079 #[test]
4080 fn profile_rejects_unmet_presupposition() {
4081 // retry presupposes verify_gate — listing retry alone is now a load-time error.
4082 let p: ProfileConfig = toml::from_str("techniques = [\"retry\"]").unwrap();
4083 let err = p.validate().unwrap_err();
4084 assert!(
4085 err.contains("retry") && err.contains("verify_gate") && err.contains("presupposes"),
4086 "err: {err}"
4087 );
4088 // …and adding verify_gate satisfies it.
4089 let ok: ProfileConfig =
4090 toml::from_str("techniques = [\"verify_gate\", \"retry\"]").unwrap();
4091 assert!(ok.validate().is_ok());
4092 }
4093
4094 #[test]
4095 fn registry_does_not_alter_the_resolved_technique_set() {
4096 // Golden: validate() accepts the nemotron set and the resolved order/membership
4097 // is byte-identical to the input — the registry adds checks, not behavior.
4098 let p: ProfileConfig =
4099 toml::from_str("techniques = [\"knowledge_base\", \"verify_gate\", \"retry\"]")
4100 .unwrap();
4101 assert!(p.validate().is_ok());
4102 assert_eq!(p.techniques, vec!["knowledge_base", "verify_gate", "retry"]);
4103 for t in ["knowledge_base", "verify_gate", "retry"] {
4104 assert!(p.enables(t));
4105 }
4106 }
4107
4108 #[test]
4109 fn empty_profiles_is_the_default() {
4110 // no [profiles] table → empty map, behavior unchanged
4111 let cfg: Config = toml::from_str("").unwrap();
4112 assert!(cfg.profiles.is_empty());
4113 assert!(cfg.bundles.is_empty());
4114 }
4115
4116 // ── bundles (the loadable kit unit) ────────────────────────────────
4117
4118 fn bundle_cfg() -> Config {
4119 toml::from_str(
4120 r#"
4121 [profiles.nemotron]
4122 techniques = ["knowledge_base", "verify_gate", "retry"]
4123 [profiles.qwen-coder]
4124 techniques = []
4125
4126 [bundles.nemotron]
4127 about = "nemotron family support"
4128 applies_to = ["nemotron"]
4129 default_profile = "nemotron"
4130 families = { "nemotron" = "nemotron", "qwen" = "qwen-coder" }
4131
4132 [bundles.review-heavy] # use-case bundle: no applies_to
4133 default_profile = "nemotron"
4134 "#,
4135 )
4136 .unwrap()
4137 }
4138
4139 #[test]
4140 fn resolve_bundle_errors_on_unknown() {
4141 let cfg = bundle_cfg();
4142 assert!(cfg.resolve_bundle("nemotron").is_ok());
4143 let err = cfg.resolve_bundle("ghost").unwrap_err();
4144 assert!(err.contains("no such bundle"), "{err}");
4145 }
4146
4147 #[test]
4148 fn bundle_profile_for_model_longest_prefix_then_default() {
4149 let cfg = bundle_cfg();
4150 let b = cfg.resolve_bundle("nemotron").unwrap();
4151 // family-prefix match
4152 assert_eq!(
4153 cfg.bundle_profile_for_model(b, "nemotron3:33b"),
4154 Some("nemotron")
4155 );
4156 assert_eq!(
4157 cfg.bundle_profile_for_model(b, "qwen2.5-coder"),
4158 Some("qwen-coder")
4159 );
4160 // no family match → default_profile
4161 assert_eq!(
4162 cfg.bundle_profile_for_model(b, "llama3.1:8b"),
4163 Some("nemotron")
4164 );
4165 }
4166
4167 #[test]
4168 fn infer_bundle_only_from_applies_to() {
4169 let cfg = bundle_cfg();
4170 // nemotron model → the nemotron bundle (applies_to match)
4171 assert_eq!(
4172 cfg.infer_bundle("nemotron3:33b").map(|(n, _)| n),
4173 Some("nemotron")
4174 );
4175 // a model no applies_to matches → no inference (the use-case bundle is never inferred)
4176 assert!(cfg.infer_bundle("gpt-4.1").is_none());
4177 }
4178
4179 #[test]
4180 fn pick_active_profile_precedence() {
4181 let cfg = bundle_cfg();
4182 // 1. explicit --profile wins over everything
4183 let p = cfg
4184 .pick_active_profile(Some("qwen-coder"), Some("nemotron"), "nemotron3:33b")
4185 .unwrap()
4186 .unwrap();
4187 assert_eq!(p.name, "qwen-coder");
4188 assert_eq!(p.via, PickVia::Profile);
4189 // 2. --bundle resolves to its profile for the model
4190 let p = cfg
4191 .pick_active_profile(None, Some("nemotron"), "nemotron3:33b")
4192 .unwrap()
4193 .unwrap();
4194 assert_eq!(
4195 (p.name.as_str(), p.via),
4196 ("nemotron", PickVia::Bundle("nemotron".into()))
4197 );
4198 // 3. inferred from the model when neither flag is set
4199 let p = cfg
4200 .pick_active_profile(None, None, "nemotron3:33b")
4201 .unwrap()
4202 .unwrap();
4203 assert_eq!(p.via, PickVia::InferredBundle("nemotron".into()));
4204 // 4. nothing matches → None (today's behavior)
4205 assert!(cfg
4206 .pick_active_profile(None, None, "gpt-4.1")
4207 .unwrap()
4208 .is_none());
4209 // an unknown explicit bundle is a hard error
4210 assert!(cfg.pick_active_profile(None, Some("ghost"), "x").is_err());
4211 }
4212
4213 // ── loadouts (the top-level composition; inert until Slice 1) ───────
4214
4215 #[test]
4216 fn loadout_parses_inline_and_validates_references() {
4217 let cfg: Config = toml::from_str(
4218 r#"
4219 [[backends]]
4220 name = "dgx"
4221 endpoint = "http://dgx.local:11434"
4222 model = "nemotron-3:33b"
4223 tiers = []
4224
4225 [profiles.nemotron]
4226 techniques = ["knowledge_base", "verify_gate", "retry"]
4227 [bundles.nemotron]
4228 default_profile = "nemotron"
4229
4230 [loadouts.dev-nemotron]
4231 provider = "dgx"
4232 model = "nemotron@deep"
4233 kit = "nemotron"
4234 profile = "nemotron"
4235 role = "python-developer"
4236 [loadouts.dev-nemotron.settings]
4237 num_ctx = 24576
4238 framing = "Ship small, verify."
4239 "#,
4240 )
4241 .unwrap();
4242 let l = &cfg.loadouts["dev-nemotron"];
4243 assert_eq!(l.provider.as_deref(), Some("dgx"));
4244 assert_eq!(l.model.as_deref(), Some("nemotron@deep"));
4245 assert_eq!(l.role.as_deref(), Some("python-developer"));
4246 assert_eq!(l.settings.as_ref().unwrap().num_ctx, Some(24576));
4247 // references resolve
4248 assert!(l.validate(&cfg).is_ok());
4249 }
4250
4251 #[test]
4252 fn loadout_rejects_dangling_references() {
4253 let cfg: Config = toml::from_str(
4254 r#"
4255 [[backends]]
4256 name = "real-box"
4257 endpoint = "http://h:11434"
4258 model = "m"
4259
4260 [profiles.nemotron]
4261 techniques = ["verify_gate"]
4262 "#,
4263 )
4264 .unwrap();
4265 // dangling kit
4266 let bad_kit = Loadout {
4267 kit: Some("ghost-bundle".into()),
4268 ..Default::default()
4269 };
4270 let e = bad_kit.validate(&cfg).unwrap_err();
4271 assert!(
4272 e.contains("kit 'ghost-bundle'") && e.contains("no such bundle"),
4273 "{e}"
4274 );
4275 // dangling profile
4276 let bad_profile = Loadout {
4277 profile: Some("ghost-profile".into()),
4278 ..Default::default()
4279 };
4280 let e = bad_profile.validate(&cfg).unwrap_err();
4281 assert!(
4282 e.contains("profile 'ghost-profile'") && e.contains("no such profile"),
4283 "{e}"
4284 );
4285 // dangling provider — must name a [backends] entry (Slice 2). The error
4286 // lists the known backends, here the explicit `real-box`.
4287 let bad_provider = Loadout {
4288 provider: Some("ghost-provider".into()),
4289 ..Default::default()
4290 };
4291 let e = bad_provider.validate(&cfg).unwrap_err();
4292 assert!(
4293 e.contains("provider 'ghost-provider'")
4294 && e.contains("no [backends] entry")
4295 && e.contains("real-box"),
4296 "{e}"
4297 );
4298 // an empty loadout is valid (no references)
4299 assert!(Loadout::default().validate(&cfg).is_ok());
4300 }
4301
4302 #[test]
4303 fn disk_bundles_load_per_file_by_stem() {
4304 let dir = tempfile::tempdir().unwrap();
4305 std::fs::write(
4306 dir.path().join("nemotron.toml"),
4307 "applies_to = [\"nemotron\"]\ndefault_profile = \"nemotron\"\n",
4308 )
4309 .unwrap();
4310 // a malformed drop-in must be skipped, not break loading
4311 std::fs::write(
4312 dir.path().join("broken.toml"),
4313 "applies_to = \"not-a-list\"\n",
4314 )
4315 .unwrap();
4316 // a non-toml file is ignored
4317 std::fs::write(dir.path().join("README.md"), "not a bundle").unwrap();
4318
4319 let mut cfg = Config::default();
4320 cfg.merge_bundles_from_dir(dir.path());
4321 assert_eq!(cfg.bundles.len(), 1, "only the valid .toml loads");
4322 let b = cfg
4323 .bundles
4324 .get("nemotron")
4325 .expect("loaded by filename stem");
4326 assert_eq!(b.applies_to, vec!["nemotron"]);
4327 assert_eq!(b.default_profile.as_deref(), Some("nemotron"));
4328 // a disk file overrides an inline bundle of the same name (last-wins)
4329 cfg.bundles.insert("x".into(), BundleConfig::default());
4330 std::fs::write(dir.path().join("x.toml"), "about = \"from disk\"\n").unwrap();
4331 cfg.merge_bundles_from_dir(dir.path());
4332 assert_eq!(cfg.bundles["x"].about.as_deref(), Some("from disk"));
4333 }
4334
4335 #[test]
4336 fn disk_loadouts_load_per_file_by_stem() {
4337 let dir = tempfile::tempdir().unwrap();
4338 std::fs::write(
4339 dir.path().join("dev-nemotron.toml"),
4340 "provider = \"dgx\"\nmodel = \"nemotron@deep\"\nkit = \"nemotron\"\n",
4341 )
4342 .unwrap();
4343 // a malformed drop-in must be skipped, not break loading
4344 std::fs::write(
4345 dir.path().join("broken.toml"),
4346 "provider = [\"not-a-string\"]\n",
4347 )
4348 .unwrap();
4349 // a non-toml file is ignored
4350 std::fs::write(dir.path().join("README.md"), "not a loadout").unwrap();
4351
4352 let mut cfg = Config::default();
4353 cfg.merge_loadouts_from_dir(dir.path());
4354 assert_eq!(cfg.loadouts.len(), 1, "only the valid .toml loads");
4355 let l = cfg
4356 .loadouts
4357 .get("dev-nemotron")
4358 .expect("loaded by filename stem");
4359 assert_eq!(l.provider.as_deref(), Some("dgx"));
4360 assert_eq!(l.model.as_deref(), Some("nemotron@deep"));
4361 assert_eq!(l.kit.as_deref(), Some("nemotron"));
4362 // a disk file overrides an inline loadout of the same name (last-wins)
4363 cfg.loadouts.insert("x".into(), Loadout::default());
4364 std::fs::write(dir.path().join("x.toml"), "role = \"from-disk\"\n").unwrap();
4365 cfg.merge_loadouts_from_dir(dir.path());
4366 assert_eq!(cfg.loadouts["x"].role.as_deref(), Some("from-disk"));
4367 }
4368
4369 #[test]
4370 fn crew_parses_inline_and_validates_role_references() {
4371 let cfg: Config = toml::from_str(
4372 r#"
4373 [[backends]]
4374 name = "dgx"
4375 endpoint = "http://dgx.local:11434"
4376 model = "qwen3-coder:30b"
4377 tiers = []
4378 [[backends]]
4379 name = "gnuc"
4380 endpoint = "http://localhost:11434"
4381 model = "qwen2.5-coder:3b"
4382 tiers = []
4383
4384 [loadouts.planner]
4385 provider = "dgx"
4386 [loadouts.navigator]
4387 provider = "dgx"
4388 [loadouts.triage]
4389 provider = "gnuc"
4390
4391 [crews.coder]
4392 planner = "planner"
4393 navigator = "navigator"
4394 triage = "triage"
4395 loop = "patch-revise"
4396 [crews.coder.budgets]
4397 max_attempts = 4
4398 require_human_review_on = ["auth", "crypto"]
4399 "#,
4400 )
4401 .unwrap();
4402 let c = &cfg.crews["coder"];
4403 assert_eq!(c.planner, "planner");
4404 assert_eq!(c.navigator.as_deref(), Some("navigator"));
4405 assert_eq!(c.loop_program.as_deref(), Some("patch-revise"));
4406 assert_eq!(c.budgets.as_ref().unwrap().max_attempts, Some(4));
4407 // each role names a known loadout, and each loadout validates
4408 assert!(c.validate(&cfg).is_ok());
4409 }
4410
4411 #[test]
4412 fn crew_rejects_dangling_and_invalid_roles() {
4413 let cfg: Config = toml::from_str(
4414 r#"
4415 [[backends]]
4416 name = "dgx"
4417 endpoint = "http://dgx.local:11434"
4418 model = "m"
4419 tiers = []
4420 [loadouts.planner]
4421 provider = "dgx"
4422 "#,
4423 )
4424 .unwrap();
4425 // dangling role: triage names no loadout
4426 let dangling = Crew {
4427 planner: "planner".into(),
4428 triage: Some("ghost".into()),
4429 ..Default::default()
4430 };
4431 let e = dangling.validate(&cfg).unwrap_err();
4432 assert!(e.contains("triage 'ghost'"), "{e}");
4433 assert!(e.contains("no [loadouts]"), "{e}");
4434 // transitive: a role's loadout has a dangling provider
4435 let mut cfg2 = cfg.clone();
4436 cfg2.loadouts.insert(
4437 "bad".into(),
4438 Loadout {
4439 provider: Some("nope".into()),
4440 ..Default::default()
4441 },
4442 );
4443 let transitive = Crew {
4444 planner: "bad".into(),
4445 ..Default::default()
4446 };
4447 let e = transitive.validate(&cfg2).unwrap_err();
4448 assert!(
4449 e.contains("planner 'bad'") && e.contains("provider 'nope'"),
4450 "{e}"
4451 );
4452 }
4453
4454 #[test]
4455 fn disk_crews_load_per_file_by_stem() {
4456 let dir = tempfile::tempdir().unwrap();
4457 std::fs::write(
4458 dir.path().join("coder.toml"),
4459 "planner = \"planner\"\nnavigator = \"navigator\"\n",
4460 )
4461 .unwrap();
4462 // malformed (missing required `planner`) is skipped, not fatal
4463 std::fs::write(dir.path().join("broken.toml"), "navigator = \"x\"\n").unwrap();
4464 std::fs::write(dir.path().join("README.md"), "not a crew").unwrap();
4465
4466 let mut cfg = Config::default();
4467 cfg.merge_crews_from_dir(dir.path());
4468 assert_eq!(cfg.crews.len(), 1, "only the valid .toml loads");
4469 let c = cfg.crews.get("coder").expect("loaded by filename stem");
4470 assert_eq!(c.planner, "planner");
4471 // disk overrides inline of the same name (last-wins)
4472 cfg.crews.insert(
4473 "coder".into(),
4474 Crew {
4475 planner: "inline".into(),
4476 ..Default::default()
4477 },
4478 );
4479 cfg.merge_crews_from_dir(dir.path());
4480 assert_eq!(cfg.crews["coder"].planner, "planner", "disk wins");
4481 }
4482
4483 #[test]
4484 fn backend_api_axis_defaults_and_parses() {
4485 // Absent → chat/completions (back-compat).
4486 let def: BackendConfig =
4487 toml::from_str("endpoint=\"http://h:1\"\nmodel=\"m\"\nkind=\"openai\"\n").unwrap();
4488 assert_eq!(def.api, OpenAiApi::ChatCompletions);
4489 // Explicit responses opt-in.
4490 let resp: BackendConfig = toml::from_str(
4491 "endpoint=\"http://h:1\"\nmodel=\"gpt-5-codex\"\nkind=\"openai\"\napi=\"responses\"\n",
4492 )
4493 .unwrap();
4494 assert_eq!(resp.api, OpenAiApi::Responses);
4495 // `chat` is an accepted alias for the default.
4496 let alias: BackendConfig =
4497 toml::from_str("endpoint=\"http://h:1\"\nmodel=\"m\"\napi=\"chat\"\n").unwrap();
4498 assert_eq!(alias.api, OpenAiApi::ChatCompletions);
4499 }
4500
4501 #[test]
4502 fn disk_backends_load_per_file_by_stem_and_override_inline() {
4503 let dir = tempfile::tempdir().unwrap();
4504 // A minimal drop-in: name omitted (filename is authoritative), tiers
4505 // omitted (defaults empty), kind omitted (defaults ollama).
4506 std::fs::write(
4507 dir.path().join("dgx1.toml"),
4508 "endpoint = \"http://REDACTED-HOST:11434\"\nmodel = \"qwen3:30b\"\n",
4509 )
4510 .unwrap();
4511 // Malformed (missing required `endpoint`) is skipped, not fatal.
4512 std::fs::write(dir.path().join("broken.toml"), "model = \"x\"\n").unwrap();
4513 std::fs::write(dir.path().join("README.md"), "not a backend").unwrap();
4514
4515 let mut cfg = Config {
4516 // An inline backend of the same name that the drop-in should replace,
4517 // plus an unrelated one that must survive untouched.
4518 backends: vec![
4519 BackendConfig {
4520 name: "dgx1".into(),
4521 endpoint: "http://stale:11434".into(),
4522 model: "old-model".into(),
4523 model_path: None,
4524 tiers: vec![],
4525 kind: BackendKind::Ollama,
4526 api: Default::default(),
4527 api_key_file: None,
4528 api_key_env: None,
4529 },
4530 BackendConfig {
4531 name: "gnuc".into(),
4532 endpoint: "http://gnuc:11434".into(),
4533 model: "qwen2.5-coder:14b".into(),
4534 model_path: None,
4535 tiers: vec![],
4536 kind: BackendKind::Ollama,
4537 api: Default::default(),
4538 api_key_file: None,
4539 api_key_env: None,
4540 },
4541 ],
4542 ..Default::default()
4543 };
4544 cfg.merge_backends_from_dir(dir.path());
4545
4546 // The drop-in replaced the inline dgx1 in place (no duplicate), gnuc kept.
4547 assert_eq!(cfg.backends.len(), 2, "only the valid .toml loads, no dup");
4548 let dgx1 = cfg.backends.iter().find(|b| b.name == "dgx1").unwrap();
4549 assert_eq!(dgx1.endpoint, "http://REDACTED-HOST:11434", "disk wins");
4550 assert_eq!(dgx1.model, "qwen3:30b");
4551 assert_eq!(dgx1.kind, BackendKind::Ollama, "kind defaults to ollama");
4552 assert!(cfg.backends.iter().any(|b| b.name == "gnuc"), "gnuc kept");
4553 }
4554
4555 #[test]
4556 fn disk_dgx_nodes_load_per_file_by_stem_and_override_inline() {
4557 let dir = tempfile::tempdir().unwrap();
4558 // A minimal drop-in: name omitted (filename is authoritative), carries
4559 // the multi-endpoint info a [[backends]] entry can't (vllm + ssh_host).
4560 std::fs::write(
4561 dir.path().join("dgx1.toml"),
4562 "ollama = \"http://REDACTED-HOST:11434\"\n\
4563 vllm = \"http://REDACTED-HOST:8000\"\n\
4564 ssh_host = \"REDACTED-HOST\"\n",
4565 )
4566 .unwrap();
4567 std::fs::write(dir.path().join("README.md"), "not a node").unwrap();
4568
4569 // [dgx] absent → created on first drop-in, with the node populated.
4570 let mut cfg = Config::default();
4571 assert!(cfg.dgx.is_none());
4572 cfg.merge_dgx_nodes_from_dir(dir.path());
4573 let dgx = cfg.dgx.as_ref().expect("[dgx] created from drop-ins");
4574 assert_eq!(dgx.nodes.len(), 1);
4575 let node = &dgx.nodes[0];
4576 assert_eq!(node.name, "dgx1", "name comes from the filename stem");
4577 assert_eq!(node.ollama.as_deref(), Some("http://REDACTED-HOST:11434"));
4578 assert_eq!(node.vllm.as_deref(), Some("http://REDACTED-HOST:8000"));
4579 assert_eq!(node.ssh_host.as_deref(), Some("REDACTED-HOST"));
4580 // A single node resolves as active without an explicit active_node.
4581 assert_eq!(dgx.active_node().unwrap().name, "dgx1");
4582
4583 // Disk replaces an inline node of the same name in place (no duplicate).
4584 cfg.dgx.as_mut().unwrap().nodes[0].ollama = Some("http://stale:1".into());
4585 cfg.merge_dgx_nodes_from_dir(dir.path());
4586 assert_eq!(cfg.dgx.as_ref().unwrap().nodes.len(), 1, "no duplicate");
4587 assert_eq!(
4588 cfg.dgx.unwrap().nodes[0].ollama.as_deref(),
4589 Some("http://REDACTED-HOST:11434"),
4590 "disk wins"
4591 );
4592 }
4593
4594 #[test]
4595 fn backendless_config_deserializes_empty_but_default_keeps_fallback() {
4596 // A config.toml with no [[backends]] must NOT inherit the struct-default
4597 // localhost Ollama — otherwise a drop-in-only setup gets a spurious
4598 // 'ollama' entry alongside its real backends (the migration regression).
4599 let cfg: Config = toml::from_str("providers = []\n").unwrap();
4600 assert!(
4601 cfg.backends.is_empty(),
4602 "absent [[backends]] deserializes to empty, got {:?}",
4603 cfg.backends
4604 );
4605 // But the no-config-file path (Config::default) keeps the fallback.
4606 assert_eq!(Config::default().backends.len(), 1);
4607 assert_eq!(Config::default().backends[0].name, "ollama");
4608 // Inline backends still load normally.
4609 let inline: Config =
4610 toml::from_str("[[backends]]\nname=\"x\"\nendpoint=\"http://h:1\"\nmodel=\"m\"\n")
4611 .unwrap();
4612 assert_eq!(inline.backends.len(), 1);
4613 assert_eq!(inline.backends[0].name, "x");
4614 }
4615
4616 #[test]
4617 fn surface_match_round_trips_lowercase() {
4618 let k: VerifyGateKnobs = toml::from_str("surface_match = \"prefix\"").unwrap();
4619 assert_eq!(k.surface_match, crate::verify_gate::SurfaceMatch::Prefix);
4620 }
4621
4622 #[test]
4623 fn resolve_profile_looks_up_validates_and_errors() {
4624 let cfg: Config = toml::from_str(
4625 r#"
4626 [profiles.nemotron]
4627 techniques = ["verify_gate"]
4628 [profiles.bad]
4629 techniques = ["teleport"]
4630 "#,
4631 )
4632 .unwrap();
4633 // known + valid → the profile
4634 assert!(cfg
4635 .resolve_profile("nemotron")
4636 .unwrap()
4637 .enables("verify_gate"));
4638 // known name but invalid technique → validation error
4639 assert!(cfg.resolve_profile("bad").unwrap_err().contains("teleport"));
4640 // unknown name → no-such-profile error, listing the known ones
4641 let err = cfg.resolve_profile("ghost").unwrap_err();
4642 assert!(
4643 err.contains("no such profile") && err.contains("nemotron"),
4644 "err: {err}"
4645 );
4646 }
4647
4648 #[test]
4649 fn memory_note_nudge_interval_defaults_and_parses() {
4650 // Default: 10 — via Default and when `[memory]` omits the key.
4651 assert_eq!(MemoryConfig::default().note_nudge_interval, 10);
4652 let cfg: MemoryConfig = toml::from_str("provider = \"rolling_window\"").unwrap();
4653 assert_eq!(cfg.note_nudge_interval, 10);
4654 // 0 = nudge off.
4655 let cfg: MemoryConfig = toml::from_str("note_nudge_interval = 0").unwrap();
4656 assert_eq!(cfg.note_nudge_interval, 0);
4657 }
4658
4659 #[test]
4660 fn memory_extract_notes_on_close_defaults_off_and_parses() {
4661 // Default OFF (Step 19.4, #248): the close-time extraction pass is
4662 // optional and costs a completion — nobody pays for it unasked.
4663 assert!(!MemoryConfig::default().extract_notes_on_close);
4664 let cfg: MemoryConfig = toml::from_str("provider = \"rolling_window\"").unwrap();
4665 assert!(!cfg.extract_notes_on_close);
4666 // `[memory] extract_notes_on_close = true` is the opt-in.
4667 let cfg: MemoryConfig = toml::from_str("extract_notes_on_close = true").unwrap();
4668 assert!(cfg.extract_notes_on_close);
4669 }
4670
4671 #[test]
4672 fn memory_disclosure_defaults_to_frozen_and_parses_index() {
4673 // INERT BY DEFAULT (#319): the disclosure facet defaults to Frozen —
4674 // today's behavior, the memory_fetch tool unwired — and only `index`
4675 // opts in to progressive disclosure.
4676 assert_eq!(MemoryConfig::default().disclosure, MemoryDisclosure::Frozen);
4677 let cfg: MemoryConfig = toml::from_str("provider = \"rolling_window\"").unwrap();
4678 assert_eq!(cfg.disclosure, MemoryDisclosure::Frozen);
4679 let cfg: MemoryConfig = toml::from_str("disclosure = \"index\"").unwrap();
4680 assert_eq!(cfg.disclosure, MemoryDisclosure::Index);
4681 let cfg: MemoryConfig = toml::from_str("disclosure = \"frozen\"").unwrap();
4682 assert_eq!(cfg.disclosure, MemoryDisclosure::Frozen);
4683 }
4684
4685 #[test]
4686 fn skill_search_dirs_defaults_to_single_newt_dir() {
4687 let cfg = Config::default();
4688 let dirs = cfg.skill_search_dirs();
4689 assert_eq!(dirs.len(), 1);
4690 assert!(dirs[0].ends_with("skills"));
4691 // The parent component is `.newt`.
4692 assert_eq!(
4693 dirs[0].parent().and_then(|p| p.file_name()),
4694 Some(".newt".as_ref())
4695 );
4696 }
4697
4698 #[test]
4699 fn skill_search_dirs_preserves_configured_order() {
4700 let cfg = Config {
4701 skills: Some(SkillsConfig {
4702 search: vec!["/abs/one".into(), "/abs/two".into()],
4703 }),
4704 ..Config::default()
4705 };
4706 assert_eq!(
4707 cfg.skill_search_dirs(),
4708 vec![PathBuf::from("/abs/one"), PathBuf::from("/abs/two")]
4709 );
4710 }
4711
4712 #[test]
4713 fn skill_search_dirs_expands_tilde() {
4714 let cfg = Config {
4715 skills: Some(SkillsConfig {
4716 search: vec!["~/skills-x".into()],
4717 }),
4718 ..Config::default()
4719 };
4720 let dirs = cfg.skill_search_dirs();
4721 // The final component survives expansion regardless of whether $HOME
4722 // was set; when set, the leading `~` must be gone.
4723 assert!(dirs[0].ends_with("skills-x"));
4724 assert!(!dirs[0].starts_with("~"));
4725 }
4726
4727 #[test]
4728 fn skills_search_round_trips_through_toml() {
4729 let cfg = Config {
4730 skills: Some(SkillsConfig {
4731 search: vec!["~/.newt/skills".into(), "~/.claude/skills".into()],
4732 }),
4733 ..Config::default()
4734 };
4735 let text = toml::to_string_pretty(&cfg).unwrap();
4736 let back: Config = toml::from_str(&text).unwrap();
4737 assert_eq!(
4738 back.skills.unwrap().search,
4739 vec!["~/.newt/skills".to_string(), "~/.claude/skills".to_string()]
4740 );
4741 }
4742 use tempfile::NamedTempFile;
4743
4744 #[test]
4745 fn defaults_are_sensible() {
4746 let cfg = Config::default();
4747 assert_eq!(cfg.backends.len(), 1);
4748 assert_eq!(cfg.providers.len(), 0);
4749 assert_eq!(cfg.default_tier_order.len(), 4);
4750 }
4751
4752 #[test]
4753 fn conversations_config_defaults_to_count_cap() {
4754 let cfg = Config::default();
4755 let conversations = cfg.conversations.unwrap_or_default();
4756 assert_eq!(conversations.max_per_workspace, 100);
4757 // 17.7: auto-resume defaults ON; `resume = false` is the off-switch.
4758 assert!(conversations.resume);
4759 }
4760
4761 #[test]
4762 fn conversations_config_roundtrips_through_toml() {
4763 let cfg: Config = toml::from_str(
4764 r#"
4765[conversations]
4766max_per_workspace = 25
4767"#,
4768 )
4769 .unwrap();
4770
4771 let conversations = cfg.conversations.unwrap_or_default();
4772 assert_eq!(conversations.max_per_workspace, 25);
4773 // Partial [conversations] table: unset keys keep their defaults.
4774 assert!(conversations.resume);
4775 }
4776
4777 #[test]
4778 fn conversations_resume_off_switch_parses() {
4779 let cfg: Config = toml::from_str(
4780 r#"
4781[conversations]
4782resume = false
4783"#,
4784 )
4785 .unwrap();
4786
4787 assert!(!cfg.conversations.unwrap_or_default().resume);
4788 }
4789
4790 #[test]
4791 fn agents_config_default_enabled() {
4792 let cfg = AgentsConfig::default();
4793 assert!(cfg.enabled);
4794 assert_eq!(cfg.path, None);
4795 // A bare Config defaults agents to enabled too.
4796 assert!(Config::default().agents.enabled);
4797 }
4798
4799 #[test]
4800 fn agents_config_roundtrips_with_path() {
4801 let cfg: Config = toml::from_str(
4802 r#"
4803[agents]
4804path = "docs/instructions"
4805"#,
4806 )
4807 .unwrap();
4808 assert!(cfg.agents.enabled);
4809 assert_eq!(cfg.agents.path.as_deref(), Some("docs/instructions"));
4810
4811 // Serialize back out and confirm the path survives.
4812 let text = toml::to_string(&cfg).unwrap();
4813 assert!(text.contains("docs/instructions"));
4814 }
4815
4816 #[test]
4817 fn agents_config_can_be_disabled() {
4818 let cfg: Config = toml::from_str(
4819 r#"
4820[agents]
4821enabled = false
4822"#,
4823 )
4824 .unwrap();
4825 assert!(!cfg.agents.enabled);
4826 assert_eq!(cfg.agents.path, None);
4827 }
4828
4829 #[test]
4830 fn load_happy_path() {
4831 let toml_text = r#"
4832[[backends]]
4833name = "local-ollama"
4834endpoint = "http://localhost:11434"
4835model = "mistral:7b"
4836tiers = ["FAST", "STANDARD"]
4837
4838[[providers]]
4839name = "cloud"
4840command = "newt-cloud-shim"
4841model = "gpt-4.1-mini"
4842env_pass = ["CLOUD_TOKEN"]
4843tiers = ["COMPLEX", "REVIEW"]
4844
4845default_tier_order = ["FAST", "STANDARD", "COMPLEX", "REVIEW"]
4846"#;
4847 let mut f = NamedTempFile::new().unwrap();
4848 f.write_all(toml_text.as_bytes()).unwrap();
4849 f.flush().unwrap();
4850
4851 let cfg = Config::load(f.path()).unwrap();
4852 assert_eq!(cfg.backends.len(), 1);
4853 assert_eq!(cfg.backends[0].name, "local-ollama");
4854 assert_eq!(cfg.backends[0].model, "mistral:7b");
4855 assert_eq!(cfg.backends[0].tiers, vec![Tier::Fast, Tier::Standard]);
4856 assert_eq!(cfg.providers.len(), 1);
4857 assert_eq!(cfg.providers[0].name, "cloud");
4858 assert_eq!(cfg.providers[0].model.as_deref(), Some("gpt-4.1-mini"));
4859 assert_eq!(cfg.providers[0].env_pass, vec!["CLOUD_TOKEN".to_string()]);
4860 }
4861
4862 #[test]
4863 fn provider_model_is_optional_for_legacy_configs() {
4864 let cfg: Config = toml::from_str(
4865 r#"
4866[[providers]]
4867name = "legacy-cloud"
4868command = "newt-cloud-shim"
4869env_pass = ["CLOUD_TOKEN"]
4870tiers = ["COMPLEX"]
4871"#,
4872 )
4873 .unwrap();
4874
4875 assert_eq!(cfg.providers.len(), 1);
4876 assert_eq!(cfg.providers[0].model, None);
4877 }
4878
4879 #[test]
4880 fn missing_file_returns_io_error() {
4881 let result = Config::load(Path::new("/tmp/newt-does-not-exist-12345.toml"));
4882 assert!(result.is_err());
4883 let err = result.unwrap_err();
4884 assert!(
4885 matches!(err, NewtError::Io(_)),
4886 "expected Io error, got: {err:?}"
4887 );
4888 }
4889
4890 #[test]
4891 fn malformed_toml_returns_config_error() {
4892 let mut f = NamedTempFile::new().unwrap();
4893 f.write_all(b"{{{{").unwrap();
4894 f.flush().unwrap();
4895
4896 let result = Config::load(f.path());
4897 assert!(result.is_err());
4898 let err = result.unwrap_err();
4899 assert!(
4900 matches!(err, NewtError::Config(_)),
4901 "expected Config error, got: {err:?}"
4902 );
4903 }
4904
4905 #[test]
4906 fn resolve_returns_default_when_no_file() {
4907 // Use a temp dir as cwd and clear env to ensure no candidates match.
4908 let dir = tempfile::tempdir().unwrap();
4909
4910 // Save & clear environment to isolate the test.
4911 let saved_config = std::env::var("NEWT_CONFIG").ok();
4912 let saved_home = std::env::var("HOME").ok();
4913 std::env::remove_var("NEWT_CONFIG");
4914 std::env::set_var("HOME", dir.path());
4915
4916 // Run resolve from inside the temp dir so ./newt.toml won't exist.
4917 let prev_dir = std::env::current_dir().unwrap();
4918 std::env::set_current_dir(dir.path()).unwrap();
4919
4920 let cfg = Config::resolve().unwrap();
4921
4922 // Restore environment.
4923 std::env::set_current_dir(prev_dir).unwrap();
4924 if let Some(v) = saved_home {
4925 std::env::set_var("HOME", v);
4926 }
4927 if let Some(v) = saved_config {
4928 std::env::set_var("NEWT_CONFIG", v);
4929 }
4930
4931 assert_eq!(cfg.backends.len(), 1);
4932 assert_eq!(cfg.backends[0].name, "ollama");
4933 }
4934
4935 // --- Project-local `.newt/config.toml` layering (issue #222) ---
4936
4937 #[test]
4938 fn merge_toml_recurses_tables_and_replaces_scalars() {
4939 let mut base: toml::Value = toml::from_str(
4940 "a = 1\nb = 2\n[tui]\nmid_loop_trim_threshold = 40\nmax_tool_rounds = 25\n",
4941 )
4942 .unwrap();
4943 let overlay: toml::Value =
4944 toml::from_str("b = 99\nc = 3\n[tui]\nmax_tool_rounds = 5\n").unwrap();
4945 merge_toml(&mut base, overlay, ArrayMergeStrategy::Replace);
4946 // Scalar overridden, untouched scalar kept, new scalar added.
4947 assert_eq!(base["a"].as_integer(), Some(1));
4948 assert_eq!(base["b"].as_integer(), Some(99));
4949 assert_eq!(base["c"].as_integer(), Some(3));
4950 // Table merged recursively: overridden key wins, sibling preserved.
4951 assert_eq!(base["tui"]["max_tool_rounds"].as_integer(), Some(5));
4952 assert_eq!(
4953 base["tui"]["mid_loop_trim_threshold"].as_integer(),
4954 Some(40)
4955 );
4956 }
4957
4958 #[test]
4959 fn merge_toml_replaces_arrays_wholesale_by_default() {
4960 let mut base: toml::Value = toml::from_str("models = [\"a\", \"b\", \"c\"]").unwrap();
4961 let overlay: toml::Value = toml::from_str("models = [\"x\"]").unwrap();
4962 merge_toml(&mut base, overlay, ArrayMergeStrategy::Replace);
4963 let arr = base["models"].as_array().unwrap();
4964 assert_eq!(arr.len(), 1, "replace strategy swaps the array");
4965 assert_eq!(arr[0].as_str(), Some("x"));
4966 }
4967
4968 #[test]
4969 fn merge_toml_appends_arrays_when_strategy_is_append() {
4970 let mut base: toml::Value = toml::from_str("models = [\"a\", \"b\"]").unwrap();
4971 let overlay: toml::Value = toml::from_str("models = [\"x\"]").unwrap();
4972 merge_toml(&mut base, overlay, ArrayMergeStrategy::Append);
4973 let arr = base["models"].as_array().unwrap();
4974 // Global entries first, then the project's appended.
4975 let got: Vec<&str> = arr.iter().filter_map(|v| v.as_str()).collect();
4976 assert_eq!(got, vec!["a", "b", "x"]);
4977 }
4978
4979 #[test]
4980 fn array_merge_strategy_project_wins_then_base_then_default() {
4981 let append: toml::Value = toml::from_str("[merge]\narrays = \"append\"\n").unwrap();
4982 let replace: toml::Value = toml::from_str("[merge]\narrays = \"replace\"\n").unwrap();
4983 let none: toml::Value = toml::from_str("x = 1").unwrap();
4984 // Project setting wins over the base.
4985 assert_eq!(
4986 array_merge_strategy(&append, &replace),
4987 ArrayMergeStrategy::Append
4988 );
4989 // Falls back to the base when the project is silent.
4990 assert_eq!(
4991 array_merge_strategy(&none, &append),
4992 ArrayMergeStrategy::Append
4993 );
4994 // Defaults to Replace when neither sets it.
4995 assert_eq!(
4996 array_merge_strategy(&none, &none),
4997 ArrayMergeStrategy::Replace
4998 );
4999 // Unrecognized values are ignored (fall through to default).
5000 let bogus: toml::Value = toml::from_str("[merge]\narrays = \"sideways\"\n").unwrap();
5001 assert_eq!(
5002 array_merge_strategy(&bogus, &none),
5003 ArrayMergeStrategy::Replace
5004 );
5005 }
5006
5007 #[test]
5008 fn append_strategy_adds_project_mcp_server_to_global() {
5009 // The motivating case from issue #222: a project registers an extra
5010 // local stdio MCP server without redefining the global one.
5011 let global = "\
5012[merge]
5013arrays = \"append\"
5014
5015[[mcp_servers]]
5016name = \"global-fs\"
5017command = \"mcp-fs\"
5018";
5019 let project = "\
5020[[mcp_servers]]
5021name = \"project-fs\"
5022command = \"mcp-fs\"
5023args = [\"--root\", \".\"]
5024";
5025 let mut merged: toml::Value = toml::from_str(global).unwrap();
5026 let proj_val: toml::Value = toml::from_str(project).unwrap();
5027 let strategy = array_merge_strategy(&proj_val, &merged);
5028 assert_eq!(strategy, ArrayMergeStrategy::Append);
5029 merge_toml(&mut merged, proj_val, strategy);
5030 let cfg: Config = merged.try_into().unwrap();
5031 let names: Vec<&str> = cfg.mcp_servers.iter().map(|m| m.name.as_str()).collect();
5032 assert_eq!(names, vec!["global-fs", "project-fs"]);
5033 }
5034
5035 #[test]
5036 fn find_project_config_walks_up_and_stops_before_home() {
5037 let home = tempfile::tempdir().unwrap();
5038 // home/proj/sub with a project config at home/proj/.newt/config.toml
5039 let proj = home.path().join("proj");
5040 let sub = proj.join("sub");
5041 std::fs::create_dir_all(&sub).unwrap();
5042 std::fs::create_dir_all(proj.join(".newt")).unwrap();
5043 std::fs::write(proj.join(".newt").join("config.toml"), "x = 1").unwrap();
5044 // Also place a (global) config at home/.newt to prove it's NOT returned.
5045 std::fs::create_dir_all(home.path().join(".newt")).unwrap();
5046 std::fs::write(home.path().join(".newt").join("config.toml"), "x = 9").unwrap();
5047
5048 let found = find_project_config_from(&sub, Some(home.path()));
5049 assert_eq!(found, Some(proj.join(".newt").join("config.toml")));
5050
5051 // From a dir with no project config above it (but under home), nothing.
5052 let bare = home.path().join("empty");
5053 std::fs::create_dir_all(&bare).unwrap();
5054 assert_eq!(find_project_config_from(&bare, Some(home.path())), None);
5055 }
5056
5057 #[test]
5058 fn project_config_deep_merges_over_global() {
5059 // global config: a backend + a tui block.
5060 let global = "\
5061[[backends]]
5062name = \"ollama\"
5063endpoint = \"http://localhost:11434\"
5064model = \"llama3\"
5065tiers = []
5066kind = \"ollama\"
5067
5068[tui]
5069mid_loop_trim_threshold = 40
5070max_tool_rounds = 25
5071";
5072 // project override: change max_tool_rounds only.
5073 let project = "[tui]\nmax_tool_rounds = 7\n";
5074
5075 let mut merged: toml::Value = toml::from_str(global).unwrap();
5076 merge_toml(
5077 &mut merged,
5078 toml::from_str(project).unwrap(),
5079 ArrayMergeStrategy::Replace,
5080 );
5081 let cfg: Config = merged.try_into().unwrap();
5082
5083 // Overridden value wins…
5084 assert_eq!(cfg.tui.as_ref().unwrap().max_tool_rounds, 7);
5085 // …sibling key preserved from global…
5086 assert_eq!(cfg.tui.as_ref().unwrap().mid_loop_trim_threshold, 40);
5087 // …and the global backend survived (not in the override).
5088 assert_eq!(cfg.backends.len(), 1);
5089 assert_eq!(cfg.backends[0].name, "ollama");
5090 }
5091
5092 #[test]
5093 fn config_default_has_no_dgx() {
5094 assert!(Config::default().dgx.is_none());
5095 }
5096
5097 #[test]
5098 fn to_redacted_toml_hides_mcp_secrets_but_keeps_shape() {
5099 let cfg: Config = toml::from_str(
5100 r#"
5101 [[backends]]
5102 name = "remote"
5103 endpoint = "http://remote:8000"
5104 model = "qwen3:32b"
5105 tiers = []
5106 kind = "openai"
5107 api_key_file = "~/.newt/openai.key"
5108
5109 [[mcp_servers]]
5110 name = "gh"
5111 type = "http"
5112 url = "https://api.example/mcp"
5113 [mcp_servers.headers]
5114 Authorization = "Bearer sk-super-secret-token"
5115 [mcp_servers.env]
5116 GH_TOKEN = "ghp_rawsecretvalue"
5117 RUST_LOG = "debug"
5118 "#,
5119 )
5120 .unwrap();
5121
5122 let dump = cfg.to_redacted_toml().unwrap();
5123 // The raw secret VALUES never appear…
5124 assert!(
5125 !dump.contains("sk-super-secret-token"),
5126 "header secret leaked:\n{dump}"
5127 );
5128 assert!(
5129 !dump.contains("ghp_rawsecretvalue"),
5130 "env secret leaked:\n{dump}"
5131 );
5132 // …but the KEYS and the placeholder do, so the audit shows the shape.
5133 assert!(dump.contains("Authorization"));
5134 assert!(dump.contains("GH_TOKEN"));
5135 assert!(dump.contains(Config::REDACTED));
5136 // Secret *references* (a path) are kept — they name where a secret lives.
5137 assert!(
5138 dump.contains("~/.newt/openai.key"),
5139 "api_key_file reference kept"
5140 );
5141 // Non-secret structure is intact.
5142 assert!(dump.contains("http://remote:8000"));
5143 }
5144
5145 #[test]
5146 fn config_with_dgx_roundtrips() {
5147 let cfg = Config {
5148 dgx: Some(crate::dgx::DgxConfig::home_template()),
5149 ..Config::default()
5150 };
5151 let text = toml::to_string_pretty(&cfg).unwrap();
5152 let back = toml::from_str::<Config>(&text).unwrap();
5153 let dgx = back.dgx.expect("dgx should round-trip");
5154 assert_eq!(dgx.active_node.as_deref(), Some("home"));
5155 assert_eq!(dgx.nodes.len(), 1);
5156 assert_eq!(dgx.formations.len(), 2);
5157 }
5158
5159 // --- ToolPermissions / to_caveats ---
5160
5161 #[test]
5162 fn workspace_dev_allows_cargo_and_just() {
5163 let perms = ToolPermissions::default(); // WorkspaceDev
5164 let cav = perms.to_caveats("/workspace");
5165 assert!(cav.permits_exec("cargo"), "cargo must be allowed");
5166 assert!(cav.permits_exec("just"), "just must be allowed");
5167 assert!(cav.permits_exec("git"), "git must be allowed");
5168 }
5169
5170 #[test]
5171 fn workspace_dev_blocks_rm_and_mv() {
5172 let perms = ToolPermissions::default();
5173 let cav = perms.to_caveats("/workspace");
5174 assert!(!cav.permits_exec("rm"), "rm must be blocked");
5175 assert!(!cav.permits_exec("mv"), "mv must be blocked");
5176 assert!(!cav.permits_exec("sudo"), "sudo must be blocked");
5177 }
5178
5179 #[test]
5180 fn workspace_dev_allows_common_dev_tools() {
5181 // Regression: these were denied under the default preset even though
5182 // they're the same risk tier as cargo/git (issue #149). `gh` in
5183 // particular is authenticated outside but was blocked in-agent.
5184 let cav = ToolPermissions::default().to_caveats("/workspace");
5185 for tool in [
5186 "gh", "python", "python3", "pip", "npm", "node", "make", "jq", "curl", "awk", "sed",
5187 "cut", "xargs", "which", "env",
5188 ] {
5189 assert!(cav.permits_exec(tool), "`{tool}` must be allowed");
5190 }
5191 // Adding tools must NOT escalate to full access — destructive commands
5192 // outside the allowlist stay blocked.
5193 assert!(!cav.permits_exec("rm"), "rm must still be blocked");
5194 assert!(!cav.permits_exec("sudo"), "sudo must still be blocked");
5195 }
5196
5197 #[test]
5198 fn workspace_dev_allows_extra_exec() {
5199 let perms = ToolPermissions {
5200 preset: PermissionPreset::WorkspaceDev,
5201 extra_exec: vec!["bacon".into(), "make".into()],
5202 net: vec![],
5203 prompt: false,
5204 };
5205 let cav = perms.to_caveats("/workspace");
5206 assert!(cav.permits_exec("bacon"));
5207 assert!(cav.permits_exec("make"));
5208 assert!(!cav.permits_exec("rm")); // extra_exec does not weaken the block
5209 }
5210
5211 #[test]
5212 fn read_only_blocks_writes_and_exec() {
5213 let perms = ToolPermissions {
5214 preset: PermissionPreset::ReadOnly,
5215 extra_exec: vec![],
5216 net: vec![],
5217 prompt: false,
5218 };
5219 let cav = perms.to_caveats("/workspace");
5220 assert!(!cav.permits_fs_write("/workspace/src/main.rs"));
5221 assert!(!cav.permits_exec("cargo"));
5222 assert!(cav.permits_fs_read("/workspace/src/main.rs"));
5223 }
5224
5225 #[test]
5226 fn workspace_edit_allows_write_blocks_exec() {
5227 let perms = ToolPermissions {
5228 preset: PermissionPreset::WorkspaceEdit,
5229 extra_exec: vec![],
5230 net: vec![],
5231 prompt: false,
5232 };
5233 let cav = perms.to_caveats("/workspace");
5234 assert!(!cav.permits_exec("cargo"));
5235 // The caveat stores workspace root; prefix matching is in the TUI layer.
5236 // Here we just verify the lattice is set up correctly (not All, not none).
5237 use crate::caveats::Scope;
5238 assert!(matches!(cav.fs_write, Scope::Only(_)));
5239 }
5240
5241 #[test]
5242 fn full_access_is_top() {
5243 let perms = ToolPermissions {
5244 preset: PermissionPreset::FullAccess,
5245 extra_exec: vec![],
5246 net: vec![],
5247 prompt: false,
5248 };
5249 let cav = perms.to_caveats("/workspace");
5250 assert_eq!(cav, crate::caveats::Caveats::top());
5251 }
5252
5253 #[test]
5254 fn net_allowlist_controls_the_net_axis() {
5255 use crate::caveats::Scope;
5256
5257 // Default (empty `net`) => no network: web_fetch is denied.
5258 let none = ToolPermissions::default().to_caveats("/ws");
5259 assert!(
5260 matches!(none.net, Scope::Only(ref s) if s.is_empty()),
5261 "empty net config must yield an empty (deny-all) net scope"
5262 );
5263
5264 // Explicit host allowlist — works under ANY preset (here ReadOnly), so
5265 // web access does not require granting writes/exec.
5266 let hosts = ToolPermissions {
5267 preset: PermissionPreset::ReadOnly,
5268 extra_exec: vec![],
5269 net: vec!["docs.rs".into(), "github.com".into()],
5270 prompt: false,
5271 }
5272 .to_caveats("/ws");
5273 assert!(
5274 matches!(hosts.net, Scope::Only(ref s) if s.contains("docs.rs") && s.contains("github.com")),
5275 "explicit hosts must populate the net allowlist"
5276 );
5277
5278 // A single "*" grants all hosts (still SSRF-screened by the web tool).
5279 let all = ToolPermissions {
5280 preset: PermissionPreset::WorkspaceDev,
5281 extra_exec: vec![],
5282 net: vec!["*".into()],
5283 prompt: false,
5284 }
5285 .to_caveats("/ws");
5286 assert!(
5287 matches!(all.net, Scope::All),
5288 "a `*` entry must grant the whole net axis"
5289 );
5290 }
5291
5292 #[test]
5293 fn custom_is_workspace_dev_not_top() {
5294 // Regression: editing the exec allowlist auto-flips the preset to
5295 // `Custom`, which used to map to `Caveats::top()` — a silent escalation
5296 // from "add one command" to "full access". `Custom` must now carry
5297 // WorkspaceDev authority plus the extra commands, never `top()`.
5298 let custom = ToolPermissions {
5299 preset: PermissionPreset::Custom,
5300 extra_exec: vec!["bacon".into()],
5301 net: vec![],
5302 prompt: false,
5303 }
5304 .to_caveats("/workspace");
5305 assert_ne!(
5306 custom,
5307 crate::caveats::Caveats::top(),
5308 "Custom must not be full access"
5309 );
5310 assert!(custom.permits_exec("cargo"), "workspace-dev tools allowed");
5311 assert!(custom.permits_exec("bacon"), "extra_exec command allowed");
5312 assert!(!custom.permits_exec("rm"), "non-allowlisted command denied");
5313 // Identical to WorkspaceDev with the same extras.
5314 let workspace_dev = ToolPermissions {
5315 preset: PermissionPreset::WorkspaceDev,
5316 extra_exec: vec!["bacon".into()],
5317 net: vec![],
5318 prompt: false,
5319 }
5320 .to_caveats("/workspace");
5321 assert_eq!(
5322 custom, workspace_dev,
5323 "Custom carries WorkspaceDev authority + extras"
5324 );
5325 }
5326
5327 #[test]
5328 fn preset_toggle_cycles() {
5329 assert_eq!(
5330 PermissionPreset::ReadOnly.toggle(),
5331 PermissionPreset::WorkspaceEdit
5332 );
5333 assert_eq!(
5334 PermissionPreset::WorkspaceEdit.toggle(),
5335 PermissionPreset::WorkspaceDev
5336 );
5337 assert_eq!(
5338 PermissionPreset::WorkspaceDev.toggle(),
5339 PermissionPreset::FullAccess
5340 );
5341 assert_eq!(
5342 PermissionPreset::FullAccess.toggle(),
5343 PermissionPreset::ReadOnly
5344 );
5345 }
5346
5347 #[test]
5348 fn tool_permissions_toml_roundtrip() {
5349 let perms = ToolPermissions {
5350 preset: PermissionPreset::WorkspaceDev,
5351 extra_exec: vec!["bacon".into()],
5352 net: vec![],
5353 prompt: false,
5354 };
5355 let toml = toml::to_string(&perms).unwrap();
5356 assert!(toml.contains("workspace_dev"));
5357 assert!(toml.contains("bacon"));
5358 let back: ToolPermissions = toml::from_str(&toml).unwrap();
5359 assert_eq!(back, perms);
5360 }
5361
5362 // ---- #904: comment-preserving "allow permanently" net writer ----
5363
5364 #[test]
5365 fn with_net_host_creates_table_from_empty_and_scope_includes_host() {
5366 let out = Config::with_net_host("", "github.com").unwrap();
5367 // The written TOML parses back and its net scope now permits the host.
5368 let cfg: Config = toml::from_str(&out).unwrap();
5369 let perms = cfg.tui.unwrap().permissions;
5370 assert!(perms.net.contains(&"github.com".to_string()));
5371 assert!(
5372 matches!(perms.net_scope(), crate::caveats::Scope::Only(ref s) if s.contains("github.com")),
5373 "net_scope must permit the granted host"
5374 );
5375 }
5376
5377 #[test]
5378 fn with_net_host_preserves_comments_and_other_keys() {
5379 let original = "\
5380# my hand-authored config — keep this comment
5381[tui.permissions]
5382preset = \"workspace_dev\" # inline comment
5383net = [\"already.example.com\"]
5384";
5385 let out = Config::with_net_host(original, "github.com").unwrap();
5386 // Comments survive (the whole point vs Config::save).
5387 assert!(
5388 out.contains("# my hand-authored config"),
5389 "top comment lost: {out}"
5390 );
5391 assert!(
5392 out.contains("# inline comment"),
5393 "inline comment lost: {out}"
5394 );
5395 // The pre-existing host is kept and the new one appended.
5396 assert!(out.contains("already.example.com"));
5397 assert!(out.contains("github.com"));
5398 // preset key untouched.
5399 assert!(out.contains("workspace_dev"));
5400 }
5401
5402 #[test]
5403 fn with_net_host_is_idempotent_no_duplicate() {
5404 let once = Config::with_net_host("", "github.com").unwrap();
5405 let twice = Config::with_net_host(&once, "github.com").unwrap();
5406 assert_eq!(
5407 twice.matches("github.com").count(),
5408 1,
5409 "duplicated host: {twice}"
5410 );
5411 }
5412
5413 #[test]
5414 fn with_net_host_rejects_invalid_toml() {
5415 assert!(Config::with_net_host("this = = not toml", "github.com").is_err());
5416 }
5417
5418 fn openai_backend(api_key_file: Option<String>, api_key_env: Option<String>) -> BackendConfig {
5419 BackendConfig {
5420 name: "remote".into(),
5421 endpoint: "https://example.test".into(),
5422 model: "some-model".into(),
5423 model_path: None,
5424 tiers: vec![Tier::Fast],
5425 kind: BackendKind::Openai,
5426 api: Default::default(),
5427 api_key_file,
5428 api_key_env,
5429 }
5430 }
5431
5432 #[test]
5433 fn backend_kind_defaults_to_ollama_when_absent() {
5434 let toml = r#"
5435 [[backends]]
5436 name = "local"
5437 endpoint = "http://localhost:8000"
5438 model = "m"
5439 tiers = ["FAST"]
5440 "#;
5441 let cfg: Config = toml::from_str(toml).unwrap();
5442 assert_eq!(cfg.backends[0].kind, BackendKind::Ollama);
5443 assert!(cfg.backends[0].api_key_file.is_none());
5444 assert!(cfg.backends[0].api_key_env.is_none());
5445 }
5446
5447 #[test]
5448 fn backend_kind_parses_openai_and_aliases() {
5449 for kind_str in ["openai", "vllm", "openai-compatible"] {
5450 let toml = format!(
5451 "[[backends]]\nname=\"x\"\nendpoint=\"http://e\"\nmodel=\"m\"\ntiers=[\"FAST\"]\nkind=\"{kind_str}\"\n"
5452 );
5453 let cfg: Config = toml::from_str(&toml).unwrap();
5454 assert_eq!(cfg.backends[0].kind, BackendKind::Openai, "kind={kind_str}");
5455 }
5456 }
5457
5458 #[test]
5459 fn backend_kind_label_is_protocol_name() {
5460 assert_eq!(BackendKind::Ollama.label(), "ollama");
5461 assert_eq!(BackendKind::Openai.label(), "openai");
5462 }
5463
5464 #[test]
5465 fn backend_config_roundtrips_auth_fields() {
5466 let cfg = openai_backend(Some("~/.newt/token".into()), Some("MY_TOKEN".into()));
5467 let toml = toml::to_string(&cfg).unwrap();
5468 assert!(toml.contains("kind = \"openai\""));
5469 assert!(toml.contains("api_key_file"));
5470 assert!(toml.contains("api_key_env"));
5471 let back: BackendConfig = toml::from_str(&toml).unwrap();
5472 assert_eq!(back.kind, BackendKind::Openai);
5473 assert_eq!(back.api_key_file.as_deref(), Some("~/.newt/token"));
5474 assert_eq!(back.api_key_env.as_deref(), Some("MY_TOKEN"));
5475 }
5476
5477 #[test]
5478 fn resolve_api_key_reads_first_nonempty_line_of_file() {
5479 let mut f = tempfile::NamedTempFile::new().unwrap();
5480 // Leading blank line + surrounding whitespace must be skipped/trimmed.
5481 write!(f, "\n secret-token-123 \nignored-second-line\n").unwrap();
5482 let cfg = openai_backend(Some(f.path().to_string_lossy().into_owned()), None);
5483 assert_eq!(cfg.resolve_api_key().as_deref(), Some("secret-token-123"));
5484 }
5485
5486 #[test]
5487 fn resolve_api_key_env_takes_precedence_over_file() {
5488 let var = "NEWT_TEST_API_KEY_PRECEDENCE";
5489 std::env::set_var(var, " from-env ");
5490 let mut f = tempfile::NamedTempFile::new().unwrap();
5491 writeln!(f, "from-file").unwrap();
5492 let cfg = openai_backend(
5493 Some(f.path().to_string_lossy().into_owned()),
5494 Some(var.into()),
5495 );
5496 assert_eq!(cfg.resolve_api_key().as_deref(), Some("from-env"));
5497 std::env::remove_var(var);
5498 }
5499
5500 #[test]
5501 fn resolve_api_key_none_when_unconfigured() {
5502 assert_eq!(openai_backend(None, None).resolve_api_key(), None);
5503 }
5504
5505 #[test]
5506 fn resolve_api_key_none_for_missing_file() {
5507 let cfg = openai_backend(Some("/no/such/newt/token/file".into()), None);
5508 assert_eq!(cfg.resolve_api_key(), None);
5509 }
5510
5511 #[test]
5512 fn expand_tilde_expands_home_and_passes_through() {
5513 let home = home_dir().expect("HOME set in test env");
5514 assert_eq!(expand_tilde("~/foo/bar"), home.join("foo/bar"));
5515 assert_eq!(expand_tilde("~"), home);
5516 assert_eq!(expand_tilde("/abs/path"), PathBuf::from("/abs/path"));
5517 assert_eq!(
5518 expand_tilde("relative/path"),
5519 PathBuf::from("relative/path")
5520 );
5521 }
5522
5523 #[test]
5524 fn default_max_tool_rounds_is_40() {
5525 // #<issue>: raised from 25 — a modest safety margin alongside
5526 // workflow_grace_rounds and the diagnose_failure delegate hint, not a
5527 // substitute for either. The function default and the struct default
5528 // agree on 40.
5529 assert_eq!(default_max_tool_rounds(), 40);
5530 assert_eq!(TuiConfig::default().max_tool_rounds, 40);
5531 assert_eq!(default_workflow_grace_rounds(), 5);
5532 assert_eq!(TuiConfig::default().workflow_grace_rounds, 5);
5533 }
5534
5535 #[test]
5536 fn tui_max_tool_rounds_defaults_when_field_absent() {
5537 // An empty `[tui]` table => serde default kicks in => 40.
5538 let toml = r#"
5539 [tui]
5540 "#;
5541 let cfg: Config = toml::from_str(toml).unwrap();
5542 assert_eq!(cfg.tui.unwrap().max_tool_rounds, 40);
5543 }
5544
5545 #[test]
5546 fn tui_max_tool_rounds_can_be_overridden() {
5547 let toml = r#"
5548 [tui]
5549 max_tool_rounds = 7
5550 "#;
5551 let cfg: Config = toml::from_str(toml).unwrap();
5552 assert_eq!(cfg.tui.unwrap().max_tool_rounds, 7);
5553 }
5554
5555 #[test]
5556 fn tui_workflow_grace_rounds_can_be_overridden_or_disabled() {
5557 let cfg: Config = toml::from_str(
5558 r#"
5559 [tui]
5560 workflow_grace_rounds = 9
5561 "#,
5562 )
5563 .unwrap();
5564 assert_eq!(cfg.tui.unwrap().workflow_grace_rounds, 9);
5565
5566 let disabled: Config = toml::from_str(
5567 r#"
5568 [tui]
5569 workflow_grace_rounds = 0
5570 "#,
5571 )
5572 .unwrap();
5573 assert_eq!(disabled.tui.unwrap().workflow_grace_rounds, 0);
5574 }
5575
5576 #[test]
5577 fn model_tuning_parses_from_toml() {
5578 let toml = r#"
5579 [[model_tuning]]
5580 model = "nemotron3:33b"
5581 num_ctx = 24576
5582 mid_loop_trim_threshold = 12
5583 max_tool_rounds = 20
5584 workflow_grace_rounds = 8
5585
5586 [[model_tuning]]
5587 model = "qwen3-coder:30b"
5588 num_ctx = 65536
5589 "#;
5590 let cfg: Config = toml::from_str(toml).unwrap();
5591 assert_eq!(cfg.model_tuning.len(), 2);
5592
5593 let nemo = cfg.find_model_tuning("nemotron3:33b").unwrap();
5594 assert_eq!(nemo.num_ctx, Some(24576));
5595 assert_eq!(nemo.mid_loop_trim_threshold, Some(12));
5596 assert_eq!(nemo.max_tool_rounds, Some(20));
5597 assert_eq!(nemo.workflow_grace_rounds, Some(8));
5598
5599 let qwen = cfg.find_model_tuning("qwen3-coder:30b").unwrap();
5600 assert_eq!(qwen.num_ctx, Some(65536));
5601 assert_eq!(qwen.mid_loop_trim_threshold, None);
5602 assert_eq!(qwen.workflow_grace_rounds, None);
5603 }
5604
5605 #[test]
5606 fn model_tuning_find_returns_none_for_unknown_model() {
5607 let cfg = Config::default();
5608 assert!(cfg.find_model_tuning("nonexistent:7b").is_none());
5609 }
5610
5611 #[test]
5612 fn model_tuning_partial_fields_are_optional() {
5613 let toml = r#"
5614 [[model_tuning]]
5615 model = "llama3.1:8b"
5616 "#;
5617 let cfg: Config = toml::from_str(toml).unwrap();
5618 let entry = cfg.find_model_tuning("llama3.1:8b").unwrap();
5619 assert_eq!(entry.num_ctx, None);
5620 assert_eq!(entry.mid_loop_trim_threshold, None);
5621 assert_eq!(entry.max_tool_rounds, None);
5622 assert_eq!(entry.workflow_grace_rounds, None);
5623 }
5624
5625 // ---- #726: [tools] max_output_tokens ----
5626
5627 #[test]
5628 fn tools_max_output_tokens_defaults_to_10k_when_absent() {
5629 // No `[tools]` section ⇒ the built-in default budget.
5630 let cfg: Config = toml::from_str("").unwrap();
5631 assert!(cfg.tools.is_none());
5632 assert_eq!(cfg.max_output_tokens(), 10_000);
5633 assert_eq!(cfg.output_head_tokens(), 1_500);
5634 assert_eq!(Config::default().max_output_tokens(), 10_000);
5635 assert_eq!(Config::default().output_head_tokens(), 1_500);
5636 }
5637
5638 #[test]
5639 fn tools_max_output_tokens_parses_an_override() {
5640 let cfg: Config = toml::from_str(
5641 r#"
5642 [tools]
5643 max_output_tokens = 4096
5644 output_head_tokens = 512
5645 "#,
5646 )
5647 .unwrap();
5648 assert_eq!(cfg.tools.as_ref().unwrap().max_output_tokens, 4096);
5649 assert_eq!(cfg.tools.as_ref().unwrap().output_head_tokens, 512);
5650 assert_eq!(cfg.max_output_tokens(), 4096);
5651 assert_eq!(cfg.output_head_tokens(), 512);
5652 }
5653
5654 #[test]
5655 fn tools_config_default_field_is_the_shared_default() {
5656 // A `[tools]` table that omits the key falls back to the default fn.
5657 let cfg: Config = toml::from_str("[tools]\n").unwrap();
5658 assert_eq!(cfg.max_output_tokens(), 10_000);
5659 assert_eq!(cfg.output_head_tokens(), 1_500);
5660 }
5661
5662 #[test]
5663 fn tools_max_output_tokens_zero_is_a_valid_no_cap() {
5664 let cfg: Config = toml::from_str("[tools]\nmax_output_tokens = 0\n").unwrap();
5665 assert_eq!(cfg.max_output_tokens(), 0);
5666 }
5667}