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