magi-code 0.77.1

Repository-aware CLI coding agent for terminal work
Documentation
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use std::{collections::BTreeMap, num::NonZeroUsize, path::PathBuf};

use super::helpers::is_false;

#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
pub struct SubagentsSettings {
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub disabled: Vec<String>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub schema_validation_max_retries: Option<u64>,
    #[serde(default, flatten)]
    pub(crate) extra: BTreeMap<String, serde_json::Value>,
}

impl SubagentsSettings {
    pub(crate) fn schema_validation_max_retries(&self) -> u64 {
        self.schema_validation_max_retries
            .unwrap_or(DEFAULT_SUBAGENT_SCHEMA_VALIDATION_MAX_RETRIES)
    }
}

#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
pub struct ModelsSettings {
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub disabled: Vec<String>,
    #[serde(default, flatten)]
    pub(crate) extra: BTreeMap<String, serde_json::Value>,
}

impl ModelsSettings {
    pub(crate) fn is_default(&self) -> bool {
        self == &Self::default()
    }
}

pub const DEFAULT_SUBAGENT_SCHEMA_VALIDATION_MAX_RETRIES: u64 = 2;
pub const MAX_SUBAGENT_SCHEMA_VALIDATION_MAX_RETRIES: u64 = 5;

#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
pub struct IntegrationsSettings {
    #[serde(default, skip_serializing_if = "HerdrSettings::is_default")]
    pub herdr: HerdrSettings,
    #[serde(default, flatten)]
    pub(crate) extra: BTreeMap<String, serde_json::Value>,
}

impl IntegrationsSettings {
    pub(crate) fn is_default(&self) -> bool {
        self == &Self::default()
    }
}

#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
pub struct HerdrSettings {
    #[serde(default)]
    pub enabled: bool,
    #[serde(default, flatten)]
    pub(crate) extra: BTreeMap<String, serde_json::Value>,
}

impl HerdrSettings {
    pub(crate) fn is_default(&self) -> bool {
        self == &Self::default()
    }
}

pub const DEFAULT_TUI_SUBAGENT_CARD_ROWS: usize = 16;
pub const MIN_TUI_SUBAGENT_CARD_ROWS: usize = 1;
pub const MAX_TUI_SUBAGENT_CARD_ROWS: usize = 50;

#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
pub struct TuiSettings {
    #[serde(default, skip_serializing_if = "Option::is_none")]
    #[schemars(range(min = 1, max = 50))]
    pub(crate) subagent_card_rows: Option<usize>,
    #[serde(default, flatten)]
    pub(crate) extra: BTreeMap<String, serde_json::Value>,
}

impl TuiSettings {
    pub(crate) fn is_default(&self) -> bool {
        self == &Self::default()
    }

    pub(crate) fn subagent_card_rows(&self) -> usize {
        self.subagent_card_rows
            .unwrap_or(DEFAULT_TUI_SUBAGENT_CARD_ROWS)
    }
}

#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
pub struct InstructionsSettings {
    #[serde(default, skip_serializing_if = "is_false")]
    pub subdir_discovery: bool,
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub additional_markdown_paths: Vec<PathBuf>,
    #[serde(default, flatten)]
    pub(crate) extra: BTreeMap<String, serde_json::Value>,
}

impl InstructionsSettings {
    pub(crate) fn is_default(&self) -> bool {
        self == &Self::default()
    }
}

#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
pub struct SkillsSettings {
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub additional_paths: Vec<PathBuf>,
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub disabled: Vec<String>,
    #[serde(default, flatten)]
    pub(crate) extra: BTreeMap<String, serde_json::Value>,
}

impl SkillsSettings {
    pub(crate) fn is_default(&self) -> bool {
        self == &Self::default()
    }
}

pub(crate) const DEFAULT_AUTO_COMPACTIONS_PER_RUN: u8 = 4;

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum AutoCompactionLimit {
    Limited(NonZeroUsize),
    NoCountCap,
}

impl AutoCompactionLimit {
    pub(crate) fn allows(self, completed_compactions: usize) -> bool {
        match self {
            Self::Limited(limit) => completed_compactions < limit.get(),
            Self::NoCountCap => true,
        }
    }
}

#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
pub struct AutoCompactionSettings {
    #[serde(default, skip_serializing_if = "is_false")]
    pub enabled: bool,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub threshold_percent: Option<u8>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub threshold_tokens: Option<u64>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    #[schemars(
        description = "Maximum automatic compactions per run. When omitted, the effective runtime default is 4. Set to 0 to remove only this per-run compaction-count cap.",
        extend("default" = DEFAULT_AUTO_COMPACTIONS_PER_RUN)
    )]
    pub max_compactions_per_run: Option<u8>,
}

impl AutoCompactionSettings {
    pub(crate) fn is_enabled(&self) -> bool {
        self.enabled
    }

    pub(crate) fn compaction_limit(&self) -> AutoCompactionLimit {
        let configured = self
            .max_compactions_per_run
            .unwrap_or(DEFAULT_AUTO_COMPACTIONS_PER_RUN);
        NonZeroUsize::new(usize::from(configured)).map_or(
            AutoCompactionLimit::NoCountCap,
            AutoCompactionLimit::Limited,
        )
    }

    fn is_default(&self) -> bool {
        self == &Self::default()
    }

    pub(crate) fn triggered(&self, projected_tokens: u64, model_max_tokens: u64) -> bool {
        if !self.enabled {
            return false;
        }
        self.threshold_percent.is_some_and(|percent| {
            u128::from(projected_tokens) * 100 >= u128::from(model_max_tokens) * u128::from(percent)
        }) || self
            .threshold_tokens
            .is_some_and(|threshold| projected_tokens >= threshold)
    }

    pub(crate) fn threshold_display(&self) -> Option<String> {
        match (self.threshold_percent, self.threshold_tokens) {
            (Some(percent), Some(tokens)) => Some(format!("{percent}% or {tokens} tokens")),
            (Some(percent), None) => Some(format!("{percent}%")),
            (None, Some(tokens)) => Some(format!("{tokens} tokens")),
            (None, None) => None,
        }
    }
}

#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
pub struct CompactionSettings {
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub provider: Option<String>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub model: Option<String>,
    #[serde(default, skip_serializing_if = "AutoCompactionSettings::is_default")]
    pub auto: AutoCompactionSettings,
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct CompactionConfig {
    pub(crate) provider: String,
    pub(crate) model: String,
}

impl CompactionSettings {
    pub(crate) fn is_default(&self) -> bool {
        self == &Self::default()
    }

    pub(crate) fn resolve_config(
        &self,
        active_provider: &str,
        active_model: &str,
    ) -> Result<CompactionConfig, String> {
        let provider = self.provider.as_deref().map(str::trim);
        let model = self.model.as_deref().map(str::trim);
        match (provider, model) {
            (None, None) => Ok(CompactionConfig {
                provider: non_blank_active(active_provider, "provider")?.to_string(),
                model: non_blank_active(active_model, "model")?.to_string(),
            }),
            (Some(provider), Some(model)) if !provider.is_empty() && !model.is_empty() => {
                Ok(CompactionConfig {
                    provider: provider.to_string(),
                    model: model.to_string(),
                })
            }
            _ => Err("compaction.provider and compaction.model must either both be configured and non-blank, or both be omitted to inherit the active provider/model".to_string()),
        }
    }
}

fn non_blank_active<'a>(value: &'a str, field: &str) -> Result<&'a str, String> {
    let trimmed = value.trim();
    if trimmed.is_empty() {
        Err(format!(
            "active assistant {field} is blank; set active provider/model or configure both compaction.provider and compaction.model"
        ))
    } else {
        Ok(trimmed)
    }
}

#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
pub struct SessionTitleSettings {
    #[serde(default)]
    pub enabled: bool,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub provider: Option<String>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub model: Option<String>,
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct SessionTitleConfig {
    pub(crate) provider: String,
    pub(crate) model: String,
}

impl SessionTitleSettings {
    pub(crate) fn is_default(&self) -> bool {
        self == &Self::default()
    }

    pub(crate) fn eligible_config(&self) -> Result<Option<SessionTitleConfig>, String> {
        if !self.enabled {
            return Ok(None);
        }
        let provider = self
            .provider
            .as_deref()
            .map(str::trim)
            .filter(|value| !value.is_empty())
            .ok_or_else(|| "session_titles.enabled is true but session_titles.provider is missing or blank; set an explicit title provider or disable session_titles".to_string())?;
        let model = self
            .model
            .as_deref()
            .map(str::trim)
            .filter(|value| !value.is_empty())
            .ok_or_else(|| "session_titles.enabled is true but session_titles.model is missing or blank; set an explicit title model or disable session_titles".to_string())?;
        Ok(Some(SessionTitleConfig {
            provider: provider.to_string(),
            model: model.to_string(),
        }))
    }
}

/// Independent session summarizer options; omitted selection fields inherit at runtime.
#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
pub struct SummarizerSettings {
    /// Enable the summarizer automatically for each session.
    #[serde(default)]
    pub auto_start: bool,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub provider: Option<String>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub model: Option<String>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub reasoning: Option<crate::thinking::ThinkingLevel>,
    /// Replace the built-in summarizer system prompt, rather than append to it.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub prompt: Option<String>,
}

impl SummarizerSettings {
    pub(crate) fn is_default(&self) -> bool {
        self == &Self::default()
    }

    pub(crate) fn validate(&self) -> anyhow::Result<()> {
        for (field, value) in [("provider", &self.provider), ("model", &self.model)] {
            if let Some(value) = value {
                super::tools::validate_model_identifier(
                    &format!("agent.summarizer.{field}"),
                    value,
                )?;
            }
        }
        if self
            .prompt
            .as_deref()
            .is_some_and(|prompt| prompt.trim().is_empty())
        {
            anyhow::bail!(
                "agent.summarizer.prompt must not be blank; omit it to use the built-in prompt"
            );
        }
        Ok(())
    }
}