use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
use crate::error::{NewtError, Result};
use crate::router::Tier;
pub use api_surface::{ApiSurfaceConfig, LanguagePack, SymbolRule};
pub(crate) use backend::resolve_api_key_common;
pub use backend::{
derive_serving, set_cli_backend_override, BackendConfig, BackendDestination, BackendKind,
BackendOverride, BackendProvenance, BackendRef, BackendRequest, BackendResolutionReceipt,
BackendSlots, DeclaredBackend, Engine, ManagedMode, OpenAiApi, RequestMode, Serving,
};
pub use context::{
compaction_trigger_is_session_pinned, input_percentage_ceiling, normalize_input_ceiling_pct,
session_compaction_trigger_policy, CompactionTriggerPolicy, ContextConfig, ContextFeature,
ContextFeatureSet, ContextFeatures, ContextManager,
};
pub use crew::{Crew, CrewBudgets, CrewPolicyConfig};
pub use dropin::*;
pub(crate) use layering::{
expand_tilde, find_ancestor_dir, find_project_config_from, find_project_file_from, home_dir,
merge_project_overlay, merge_toml, strip_control_plane,
};
pub use layering::{ArrayMergeStrategy, MergeConfig};
pub use loadout::{Loadout, LoadoutSettings};
pub use memory::{MemoryConfig, MemoryDisclosure, MemoryProviderKind};
pub use newt_tuner::{ModelTuning, OverflowRetry};
pub use permissions::{ModeConfig, PermissionPreset, ToolPermissions};
pub use presentation::{
markdown_is_session_pinned, session_markdown_mode, session_spill_lines,
set_session_spill_lines, ColorMode, EditMode, FooterMode, MarkdownMode, ThinkingMode,
};
pub use profile::{
BundleConfig, PickVia, ProfileConfig, ProfilePick, RetryKnobs, VerifyGateKnobs,
KNOWN_TECHNIQUES,
};
pub use semantic::{OnEmbedFailure, SemanticConfig};
pub use skills::SkillsConfig;
pub(crate) use smart_harness::{normalize_path, resolve_uncreated_path, validate_stable_anchor};
pub use smart_harness::{HarnessLaunch, SmartHarnessConfig};
pub use summarizer::SummarizerConfig;
pub use tool_exposure::{ExposureProfile, ToolExposureConfig};
pub use tools::ToolsConfig;
mod command_budget;
pub use command_budget::RunCommandBudget;
mod api_surface;
mod backend;
mod context;
mod crew;
mod dropin;
mod layering;
mod loadout;
mod memory;
mod permissions;
mod presentation;
mod profile;
mod redact;
mod semantic;
mod shell;
mod skills;
mod smart_harness;
mod summarizer;
mod tool_exposure;
mod tools;
use backend::{cli_backend_override, validate_backend_names, BackendAssembly, RecordTag};
#[cfg(test)]
use backend::{first_emission_this_process, reset_emitted_warnings_for_test};
#[cfg(test)]
use context::default_input_ceiling_pct;
use layering::{array_merge_strategy, base_is_ambient_newt_toml, mark_project_mcp_untrusted};
use presentation::{default_spill_lines, default_time_marker_secs, default_tool_output_lines};
use redact::{redact_arg_secrets, redact_url_secrets};
pub use shell::{
confined_default_engine, full_access_default_engine, mcp_stdio_env_passthrough,
ocap_l3_backend, resolve_shell_engine, resolve_shell_engine_choice, resolved_confined_default,
shell_env_passthrough_default, windows_cmd_enabled, IntakeConfig, ShellConfig, ShellEngine,
};
use tools::{
default_max_output_tokens, default_output_cap_chars_per_token, default_output_head_tokens,
};
pub const NEWT_CONFIG_DIR_ENV: &str = "NEWT_CONFIG_DIR";
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ScratchConfig {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub dir: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub build_dir: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct Config {
#[serde(default = "Vec::new")]
pub backends: Vec<BackendConfig>,
#[doc(hidden)]
#[serde(skip)]
pub backend_fallback: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub default_backend: Option<String>,
#[serde(default)]
pub discovery: Discovery,
#[serde(default)]
pub network: NetworkConfig,
pub providers: Vec<ProviderConfig>,
#[serde(default)]
pub scratch: Option<ScratchConfig>,
pub default_tier_order: Vec<Tier>,
#[serde(default)]
pub lifecycle: Option<crate::tooling::PhaseCommands>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub dgx: Option<crate::dgx::DgxConfig>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub tui: Option<TuiConfig>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub shell: Option<ShellConfig>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub intake: Option<IntakeConfig>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub smart_harness: Option<SmartHarnessConfig>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub context: Option<ContextConfig>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub tools: Option<ToolsConfig>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub initiative: Option<crate::initiative::InitiativeConfig>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub tool_exposure: Option<ToolExposureConfig>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub pricing: Option<crate::pricing::PricingConfig>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub memory: Option<MemoryConfig>,
#[serde(default)]
pub agents: AgentsConfig,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub mcp_servers: Vec<crate::mcp::McpServerEntry>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub logs: Option<LogConfig>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub skills: Option<SkillsConfig>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub model_tuning: Vec<ModelTuning>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub conversations: Option<ConversationsConfig>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub merge: Option<MergeConfig>,
#[serde(default, skip_serializing_if = "std::collections::BTreeMap::is_empty")]
pub permission_presets: std::collections::BTreeMap<String, crate::NamedPermissionPreset>,
#[serde(default, skip_serializing_if = "std::collections::BTreeMap::is_empty")]
pub modes: std::collections::BTreeMap<String, ModeConfig>,
#[serde(default, skip_serializing_if = "std::collections::BTreeMap::is_empty")]
pub profiles: std::collections::BTreeMap<String, ProfileConfig>,
#[serde(default, skip_serializing_if = "std::collections::BTreeMap::is_empty")]
pub bundles: std::collections::BTreeMap<String, BundleConfig>,
#[serde(default, skip_serializing_if = "std::collections::BTreeMap::is_empty")]
pub loadouts: std::collections::BTreeMap<String, Loadout>,
#[serde(default, skip_serializing_if = "std::collections::BTreeMap::is_empty")]
pub crews: std::collections::BTreeMap<String, Crew>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub crew: Option<CrewPolicyConfig>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub plan: Option<PlanConfig>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(default)]
pub struct ConversationsConfig {
#[serde(default = "default_conversations_max_per_workspace")]
pub max_per_workspace: usize,
#[serde(default = "default_conversations_resume")]
pub resume: bool,
}
fn default_conversations_max_per_workspace() -> usize {
100
}
fn default_conversations_resume() -> bool {
false
}
impl Default for ConversationsConfig {
fn default() -> Self {
Self {
max_per_workspace: default_conversations_max_per_workspace(),
resume: default_conversations_resume(),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(default)]
pub struct LogConfig {
#[serde(default = "default_log_max_sessions")]
pub max_sessions: usize,
#[serde(default)]
pub max_size_mb: u64,
#[serde(default)]
pub max_age_days: u64,
#[serde(default = "default_log_keep_rotated")]
pub keep_rotated: usize,
}
fn default_log_max_sessions() -> usize {
7
}
fn default_log_keep_rotated() -> usize {
3
}
impl Default for LogConfig {
fn default() -> Self {
Self {
max_sessions: default_log_max_sessions(),
max_size_mb: 0,
max_age_days: 0,
keep_rotated: default_log_keep_rotated(),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(default)]
pub struct AgentsConfig {
pub enabled: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub path: Option<String>,
}
impl Default for AgentsConfig {
fn default() -> Self {
Self {
enabled: true,
path: None,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(default)]
pub struct TuiConfig {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub prompt: Option<String>,
#[serde(default)]
pub no_splash: bool,
#[serde(default)]
pub mouse_viewport: bool,
#[serde(default)]
pub edit_mode: EditMode,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub prefix_key: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub gutter: Option<u16>,
#[serde(default)]
pub footer: FooterMode,
#[serde(default)]
pub color: ColorMode,
#[serde(default)]
pub thinking: ThinkingMode,
#[serde(default = "default_tool_output_lines")]
pub tool_output_lines: usize,
#[serde(default = "default_spill_lines")]
pub spill_lines: usize,
#[serde(default = "default_time_marker_secs")]
pub time_marker_secs: u64,
#[serde(default = "default_max_tool_rounds")]
pub max_tool_rounds: usize,
#[serde(default = "default_workflow_grace_rounds")]
pub workflow_grace_rounds: usize,
#[serde(default = "default_narration_nudge_cap")]
pub narration_nudge_cap: usize,
#[serde(default = "default_narration_intent_line")]
pub narration_intent_line: bool,
#[serde(default)]
pub permissions: ToolPermissions,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub debug: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub trace: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub build_check_cmd: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub num_ctx: Option<u32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub real_context_discovery: Option<bool>,
#[serde(default = "default_connect_timeout_secs")]
pub connect_timeout_secs: u64,
#[serde(default = "default_inference_timeout_secs")]
pub inference_timeout_secs: u64,
#[serde(default = "default_keep_alive")]
pub keep_alive: String,
#[serde(default)]
pub markdown: MarkdownMode,
#[serde(default = "default_mid_loop_trim_threshold")]
pub mid_loop_trim_threshold: usize,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub mid_loop_trim_tokens: Option<usize>,
#[serde(default = "default_sanitize_mcp_server_names")]
pub sanitize_mcp_server_names: bool,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub mcp_allow_insecure_hosts: Vec<String>,
#[serde(default = "default_allow_bang_escape")]
pub allow_bang_escape: bool,
#[serde(default = "default_allow_shell_commands")]
pub allow_shell_commands: bool,
#[serde(default = "default_allow_shell_mutations")]
pub allow_shell_mutations: bool,
}
fn default_max_tool_rounds() -> usize {
40
}
fn default_workflow_grace_rounds() -> usize {
5
}
fn default_narration_nudge_cap() -> usize {
1
}
fn default_narration_intent_line() -> bool {
false
}
fn default_connect_timeout_secs() -> u64 {
5
}
fn default_inference_timeout_secs() -> u64 {
120
}
fn default_keep_alive() -> String {
"5m".to_string()
}
fn default_mid_loop_trim_threshold() -> usize {
40
}
fn default_sanitize_mcp_server_names() -> bool {
true
}
fn default_allow_bang_escape() -> bool {
true
}
fn default_allow_shell_commands() -> bool {
true
}
fn default_allow_shell_mutations() -> bool {
false
}
impl Config {
#[must_use]
pub fn find_model_tuning(&self, name: &str) -> Option<&ModelTuning> {
self.model_tuning.iter().find(|t| t.model == name)
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
pub struct PlanConfig {
#[serde(default)]
pub prune: PlanPruneConfig,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
pub struct PlanPruneConfig {
#[serde(default)]
pub disabled: bool,
#[serde(default)]
pub add_inspect: Vec<String>,
#[serde(default)]
pub add_gate: Vec<String>,
#[serde(default)]
pub remove: Vec<String>,
}
impl Default for TuiConfig {
fn default() -> Self {
Self {
prompt: None,
no_splash: false,
mouse_viewport: false,
edit_mode: EditMode::Nano,
prefix_key: None,
gutter: None,
footer: FooterMode::Auto,
color: ColorMode::Auto,
thinking: ThinkingMode::Stream,
tool_output_lines: default_tool_output_lines(),
spill_lines: default_spill_lines(),
time_marker_secs: default_time_marker_secs(),
max_tool_rounds: default_max_tool_rounds(),
workflow_grace_rounds: default_workflow_grace_rounds(),
narration_nudge_cap: default_narration_nudge_cap(),
narration_intent_line: default_narration_intent_line(),
permissions: ToolPermissions::default(),
debug: None,
trace: None,
build_check_cmd: None,
num_ctx: None,
real_context_discovery: None,
connect_timeout_secs: default_connect_timeout_secs(),
inference_timeout_secs: default_inference_timeout_secs(),
keep_alive: default_keep_alive(),
markdown: MarkdownMode::default(),
mid_loop_trim_threshold: default_mid_loop_trim_threshold(),
mid_loop_trim_tokens: None,
sanitize_mcp_server_names: default_sanitize_mcp_server_names(),
mcp_allow_insecure_hosts: Vec::new(),
allow_bang_escape: default_allow_bang_escape(),
allow_shell_commands: default_allow_shell_commands(),
allow_shell_mutations: default_allow_shell_mutations(),
}
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(default)]
pub struct Discovery {
pub hosts: Vec<String>,
pub ollama_ports: Vec<u16>,
pub vllm_ports: Vec<u16>,
}
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct NetworkConfig {
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub owned_suffixes: Vec<String>,
}
impl Default for Discovery {
fn default() -> Self {
Self {
hosts: vec!["localhost".into()],
ollama_ports: vec![11434],
vllm_ports: vec![8000, 8080, 8001, 8002, 8003],
}
}
}
fn validate_provider_names(providers: &[ProviderConfig]) -> std::result::Result<(), String> {
let mut seen = std::collections::BTreeSet::new();
for (i, p) in providers.iter().enumerate() {
if p.name.trim().is_empty() {
return Err(format!(
"provider #{} has no name — every [[providers]] entry needs a unique \
`name` (selection is name-based)",
i + 1
));
}
if !seen.insert(p.name.clone()) {
return Err(format!(
"two providers share the name `{}` — provider selection is name-based \
($NEWT_PROVIDER, default_backend), so a duplicate can run the wrong \
one; rename one",
p.name
));
}
}
Ok(())
}
fn backend_is_routable(b: &BackendConfig) -> bool {
if !b.endpoint.is_empty() {
return true;
}
b.model_path.as_deref().is_some_and(|p| !p.is_empty())
&& matches!(b.kind, None | Some(BackendKind::Embedded))
}
enum SlotSelection {
Slot(usize),
ExplicitlyUnroutable { name: String },
ExplicitlyUnmatched { name: String },
None,
}
fn select_backend_slot(
backends: &[&BackendConfig],
default_backend: Option<&str>,
) -> SlotSelection {
if let Ok(name) = std::env::var("NEWT_PROVIDER") {
if !name.is_empty() {
return match backends.iter().position(|b| b.name == name) {
Some(i) if backend_is_routable(backends[i]) => SlotSelection::Slot(i),
Some(_) => SlotSelection::ExplicitlyUnroutable { name },
None => SlotSelection::ExplicitlyUnmatched { name },
};
}
}
if let Some(name) = default_backend.filter(|n| !n.is_empty()) {
return match backends.iter().position(|b| b.name == name) {
Some(i) if backend_is_routable(backends[i]) => SlotSelection::Slot(i),
Some(_) => SlotSelection::ExplicitlyUnroutable {
name: name.to_string(),
},
None => SlotSelection::ExplicitlyUnmatched {
name: name.to_string(),
},
};
}
if backends.len() == 1 {
return match backends.first().filter(|b| backend_is_routable(b)) {
Some(_) => SlotSelection::Slot(0),
None => SlotSelection::None,
};
}
backends
.iter()
.position(|b| b.kind == Some(BackendKind::Openai) && backend_is_routable(b))
.or_else(|| backends.iter().position(|b| backend_is_routable(b)))
.map_or(SlotSelection::None, SlotSelection::Slot)
}
fn pin_requested_selection(
cfg: &mut Config,
over: Option<&BackendOverride>,
requested_slot: Option<usize>,
) {
let Some(over) = over.filter(|o| !o.is_empty()) else {
return;
};
if over.endpoint.is_some() || over.model_path.is_some() || over.name.is_some() {
if let Some(target) = requested_slot.and_then(|i| cfg.backends.get(i)) {
cfg.default_backend = Some(target.name.clone());
}
}
}
#[derive(Debug, Clone)]
pub struct ResolvedConfig {
config: Config,
receipts: Vec<BackendResolutionReceipt>,
}
#[derive(Debug, Clone, Copy)]
pub struct ResolvedBackend<'a> {
pub slot: usize,
pub backend: &'a BackendConfig,
pub receipt: &'a BackendResolutionReceipt,
}
impl std::ops::Deref for ResolvedConfig {
type Target = Config;
fn deref(&self) -> &Config {
&self.config
}
}
impl ResolvedConfig {
#[must_use]
pub fn into_config(self) -> Config {
self.config
}
#[must_use]
pub fn unrequested(config: Config) -> Self {
let receipts = config
.backends
.iter()
.map(|b| BackendResolutionReceipt {
declaration: DeclaredBackend::of(b),
request: None,
observation: None,
binding: crate::model_card::CardBindingSeed::from_backend(b),
})
.collect();
Self { config, receipts }
}
#[doc(hidden)]
pub fn assemble_for_test(
mut config: Config,
dirs: &[&Path],
over: Option<BackendOverride>,
) -> std::result::Result<Self, String> {
let mut assembly = BackendAssembly::new(std::mem::take(&mut config.backends))?;
for dir in dirs {
assembly.merge_dir(dir)?;
}
let default_backend = config.default_backend.clone();
let _slot = assembly.apply_request(over, default_backend.as_deref())?;
let (backends, receipts) = assembly.finish();
config.backends = backends;
Ok(Self { config, receipts })
}
pub fn publish_runtime_settings(&self) {
self.config.publish_runtime_settings();
}
#[must_use]
pub fn receipts(&self) -> &[BackendResolutionReceipt] {
&self.receipts
}
#[must_use]
pub fn backend(&self, slot: usize) -> Option<ResolvedBackend<'_>> {
match (self.config.backends.get(slot), self.receipts.get(slot)) {
(Some(backend), Some(receipt)) => Some(ResolvedBackend {
slot,
backend,
receipt,
}),
_ => None,
}
}
pub fn backends(&self) -> impl Iterator<Item = ResolvedBackend<'_>> {
self.config
.backends
.iter()
.zip(self.receipts.iter())
.enumerate()
.map(|(slot, (backend, receipt))| ResolvedBackend {
slot,
backend,
receipt,
})
}
#[must_use]
pub fn selected_backend(&self) -> Option<ResolvedBackend<'_>> {
self.config
.selected_configured_slot()
.and_then(|slot| self.backend(slot))
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ProviderConfig {
pub name: String,
pub command: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub model: Option<String>,
#[serde(default)]
pub env_pass: Vec<String>,
pub tiers: Vec<Tier>,
}
#[derive(Debug, Clone)]
pub enum SelectedBackend<'a> {
Configured(&'a BackendConfig),
Provider(&'a ProviderConfig),
}
#[derive(Debug, Clone)]
pub enum SelectionOutcome<'a> {
Selected(SelectedBackend<'a>),
UnknownNamed(String),
UnroutableNamed(String),
Unset,
}
fn fallback_localhost_backend() -> BackendConfig {
let endpoint = std::env::var("OLLAMA_HOST")
.ok()
.filter(|host| !host.is_empty())
.unwrap_or_else(|| "http://127.0.0.1:11434".into());
BackendConfig {
name: "ollama".into(),
endpoint,
model: Some("llama3.1:8b".into()),
tiers: vec![Tier::Fast, Tier::Standard, Tier::Complex, Tier::Review],
kind: Some(BackendKind::Ollama),
..Default::default()
}
}
impl Default for Config {
fn default() -> Self {
Self {
backends: vec![fallback_localhost_backend()],
backend_fallback: true,
default_backend: None,
discovery: Discovery::default(),
network: NetworkConfig::default(),
providers: Vec::new(),
scratch: None,
default_tier_order: vec![Tier::Fast, Tier::Standard, Tier::Complex, Tier::Review],
lifecycle: None,
dgx: None,
tui: None,
shell: None,
intake: None,
smart_harness: None,
context: None,
tools: None,
initiative: None,
tool_exposure: None,
pricing: None,
memory: None,
agents: AgentsConfig::default(),
mcp_servers: Vec::new(),
logs: None,
skills: None,
model_tuning: Vec::new(),
conversations: None,
merge: None,
permission_presets: std::collections::BTreeMap::new(),
modes: std::collections::BTreeMap::new(),
profiles: std::collections::BTreeMap::new(),
bundles: std::collections::BTreeMap::new(),
loadouts: std::collections::BTreeMap::new(),
crews: std::collections::BTreeMap::new(),
crew: None,
plan: None,
}
}
}
impl Config {
pub fn load(path: &Path, report: &mut dyn FnMut(crate::tty::Notice<'static>)) -> Result<Self> {
let text = Self::read_text(path, report)?;
toml::from_str(&text).map_err(|e| NewtError::Config(e.to_string()))
}
fn read_text(
path: &Path,
report: &mut dyn FnMut(crate::tty::Notice<'static>),
) -> Result<String> {
let own = Self::user_config_dir().is_some_and(|dir| path.starts_with(dir));
Ok(crate::psyche_import::read_config_file(path, own, report)?)
}
#[must_use]
pub fn is_unconfigured(&self) -> bool {
self.backend_fallback
}
pub fn resolve(report: &mut dyn FnMut(crate::tty::Notice<'static>)) -> Result<Self> {
Self::resolve_runtime(report).map(ResolvedConfig::into_config)
}
pub fn resolve_unpublished(
report: &mut dyn FnMut(crate::tty::Notice<'static>),
) -> Result<Self> {
Self::resolve_runtime_unpublished(report).map(ResolvedConfig::into_config)
}
pub fn resolve_runtime(
report: &mut dyn FnMut(crate::tty::Notice<'static>),
) -> Result<ResolvedConfig> {
let resolved = Self::resolve_runtime_unpublished(report)?;
resolved.publish_runtime_settings();
Ok(resolved)
}
pub fn resolve_runtime_unpublished(
report: &mut dyn FnMut(crate::tty::Notice<'static>),
) -> Result<ResolvedConfig> {
let base_path = Self::candidate_paths().into_iter().find(|p| p.is_file());
let base_ambient = base_is_ambient_newt_toml(base_path.as_deref());
let project_path =
Self::project_config_path().filter(|p| Some(p.as_path()) != base_path.as_deref());
let mut cfg = match (&base_path, &project_path) {
(Some(p), None) => {
if base_ambient {
let mut base_val = Self::load_value(p, report)?;
strip_control_plane(&mut base_val);
base_val
.try_into()
.map_err(|e| NewtError::Config(e.to_string()))?
} else {
Self::load(p, report)?
}
}
(None, None) => Self::default(),
(base, Some(proj)) => {
let mut merged = match base {
Some(p) => Self::load_value(p, report)?,
None => toml::Value::try_from(Self::default())
.map_err(|e| NewtError::Config(e.to_string()))?,
};
if base_ambient {
strip_control_plane(&mut merged);
}
let project_val = Self::load_value(proj, report)?;
let strategy = array_merge_strategy(&project_val, &merged);
let project_mcp_count = project_val
.get("mcp_servers")
.and_then(toml::Value::as_array)
.map(Vec::len);
merge_project_overlay(&mut merged, project_val, strategy);
let mut cfg: Self = merged
.try_into()
.map_err(|e| NewtError::Config(e.to_string()))?;
mark_project_mcp_untrusted(&mut cfg.mcp_servers, strategy, project_mcp_count);
cfg
}
};
if base_ambient {
for entry in &mut cfg.mcp_servers {
entry.trust = crate::mcp::McpTrust::Untrusted;
}
}
if base_path.is_some() || project_path.is_some() {
cfg.backend_fallback = cfg.backends.is_empty();
}
let mut assembly =
BackendAssembly::new(std::mem::take(&mut cfg.backends)).map_err(NewtError::Config)?;
validate_provider_names(&cfg.providers).map_err(NewtError::Config)?;
if let Some(dir) = Self::user_config_dir() {
assembly
.merge_dir(&dir.join("backends"))
.map_err(NewtError::Config)?;
}
if let Some(proj) = Self::project_config_path() {
if let Some(parent) = proj.parent() {
assembly
.merge_dir(&parent.join("backends"))
.map_err(NewtError::Config)?;
}
}
if assembly.operator_configured() {
cfg.backend_fallback = false;
}
if assembly.is_empty() {
if cfg.providers.is_empty() {
cfg.backend_fallback = true;
assembly.push_fallback(fallback_localhost_backend());
} else {
cfg.backend_fallback = false;
}
}
cfg.merge_disk_bundles();
cfg.merge_disk_loadouts();
cfg.merge_disk_crews();
cfg.merge_disk_dgx_nodes();
let over = cli_backend_override();
let requested_slot = assembly
.apply_request(over.clone(), cfg.default_backend.as_deref())
.map_err(NewtError::Config)?;
if assembly.requested() {
cfg.backend_fallback = false;
}
let (backends, receipts) = assembly.finish();
cfg.backends = backends;
pin_requested_selection(&mut cfg, over.as_ref(), requested_slot);
Ok(ResolvedConfig {
config: cfg,
receipts,
})
}
pub fn apply_runtime_settings(&mut self) {
if let Some(over) = cli_backend_override().filter(|o| !o.is_empty()) {
over.apply(self);
}
self.publish_runtime_settings();
}
pub fn prepare_runtime(mut self) -> Result<ResolvedConfig> {
let mut assembly =
BackendAssembly::new(std::mem::take(&mut self.backends)).map_err(NewtError::Config)?;
validate_provider_names(&self.providers).map_err(NewtError::Config)?;
let default_backend = self.default_backend.clone();
let over = cli_backend_override();
let requested_slot = assembly
.apply_request(over.clone(), default_backend.as_deref())
.map_err(NewtError::Config)?;
if assembly.requested() {
self.backend_fallback = false;
}
let (backends, receipts) = assembly.finish();
self.backends = backends;
pin_requested_selection(&mut self, over.as_ref(), requested_slot);
Ok(ResolvedConfig {
config: self,
receipts,
})
}
pub fn publish_runtime_settings(&self) {
shell::set_windows_cmd(
self.shell
.as_ref()
.and_then(|s| s.windows_cmd)
.unwrap_or(false),
);
crate::agentic::set_max_output_tokens(self.max_output_tokens());
crate::agentic::set_output_head_tokens(self.output_head_tokens());
crate::agentic::set_output_cap_chars_per_token(self.output_cap_chars_per_token());
crate::initiative::set_initiative_config(self.initiative.clone().unwrap_or_default());
if let Some(lc) = &self.lifecycle {
crate::tooling::set_lifecycle_override(lc.clone());
}
if let Some(dir) = self.scratch.as_ref().and_then(|s| s.dir.as_deref()) {
crate::scratch::set_scratch_dir(dir);
}
if let Some(dir) = self.scratch.as_ref().and_then(|s| s.build_dir.as_deref()) {
crate::scratch::set_build_scratch_dir(dir);
}
if !self.network.owned_suffixes.is_empty() {
crate::owned_hosts::set_owned_suffixes(self.network.owned_suffixes.clone());
}
}
pub fn max_output_tokens(&self) -> usize {
self.tools
.as_ref()
.map(|t| t.max_output_tokens)
.unwrap_or_else(default_max_output_tokens)
}
pub fn tool_exposure(&self) -> ToolExposureConfig {
self.tool_exposure.unwrap_or_default()
}
pub fn output_head_tokens(&self) -> usize {
self.tools
.as_ref()
.map(|t| t.output_head_tokens)
.unwrap_or_else(default_output_head_tokens)
}
pub fn output_cap_chars_per_token(&self) -> usize {
self.tools
.as_ref()
.map(|t| t.output_cap_chars_per_token)
.unwrap_or_else(default_output_cap_chars_per_token)
}
pub fn validate_backend_identities(&self) -> std::result::Result<(), String> {
validate_backend_names(self.backends.iter())
}
fn merge_disk_dgx_nodes(&mut self) {
if let Some(dir) = Self::user_config_dir() {
self.merge_dgx_nodes_from_dir(&dir.join("dgx"));
}
if let Some(proj) = Self::project_config_path() {
if let Some(parent) = proj.parent() {
self.merge_dgx_nodes_from_dir(&parent.join("dgx"));
}
}
}
fn merge_dgx_nodes_from_dir(&mut self, dir: &Path) {
let Ok(entries) = std::fs::read_dir(dir) else {
return; };
let mut paths: Vec<PathBuf> = entries
.flatten()
.map(|e| e.path())
.filter(|p| p.extension().is_some_and(|x| x == "toml"))
.collect();
paths.sort();
for path in paths {
let Some(stem) = path.file_stem().and_then(|s| s.to_str()) else {
continue;
};
match std::fs::read_to_string(&path).map(|t| toml::from_str::<crate::dgx::DgxNode>(&t))
{
Ok(Ok(mut node)) => {
node.name = stem.to_string();
let dgx = self.dgx.get_or_insert_with(Default::default);
match dgx.nodes.iter_mut().find(|n| n.name == node.name) {
Some(existing) => *existing = node,
None => dgx.nodes.push(node),
}
}
Ok(Err(e)) => {
tracing::warn!(path = %path.display(), error = %e, "skipping malformed dgx node file");
}
Err(_) => {}
}
}
}
fn load_value(
path: &Path,
report: &mut dyn FnMut(crate::tty::Notice<'static>),
) -> Result<toml::Value> {
let text = Self::read_text(path, report)?;
toml::from_str(&text).map_err(|e| NewtError::Config(e.to_string()))
}
pub fn project_config_path() -> Option<PathBuf> {
let cwd = std::env::current_dir().ok()?;
find_project_config_from(&cwd, home_dir().as_deref())
}
#[must_use]
pub fn pinned_config_path() -> Option<PathBuf> {
std::env::var("NEWT_CONFIG")
.ok()
.filter(|v| !v.is_empty())
.map(PathBuf::from)
.filter(|p| p.is_file())
}
pub fn user_config_dir() -> Option<PathBuf> {
if let Some(path) = std::env::var_os(NEWT_CONFIG_DIR_ENV)
.filter(|value| !value.is_empty())
.map(PathBuf::from)
{
return Some(path);
}
home_dir().map(|h| h.join(".newt"))
}
pub fn user_config_path() -> Option<PathBuf> {
Self::user_config_dir().map(|dir| dir.join("config.toml"))
}
#[must_use]
pub fn select_configured_backend(&self) -> Option<&BackendConfig> {
self.selected_configured_slot().map(|i| &self.backends[i])
}
fn selected_configured_slot(&self) -> Option<usize> {
let backends: Vec<&BackendConfig> = self.backends.iter().collect();
match select_backend_slot(&backends, self.default_backend.as_deref()) {
SlotSelection::Slot(i) => Some(i),
SlotSelection::ExplicitlyUnroutable { .. }
| SlotSelection::ExplicitlyUnmatched { .. }
| SlotSelection::None => None,
}
}
pub fn select_backend(&self) -> SelectionOutcome<'_> {
let explicit_selector = std::env::var("NEWT_PROVIDER")
.ok()
.filter(|n| !n.is_empty())
.or_else(|| self.default_backend.clone().filter(|n| !n.is_empty()));
if let Some(name) = explicit_selector {
let routable_backend = self
.backends
.iter()
.any(|b| b.name == name && backend_is_routable(b));
if !routable_backend {
if let Some(provider) = self.providers.iter().find(|p| p.name == name) {
return SelectionOutcome::Selected(SelectedBackend::Provider(provider));
}
if self.backends.iter().any(|b| b.name == name) {
return SelectionOutcome::UnroutableNamed(name);
}
return SelectionOutcome::UnknownNamed(name);
}
}
if let Some(backend) = self.select_configured_backend() {
return SelectionOutcome::Selected(SelectedBackend::Configured(backend));
}
match self.providers.first() {
Some(provider) => SelectionOutcome::Selected(SelectedBackend::Provider(provider)),
None => SelectionOutcome::Unset,
}
}
pub fn to_redacted_toml(&self) -> Result<String> {
use crate::mcp::SecretValue;
let mut redacted = self.clone();
let redact = |v: &mut SecretValue| {
if matches!(v, SecretValue::Literal(_)) {
*v = SecretValue::Literal(Self::REDACTED.to_string());
}
};
for server in &mut redacted.mcp_servers {
server.env.values_mut().for_each(redact);
server.headers.values_mut().for_each(redact);
if let Some(url) = &server.url {
server.url = Some(redact_url_secrets(url));
}
server.args = redact_arg_secrets(&server.args);
}
toml::to_string_pretty(&redacted).map_err(|e| NewtError::Config(e.to_string()))
}
pub const REDACTED: &'static str = "<redacted>";
#[must_use]
pub fn personas_dir() -> PathBuf {
Self::user_config_path()
.map(|p| p.with_file_name("personas"))
.unwrap_or_else(|| PathBuf::from("personas"))
}
pub fn save(&self, path: &Path) -> Result<()> {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).map_err(NewtError::Io)?;
}
let destination = crate::atomic_fs::ResolvedPath::resolve(path).map_err(|error| {
NewtError::Config(format!(
"resolve config destination for {}: {error:#}",
path.display()
))
})?;
let _lock = crate::atomic_fs::acquire_lock(&destination.lock_path())
.map_err(|error| NewtError::Config(format!("lock {}: {error:#}", path.display())))?;
let text = toml::to_string_pretty(self).map_err(|e| NewtError::Config(e.to_string()))?;
destination
.atomic_write(text.as_bytes())
.map_err(|error| NewtError::Config(format!("write {}: {error:#}", path.display())))
}
pub fn with_default_backend(text: &str, name: &str) -> Result<String> {
if name.trim().is_empty() {
return Err(NewtError::Config(
"default backend name cannot be empty".to_string(),
));
}
let mut doc = text
.parse::<toml_edit::DocumentMut>()
.map_err(|e| NewtError::Config(format!("config is not valid TOML: {e}")))?;
let root = doc.as_table_mut();
if let Some(item) = root.get_mut("default_backend") {
let existing = item.as_str().ok_or_else(|| {
NewtError::Config("`default_backend` is not a string".to_string())
})?;
if existing == name {
return Ok(doc.to_string());
}
let decor = item
.as_value()
.expect("a string item is always a value")
.decor()
.clone();
*item = toml_edit::value(name);
*item
.as_value_mut()
.expect("toml_edit::value always creates a value")
.decor_mut() = decor;
} else {
root.insert("default_backend", toml_edit::value(name));
}
Ok(doc.to_string())
}
pub fn with_tui_key(text: &str, key: &str, value: &str) -> Result<String> {
let mut doc = text
.parse::<toml_edit::DocumentMut>()
.map_err(|e| NewtError::Config(format!("config is not valid TOML: {e}")))?;
let tui = doc
.as_table_mut()
.entry("tui")
.or_insert_with(|| {
let mut table = toml_edit::Table::new();
table.set_implicit(false);
toml_edit::Item::Table(table)
})
.as_table_mut()
.ok_or_else(|| NewtError::Config("`tui` is not a table".to_string()))?;
tui.insert(key, toml_edit::value(value));
Ok(doc.to_string())
}
pub fn with_mcp_enabled(text: &str, name: &str, enabled: bool) -> Result<String> {
let mut doc = text
.parse::<toml_edit::DocumentMut>()
.map_err(|e| NewtError::Config(format!("config is not valid TOML: {e}")))?;
let servers =
doc.as_table_mut()
.entry("mcp_servers")
.or_insert(toml_edit::Item::ArrayOfTables(
toml_edit::ArrayOfTables::new(),
));
let arr = servers.as_array_of_tables_mut().ok_or_else(|| {
NewtError::Config("[[mcp_servers]] is not an array of tables".to_string())
})?;
let entry = arr
.iter_mut()
.find(|t| t.get("name").and_then(|v| v.as_str()) == Some(name))
.ok_or_else(|| NewtError::Config(format!("no [[mcp_servers]] entry named `{name}`")))?;
if enabled {
entry.remove("enabled");
} else {
entry["enabled"] = toml_edit::value(false);
}
Ok(doc.to_string())
}
pub fn with_mcp_server_added(text: &str, entry: &crate::mcp::McpServerEntry) -> Result<String> {
crate::mcp::validate_entry_for_write(entry)?;
let mut doc = text
.parse::<toml_edit::DocumentMut>()
.map_err(|e| NewtError::Config(format!("config is not valid TOML: {e}")))?;
let servers =
doc.as_table_mut()
.entry("mcp_servers")
.or_insert(toml_edit::Item::ArrayOfTables(
toml_edit::ArrayOfTables::new(),
));
let arr = servers.as_array_of_tables_mut().ok_or_else(|| {
NewtError::Config("[[mcp_servers]] is not an array of tables".to_string())
})?;
if arr
.iter()
.any(|t| t.get("name").and_then(|v| v.as_str()) == Some(entry.name.as_str()))
{
return Err(NewtError::Config(format!(
"an [[mcp_servers]] entry named `{}` already exists (remove it first, \
or toggle it with `/mcp enable|disable`)",
entry.name
)));
}
arr.push(crate::mcp::entry_to_toml_table(entry, None)?);
Ok(doc.to_string())
}
pub fn with_mcp_server_removed(text: &str, name: &str) -> Result<String> {
let mut doc = text
.parse::<toml_edit::DocumentMut>()
.map_err(|e| NewtError::Config(format!("config is not valid TOML: {e}")))?;
let item = doc
.as_table_mut()
.get_mut("mcp_servers")
.ok_or_else(|| NewtError::Config(format!("no [[mcp_servers]] entry named `{name}`")))?;
let arr = item.as_array_of_tables_mut().ok_or_else(|| {
NewtError::Config("[[mcp_servers]] is not an array of tables".to_string())
})?;
let before = arr.len();
arr.retain(|t| t.get("name").and_then(|v| v.as_str()) != Some(name));
if arr.len() == before {
return Err(NewtError::Config(format!(
"no [[mcp_servers]] entry named `{name}`"
)));
}
Ok(doc.to_string())
}
fn candidate_paths() -> Vec<PathBuf> {
let mut paths = Vec::new();
if let Ok(p) = std::env::var("NEWT_CONFIG") {
paths.push(PathBuf::from(p));
}
paths.push(PathBuf::from("./newt.toml"));
if let Some(path) = Self::user_config_path() {
paths.push(path);
}
paths.push(PathBuf::from("/etc/newt/config.toml"));
paths
}
}
#[cfg(test)]
#[path = "config_tests/tests.rs"]
mod tests;
#[cfg(test)]
#[path = "config_tests/select_backend_tests.rs"]
mod select_backend_tests;