Skip to main content

magi_code/config/settings/
agent.rs

1use schemars::JsonSchema;
2use serde::{Deserialize, Serialize};
3use std::{collections::BTreeMap, num::NonZeroUsize, path::PathBuf};
4
5use super::helpers::is_false;
6
7#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
8pub struct SideAgentSettings {
9    #[serde(default, skip_serializing_if = "Option::is_none")]
10    pub provider: Option<String>,
11    #[serde(default, skip_serializing_if = "Option::is_none")]
12    pub model: Option<String>,
13}
14
15impl SideAgentSettings {
16    pub(crate) fn is_default(&self) -> bool {
17        self == &Self::default()
18    }
19
20    pub(crate) fn validate(&self) -> anyhow::Result<()> {
21        anyhow::ensure!(
22            self.provider.is_some() == self.model.is_some(),
23            "agent.side.provider and agent.side.model must both be configured, or both omitted to inherit the primary provider/model"
24        );
25        for (field, value) in [("provider", &self.provider), ("model", &self.model)] {
26            if let Some(value) = value {
27                super::tools::validate_model_identifier(&format!("agent.side.{field}"), value)?;
28            }
29        }
30        Ok(())
31    }
32}
33
34#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
35pub struct SubagentsSettings {
36    #[serde(default, skip_serializing_if = "Vec::is_empty")]
37    pub disabled: Vec<String>,
38    #[serde(default, skip_serializing_if = "Option::is_none")]
39    pub schema_validation_max_retries: Option<u64>,
40    #[serde(default, flatten)]
41    pub(crate) extra: BTreeMap<String, serde_json::Value>,
42}
43
44impl SubagentsSettings {
45    pub(crate) fn schema_validation_max_retries(&self) -> u64 {
46        self.schema_validation_max_retries
47            .unwrap_or(DEFAULT_SUBAGENT_SCHEMA_VALIDATION_MAX_RETRIES)
48    }
49}
50
51#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
52pub struct ModelsSettings {
53    #[serde(default, skip_serializing_if = "Vec::is_empty")]
54    pub enabled: Vec<String>,
55    #[serde(default, flatten)]
56    pub(crate) extra: BTreeMap<String, serde_json::Value>,
57}
58
59impl ModelsSettings {
60    pub(crate) fn is_default(&self) -> bool {
61        self == &Self::default()
62    }
63}
64
65pub const DEFAULT_SUBAGENT_SCHEMA_VALIDATION_MAX_RETRIES: u64 = 2;
66pub const MAX_SUBAGENT_SCHEMA_VALIDATION_MAX_RETRIES: u64 = 5;
67
68#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
69pub struct IntegrationsSettings {
70    #[serde(default, skip_serializing_if = "HerdrSettings::is_default")]
71    pub herdr: HerdrSettings,
72    #[serde(default, flatten)]
73    pub(crate) extra: BTreeMap<String, serde_json::Value>,
74}
75
76impl IntegrationsSettings {
77    pub(crate) fn is_default(&self) -> bool {
78        self == &Self::default()
79    }
80}
81
82#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
83pub struct HerdrSettings {
84    #[serde(default)]
85    pub enabled: bool,
86    #[serde(default, flatten)]
87    pub(crate) extra: BTreeMap<String, serde_json::Value>,
88}
89
90impl HerdrSettings {
91    pub(crate) fn is_default(&self) -> bool {
92        self == &Self::default()
93    }
94}
95
96pub const DEFAULT_TUI_SUBAGENT_CARD_ROWS: usize = 16;
97pub const MIN_TUI_SUBAGENT_CARD_ROWS: usize = 1;
98pub const MAX_TUI_SUBAGENT_CARD_ROWS: usize = 50;
99
100#[derive(Debug, Clone, Copy, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
101#[serde(rename_all = "snake_case")]
102pub(crate) enum HeaderPanel {
103    Summary,
104    ActivityTree,
105    SessionFiles,
106}
107
108#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
109#[serde(default)]
110pub(crate) struct PanelLayoutSettings {
111    pub(crate) open: Vec<HeaderPanel>,
112    pub(crate) pinned: bool,
113    pub(crate) opened_by_pin: bool,
114    #[serde(flatten)]
115    pub(crate) extra: BTreeMap<String, serde_json::Value>,
116}
117
118impl PanelLayoutSettings {
119    fn is_default(&self) -> bool {
120        self == &Self::default()
121    }
122}
123
124#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
125pub struct TuiSettings {
126    #[serde(default, skip_serializing_if = "Option::is_none")]
127    #[schemars(range(min = 1, max = 50))]
128    pub(crate) subagent_card_rows: Option<usize>,
129    #[serde(default, skip_serializing_if = "PanelLayoutSettings::is_default")]
130    pub(crate) panel_layout: PanelLayoutSettings,
131    #[serde(default, flatten)]
132    pub(crate) extra: BTreeMap<String, serde_json::Value>,
133}
134
135impl TuiSettings {
136    pub(crate) fn is_default(&self) -> bool {
137        self == &Self::default()
138    }
139
140    pub(crate) fn subagent_card_rows(&self) -> usize {
141        self.subagent_card_rows
142            .unwrap_or(DEFAULT_TUI_SUBAGENT_CARD_ROWS)
143    }
144}
145
146#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
147pub struct InstructionsSettings {
148    #[serde(default, skip_serializing_if = "is_false")]
149    pub subdir_discovery: bool,
150    #[serde(default, skip_serializing_if = "Vec::is_empty")]
151    pub additional_markdown_paths: Vec<PathBuf>,
152    #[serde(default, flatten)]
153    pub(crate) extra: BTreeMap<String, serde_json::Value>,
154}
155
156impl InstructionsSettings {
157    pub(crate) fn is_default(&self) -> bool {
158        self == &Self::default()
159    }
160}
161
162#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
163pub struct SkillsSettings {
164    #[serde(default, skip_serializing_if = "Vec::is_empty")]
165    pub additional_paths: Vec<PathBuf>,
166    #[serde(default, skip_serializing_if = "Vec::is_empty")]
167    pub disabled: Vec<String>,
168    #[serde(default, flatten)]
169    pub(crate) extra: BTreeMap<String, serde_json::Value>,
170}
171
172impl SkillsSettings {
173    pub(crate) fn is_default(&self) -> bool {
174        self == &Self::default()
175    }
176}
177
178pub(crate) const DEFAULT_AUTO_COMPACTIONS_PER_RUN: u8 = 4;
179
180#[derive(Debug, Clone, Copy, PartialEq, Eq)]
181pub(crate) enum AutoCompactionLimit {
182    Limited(NonZeroUsize),
183    NoCountCap,
184}
185
186impl AutoCompactionLimit {
187    pub(crate) fn allows(self, completed_compactions: usize) -> bool {
188        match self {
189            Self::Limited(limit) => completed_compactions < limit.get(),
190            Self::NoCountCap => true,
191        }
192    }
193}
194
195#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
196#[serde(default)]
197pub struct AutoCompactionSettings {
198    pub enabled: bool,
199    pub threshold_percent: Option<u8>,
200    #[serde(default, skip_serializing_if = "Option::is_none")]
201    pub threshold_tokens: Option<u64>,
202    #[serde(default, skip_serializing_if = "Option::is_none")]
203    #[schemars(
204        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.",
205        extend("default" = DEFAULT_AUTO_COMPACTIONS_PER_RUN)
206    )]
207    pub max_compactions_per_run: Option<u8>,
208}
209
210impl Default for AutoCompactionSettings {
211    fn default() -> Self {
212        Self {
213            enabled: true,
214            threshold_percent: Some(80),
215            threshold_tokens: None,
216            max_compactions_per_run: None,
217        }
218    }
219}
220
221impl AutoCompactionSettings {
222    pub(crate) fn is_enabled(&self) -> bool {
223        self.enabled
224    }
225
226    pub(crate) fn compaction_limit(&self) -> AutoCompactionLimit {
227        let configured = self
228            .max_compactions_per_run
229            .unwrap_or(DEFAULT_AUTO_COMPACTIONS_PER_RUN);
230        NonZeroUsize::new(usize::from(configured)).map_or(
231            AutoCompactionLimit::NoCountCap,
232            AutoCompactionLimit::Limited,
233        )
234    }
235
236    fn is_default(&self) -> bool {
237        self == &Self::default()
238    }
239
240    pub(crate) fn triggered(&self, projected_tokens: u64, model_max_tokens: u64) -> bool {
241        if !self.enabled {
242            return false;
243        }
244        self.threshold_percent.is_some_and(|percent| {
245            u128::from(projected_tokens) * 100 >= u128::from(model_max_tokens) * u128::from(percent)
246        }) || self
247            .threshold_tokens
248            .is_some_and(|threshold| projected_tokens >= threshold)
249    }
250
251    pub(crate) fn threshold_display(&self) -> Option<String> {
252        match (self.threshold_percent, self.threshold_tokens) {
253            (Some(percent), Some(tokens)) => Some(format!("{percent}% or {tokens} tokens")),
254            (Some(percent), None) => Some(format!("{percent}%")),
255            (None, Some(tokens)) => Some(format!("{tokens} tokens")),
256            (None, None) => None,
257        }
258    }
259}
260
261#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
262pub struct CompactionSettings {
263    #[serde(default, skip_serializing_if = "Option::is_none")]
264    pub provider: Option<String>,
265    #[serde(default, skip_serializing_if = "Option::is_none")]
266    pub model: Option<String>,
267    #[serde(default, skip_serializing_if = "AutoCompactionSettings::is_default")]
268    pub auto: AutoCompactionSettings,
269}
270
271#[derive(Debug, Clone, PartialEq, Eq)]
272pub(crate) struct CompactionConfig {
273    pub(crate) provider: String,
274    pub(crate) model: String,
275}
276
277impl CompactionSettings {
278    pub(crate) fn is_default(&self) -> bool {
279        self == &Self::default()
280    }
281
282    pub(crate) fn resolve_config(
283        &self,
284        active_provider: &str,
285        active_model: &str,
286    ) -> Result<CompactionConfig, String> {
287        let provider = self.provider.as_deref().map(str::trim);
288        let model = self.model.as_deref().map(str::trim);
289        match (provider, model) {
290            (None, None) => Ok(CompactionConfig {
291                provider: non_blank_active(active_provider, "provider")?.to_string(),
292                model: non_blank_active(active_model, "model")?.to_string(),
293            }),
294            (Some(provider), Some(model)) if !provider.is_empty() && !model.is_empty() => {
295                Ok(CompactionConfig {
296                    provider: provider.to_string(),
297                    model: model.to_string(),
298                })
299            }
300            _ => 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()),
301        }
302    }
303}
304
305fn non_blank_active<'a>(value: &'a str, field: &str) -> Result<&'a str, String> {
306    let trimmed = value.trim();
307    if trimmed.is_empty() {
308        Err(format!(
309            "active assistant {field} is blank; set active provider/model or configure both compaction.provider and compaction.model"
310        ))
311    } else {
312        Ok(trimmed)
313    }
314}
315
316#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
317pub struct SessionTitleSettings {
318    #[serde(default)]
319    pub enabled: bool,
320    #[serde(default, skip_serializing_if = "Option::is_none")]
321    pub provider: Option<String>,
322    #[serde(default, skip_serializing_if = "Option::is_none")]
323    pub model: Option<String>,
324}
325
326#[derive(Debug, Clone, PartialEq, Eq)]
327pub(crate) struct SessionTitleConfig {
328    pub(crate) provider: String,
329    pub(crate) model: String,
330}
331
332impl SessionTitleSettings {
333    pub(crate) fn is_default(&self) -> bool {
334        self == &Self::default()
335    }
336
337    pub(crate) fn eligible_config(&self) -> Result<Option<SessionTitleConfig>, String> {
338        if !self.enabled {
339            return Ok(None);
340        }
341        let provider = self
342            .provider
343            .as_deref()
344            .map(str::trim)
345            .filter(|value| !value.is_empty())
346            .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())?;
347        let model = self
348            .model
349            .as_deref()
350            .map(str::trim)
351            .filter(|value| !value.is_empty())
352            .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())?;
353        Ok(Some(SessionTitleConfig {
354            provider: provider.to_string(),
355            model: model.to_string(),
356        }))
357    }
358}
359
360/// Independent session summarizer options; omitted selection fields inherit at runtime.
361#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
362pub struct SummarizerSettings {
363    /// Enable the summarizer automatically for each session.
364    #[serde(default)]
365    pub auto_start: bool,
366    #[serde(default, skip_serializing_if = "Option::is_none")]
367    pub provider: Option<String>,
368    #[serde(default, skip_serializing_if = "Option::is_none")]
369    pub model: Option<String>,
370    #[serde(default, skip_serializing_if = "Option::is_none")]
371    pub reasoning: Option<crate::thinking::ThinkingLevel>,
372    /// Replace the built-in summarizer system prompt, rather than append to it.
373    #[serde(default, skip_serializing_if = "Option::is_none")]
374    pub prompt: Option<String>,
375}
376
377impl SummarizerSettings {
378    pub(crate) fn is_default(&self) -> bool {
379        self == &Self::default()
380    }
381
382    pub(crate) fn validate(&self) -> anyhow::Result<()> {
383        for (field, value) in [("provider", &self.provider), ("model", &self.model)] {
384            if let Some(value) = value {
385                super::tools::validate_model_identifier(
386                    &format!("agent.summarizer.{field}"),
387                    value,
388                )?;
389            }
390        }
391        if self
392            .prompt
393            .as_deref()
394            .is_some_and(|prompt| prompt.trim().is_empty())
395        {
396            anyhow::bail!(
397                "agent.summarizer.prompt must not be blank; omit it to use the built-in prompt"
398            );
399        }
400        Ok(())
401    }
402}