use std::collections::HashMap;
use std::path::Path;
use serde::{Deserialize, Serialize};
use crate::core::sm::config::SessionManagerConfig;
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
pub struct AgentsConfig {
#[serde(default)]
pub sources: Vec<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
pub struct TierAliases {
pub haiku: Option<String>,
pub sonnet: Option<String>,
pub opus: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
pub struct ModelsConfig {
#[serde(default)]
pub agents: HashMap<String, String>,
#[serde(default)]
pub tiers: TierAliases,
pub default: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
pub struct SkillsConfig {
#[serde(default)]
pub sources: Vec<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
pub struct ManifestConfig {
pub repo: Option<String>,
pub git_ref: Option<String>,
pub ttl_hours: Option<u64>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct IdleAutoStopConfig {
#[serde(default)]
pub enabled: bool,
#[serde(default = "IdleAutoStopConfig::default_dry_run")]
pub dry_run: bool,
#[serde(default = "IdleAutoStopConfig::default_poll_interval_secs")]
pub poll_interval_secs: u64,
#[serde(default = "IdleAutoStopConfig::default_idle_consecutive_threshold")]
pub idle_consecutive_threshold: u32,
#[serde(default = "IdleAutoStopConfig::default_done_consecutive_threshold")]
pub done_consecutive_threshold: u32,
}
impl IdleAutoStopConfig {
fn default_dry_run() -> bool {
true
}
fn default_poll_interval_secs() -> u64 {
300
}
fn default_idle_consecutive_threshold() -> u32 {
3
}
fn default_done_consecutive_threshold() -> u32 {
1
}
}
impl Default for IdleAutoStopConfig {
fn default() -> Self {
Self {
enabled: false,
dry_run: Self::default_dry_run(),
poll_interval_secs: Self::default_poll_interval_secs(),
idle_consecutive_threshold: Self::default_idle_consecutive_threshold(),
done_consecutive_threshold: Self::default_done_consecutive_threshold(),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct CatchupConfig {
pub auto: bool,
pub include_git: bool,
pub include_palace: bool,
pub git_limit: usize,
pub drawer_limit: usize,
}
impl Default for CatchupConfig {
fn default() -> Self {
Self {
auto: true,
include_git: true,
include_palace: true,
git_limit: 50,
drawer_limit: 15,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
pub struct PmConfig {
pub circuit_breaker: Option<bool>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
pub struct MpmConfig {
#[serde(default)]
pub agents: AgentsConfig,
#[serde(default)]
pub models: ModelsConfig,
#[serde(default)]
pub skills: SkillsConfig,
#[serde(default)]
pub manifest: ManifestConfig,
#[serde(default)]
pub pm: PmConfig,
#[serde(default)]
pub session_manager: SessionManagerConfig,
#[serde(default)]
pub control_plane: crate::control::config::ControlPlaneConfig,
#[serde(default)]
pub style: crate::core::output_style::StyleConfig,
#[serde(default)]
pub catchup: CatchupConfig,
#[serde(default)]
pub idle_auto_stop: IdleAutoStopConfig,
#[serde(default)]
pub idle_nudge: crate::core::idle_nudge::IdleNudgeConfig,
}
impl MpmConfig {
pub fn load(root: &Path) -> Self {
let path = root.join("config.toml");
match std::fs::read_to_string(&path) {
Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
tracing::debug!("no config.toml found at {}; using defaults", path.display());
Self::default()
}
Err(err) => {
tracing::warn!(
"could not read config.toml at {}: {err}; using defaults",
path.display()
);
Self::default()
}
Ok(raw) => match toml::from_str::<Self>(&raw) {
Ok(cfg) => {
tracing::debug!("loaded config from {}", path.display());
cfg
}
Err(err) => {
tracing::warn!(
"config.toml at {} is malformed: {err}; using defaults",
path.display()
);
Self::default()
}
},
}
}
pub fn load_default() -> Self {
match dirs::home_dir() {
Some(home) => Self::load(&home.join(".trusty-mpm")),
None => {
tracing::warn!("home directory unavailable; using default config");
Self::default()
}
}
}
pub fn expand_model_alias<'a>(&'a self, alias: &'a str) -> &'a str {
match alias {
"haiku" => self
.models
.tiers
.haiku
.as_deref()
.unwrap_or("claude-haiku-4-5"),
"sonnet" => self
.models
.tiers
.sonnet
.as_deref()
.unwrap_or("claude-sonnet-4-5"),
"opus" => self
.models
.tiers
.opus
.as_deref()
.unwrap_or("claude-opus-4-5"),
"auto" => "claude-sonnet-4-5",
other => other,
}
}
}
pub fn resolve_agent_model(
config: &MpmConfig,
agent_name: &str,
frontmatter_model: Option<&str>,
explicit: Option<&str>,
) -> String {
if let Some(m) = explicit {
return config.expand_model_alias(m).to_string();
}
if let Some(m) = config.models.agents.get(agent_name) {
return config.expand_model_alias(m).to_string();
}
if let Some(m) = frontmatter_model {
return config.expand_model_alias(m).to_string();
}
let fallback = config
.models
.default
.as_deref()
.unwrap_or("claude-sonnet-4-5");
config.expand_model_alias(fallback).to_string()
}
#[cfg(test)]
mod tests {
use super::*;
fn load_from_str(dir: &Path, content: &str) -> MpmConfig {
std::fs::write(dir.join("config.toml"), content).unwrap();
MpmConfig::load(dir)
}
#[test]
fn config_absent_yields_defaults() {
let dir = tempfile::TempDir::new().unwrap();
let cfg = MpmConfig::load(dir.path());
assert_eq!(cfg, MpmConfig::default());
assert!(cfg.agents.sources.is_empty());
assert!(cfg.models.agents.is_empty());
assert!(!cfg.session_manager.enabled);
}
#[test]
fn config_valid_parsed() {
let dir = tempfile::TempDir::new().unwrap();
let toml = r#"
[agents]
sources = ["bundled", "user"]
[models]
default = "sonnet"
[models.agents]
engineer = "haiku"
rust-engineer = "opus"
[models.tiers]
haiku = "claude-haiku-4-5"
sonnet = "claude-sonnet-4-5"
opus = "claude-opus-4-5"
[skills]
sources = ["bundled"]
[pm]
circuit_breaker = true
[session_manager]
enabled = true
[session_manager.inference]
provider = "anthropic"
sm_model = "anthropic/claude-sonnet-4-6"
temperature = 0.4
[session_manager.memory]
palace = "session-manager"
recall_top_k = 8
[session_manager.rounds]
window = 12
"#;
let cfg = load_from_str(dir.path(), toml);
assert_eq!(cfg.agents.sources, vec!["bundled", "user"]);
assert_eq!(
cfg.models.agents.get("engineer").map(|s| s.as_str()),
Some("haiku")
);
assert_eq!(
cfg.models.agents.get("rust-engineer").map(|s| s.as_str()),
Some("opus")
);
assert_eq!(cfg.models.tiers.haiku.as_deref(), Some("claude-haiku-4-5"));
assert_eq!(cfg.models.default.as_deref(), Some("sonnet"));
assert_eq!(cfg.skills.sources, vec!["bundled"]);
assert_eq!(cfg.pm.circuit_breaker, Some(true));
assert!(cfg.session_manager.enabled);
assert_eq!(cfg.session_manager.inference.provider, "anthropic");
assert_eq!(cfg.session_manager.inference.temperature, 0.4);
assert_eq!(cfg.session_manager.memory.recall_top_k, 8);
assert_eq!(cfg.session_manager.rounds.window, 12);
assert_eq!(cfg.session_manager.inference.context_token_budget, 24_000);
}
#[test]
fn config_session_manager_partial_takes_defaults() {
let dir = tempfile::TempDir::new().unwrap();
let toml = r#"
[session_manager]
enabled = true
"#;
let cfg = load_from_str(dir.path(), toml);
assert!(cfg.session_manager.enabled);
assert_eq!(cfg.session_manager.inference.provider, "auto");
assert_eq!(cfg.session_manager.memory.palace, "session-manager");
assert_eq!(cfg.session_manager.rounds.window, 10);
}
#[test]
fn config_session_manager_absent_is_noop_default() {
let dir = tempfile::TempDir::new().unwrap();
let toml = r#"
[models.agents]
engineer = "haiku"
"#;
let cfg = load_from_str(dir.path(), toml);
assert_eq!(
cfg.session_manager,
crate::core::sm::config::SessionManagerConfig::default()
);
assert!(!cfg.session_manager.enabled);
}
#[test]
fn config_manifest_section_parses() {
let dir = tempfile::TempDir::new().unwrap();
let toml = r#"
[manifest]
repo = "https://github.com/me/my-fork"
git_ref = "dev"
ttl_hours = 6
"#;
let cfg = load_from_str(dir.path(), toml);
assert_eq!(
cfg.manifest.repo.as_deref(),
Some("https://github.com/me/my-fork")
);
assert_eq!(cfg.manifest.git_ref.as_deref(), Some("dev"));
assert_eq!(cfg.manifest.ttl_hours, Some(6));
let empty = MpmConfig::load(tempfile::TempDir::new().unwrap().path());
assert_eq!(empty.manifest, ManifestConfig::default());
}
#[test]
fn config_malformed_falls_back() {
let dir = tempfile::TempDir::new().unwrap();
let cfg = load_from_str(dir.path(), "this is not toml {{{{");
assert_eq!(cfg, MpmConfig::default());
}
#[test]
fn config_partial_sections_are_fine() {
let dir = tempfile::TempDir::new().unwrap();
let toml = r#"
[models.agents]
engineer = "haiku"
"#;
let cfg = load_from_str(dir.path(), toml);
assert_eq!(
cfg.models.agents.get("engineer").map(|s| s.as_str()),
Some("haiku")
);
assert!(cfg.agents.sources.is_empty());
assert!(cfg.pm.circuit_breaker.is_none());
}
#[test]
fn tier_alias_expansion() {
let dir = tempfile::TempDir::new().unwrap();
let toml = r#"
[models.tiers]
haiku = "claude-haiku-4-5"
sonnet = "claude-sonnet-4-7"
opus = "claude-opus-4-7"
"#;
let cfg = load_from_str(dir.path(), toml);
assert_eq!(cfg.expand_model_alias("haiku"), "claude-haiku-4-5");
assert_eq!(cfg.expand_model_alias("sonnet"), "claude-sonnet-4-7");
assert_eq!(cfg.expand_model_alias("opus"), "claude-opus-4-7");
assert_eq!(cfg.expand_model_alias("claude-opus-4-7"), "claude-opus-4-7");
assert_eq!(cfg.expand_model_alias("auto"), "claude-sonnet-4-5");
}
#[test]
fn tier_alias_defaults_when_not_configured() {
let cfg = MpmConfig::default();
assert_eq!(cfg.expand_model_alias("haiku"), "claude-haiku-4-5");
assert_eq!(cfg.expand_model_alias("sonnet"), "claude-sonnet-4-5");
assert_eq!(cfg.expand_model_alias("opus"), "claude-opus-4-5");
}
#[test]
fn config_catchup_defaults_parse() {
let dir = tempfile::TempDir::new().unwrap();
let cfg = MpmConfig::load(dir.path());
assert!(cfg.catchup.auto, "auto defaults to true");
assert!(cfg.catchup.include_git, "include_git defaults to true");
assert!(
cfg.catchup.include_palace,
"include_palace defaults to true"
);
assert_eq!(cfg.catchup.git_limit, 50);
assert_eq!(cfg.catchup.drawer_limit, 15);
}
#[test]
fn config_catchup_section_parses() {
let dir = tempfile::TempDir::new().unwrap();
let toml = r#"
[catchup]
auto = false
include_git = false
include_palace = true
git_limit = 25
drawer_limit = 5
"#;
let cfg = load_from_str(dir.path(), toml);
assert!(!cfg.catchup.auto);
assert!(!cfg.catchup.include_git);
assert!(cfg.catchup.include_palace);
assert_eq!(cfg.catchup.git_limit, 25);
assert_eq!(cfg.catchup.drawer_limit, 5);
}
#[test]
fn config_idle_auto_stop_defaults() {
let dir = tempfile::TempDir::new().unwrap();
let cfg = MpmConfig::load(dir.path());
assert!(
!cfg.idle_auto_stop.enabled,
"idle_auto_stop must default to disabled"
);
assert!(
cfg.idle_auto_stop.dry_run,
"idle_auto_stop must default to report-only (dry_run = true) — #1783"
);
assert_eq!(cfg.idle_auto_stop.poll_interval_secs, 300);
assert_eq!(cfg.idle_auto_stop.idle_consecutive_threshold, 3);
assert_eq!(cfg.idle_auto_stop.done_consecutive_threshold, 1);
}
#[test]
fn config_idle_auto_stop_section_parses() {
let dir = tempfile::TempDir::new().unwrap();
let toml = r#"
[idle_auto_stop]
enabled = true
dry_run = false
poll_interval_secs = 120
idle_consecutive_threshold = 5
done_consecutive_threshold = 2
"#;
let cfg = load_from_str(dir.path(), toml);
assert!(cfg.idle_auto_stop.enabled);
assert!(
!cfg.idle_auto_stop.dry_run,
"explicit dry_run = false must disable report-only mode"
);
assert_eq!(cfg.idle_auto_stop.poll_interval_secs, 120);
assert_eq!(cfg.idle_auto_stop.idle_consecutive_threshold, 5);
assert_eq!(cfg.idle_auto_stop.done_consecutive_threshold, 2);
}
#[test]
fn config_idle_auto_stop_enabled_only_keeps_defaults() {
let dir = tempfile::TempDir::new().unwrap();
let toml = r#"
[idle_auto_stop]
enabled = true
"#;
let cfg = load_from_str(dir.path(), toml);
assert!(cfg.idle_auto_stop.enabled);
assert!(
cfg.idle_auto_stop.dry_run,
"enabling the loop alone must NOT enact teardown — dry_run stays true (#1783)"
);
assert_eq!(cfg.idle_auto_stop.poll_interval_secs, 300);
assert_eq!(cfg.idle_auto_stop.idle_consecutive_threshold, 3);
assert_eq!(cfg.idle_auto_stop.done_consecutive_threshold, 1);
}
#[test]
fn config_idle_nudge_section_parses() {
let dir = tempfile::TempDir::new().unwrap();
let absent = load_from_str(dir.path(), "");
assert!(!absent.idle_nudge.enabled, "must default to disabled");
assert_eq!(absent.idle_nudge.max_nudges_per_session, 2);
assert_eq!(absent.idle_nudge.cooldown_secs, 300);
assert_eq!(
absent.idle_nudge.message,
crate::core::idle_nudge::DEFAULT_NUDGE_MESSAGE
);
let full = load_from_str(
dir.path(),
"[idle_nudge]\nenabled = true\nmax_nudges_per_session = 1\ncooldown_secs = 60\nmessage = \"resume now\"\n",
);
assert!(full.idle_nudge.enabled);
assert_eq!(full.idle_nudge.max_nudges_per_session, 1);
assert_eq!(full.idle_nudge.cooldown_secs, 60);
assert_eq!(full.idle_nudge.message, "resume now");
let partial = load_from_str(dir.path(), "[idle_nudge]\nenabled = true\n");
assert!(partial.idle_nudge.enabled);
assert_eq!(partial.idle_nudge.max_nudges_per_session, 2);
assert_eq!(partial.idle_nudge.cooldown_secs, 300);
assert_eq!(
partial.idle_nudge.message,
crate::core::idle_nudge::DEFAULT_NUDGE_MESSAGE
);
}
#[test]
fn model_resolution_precedence() {
let dir = tempfile::TempDir::new().unwrap();
let toml = r#"
[models]
default = "sonnet"
[models.agents]
engineer = "haiku"
"#;
let cfg = load_from_str(dir.path(), toml);
let m = resolve_agent_model(&cfg, "engineer", Some("opus"), Some("claude-opus-4-5"));
assert_eq!(m, "claude-opus-4-5");
let m = resolve_agent_model(&cfg, "engineer", Some("opus"), None);
assert_eq!(m, "claude-haiku-4-5");
let m = resolve_agent_model(&cfg, "unknown-agent", Some("opus"), None);
assert_eq!(m, "claude-opus-4-5");
let m = resolve_agent_model(&cfg, "unknown-agent", None, None);
assert_eq!(m, "claude-sonnet-4-5");
let cfg_empty = MpmConfig::default();
let m = resolve_agent_model(&cfg_empty, "nobody", None, None);
assert_eq!(m, "claude-sonnet-4-5");
}
}