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::{default_true, is_false, is_true};
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 disabled: 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, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
101pub struct TuiSettings {
102    #[serde(default = "default_true", skip_serializing_if = "is_true")]
103    #[schemars(!skip_serializing_if)]
104    pub(crate) show_panel_on_startup: bool,
105    #[serde(default, skip_serializing_if = "Option::is_none")]
106    #[schemars(range(min = 1, max = 50))]
107    pub(crate) subagent_card_rows: Option<usize>,
108    #[serde(default, flatten)]
109    pub(crate) extra: BTreeMap<String, serde_json::Value>,
110}
111
112impl Default for TuiSettings {
113    fn default() -> Self {
114        Self {
115            show_panel_on_startup: true,
116            subagent_card_rows: None,
117            extra: BTreeMap::new(),
118        }
119    }
120}
121
122impl TuiSettings {
123    pub(crate) fn is_default(&self) -> bool {
124        self == &Self::default()
125    }
126
127    pub(crate) fn subagent_card_rows(&self) -> usize {
128        self.subagent_card_rows
129            .unwrap_or(DEFAULT_TUI_SUBAGENT_CARD_ROWS)
130    }
131}
132
133#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
134pub struct InstructionsSettings {
135    #[serde(default, skip_serializing_if = "is_false")]
136    pub subdir_discovery: bool,
137    #[serde(default, skip_serializing_if = "Vec::is_empty")]
138    pub additional_markdown_paths: Vec<PathBuf>,
139    #[serde(default, flatten)]
140    pub(crate) extra: BTreeMap<String, serde_json::Value>,
141}
142
143impl InstructionsSettings {
144    pub(crate) fn is_default(&self) -> bool {
145        self == &Self::default()
146    }
147}
148
149#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
150pub struct SkillsSettings {
151    #[serde(default, skip_serializing_if = "Vec::is_empty")]
152    pub additional_paths: Vec<PathBuf>,
153    #[serde(default, skip_serializing_if = "Vec::is_empty")]
154    pub disabled: Vec<String>,
155    #[serde(default, flatten)]
156    pub(crate) extra: BTreeMap<String, serde_json::Value>,
157}
158
159impl SkillsSettings {
160    pub(crate) fn is_default(&self) -> bool {
161        self == &Self::default()
162    }
163}
164
165pub(crate) const DEFAULT_AUTO_COMPACTIONS_PER_RUN: u8 = 4;
166
167#[derive(Debug, Clone, Copy, PartialEq, Eq)]
168pub(crate) enum AutoCompactionLimit {
169    Limited(NonZeroUsize),
170    NoCountCap,
171}
172
173impl AutoCompactionLimit {
174    pub(crate) fn allows(self, completed_compactions: usize) -> bool {
175        match self {
176            Self::Limited(limit) => completed_compactions < limit.get(),
177            Self::NoCountCap => true,
178        }
179    }
180}
181
182#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
183#[serde(default)]
184pub struct AutoCompactionSettings {
185    pub enabled: bool,
186    pub threshold_percent: Option<u8>,
187    #[serde(default, skip_serializing_if = "Option::is_none")]
188    pub threshold_tokens: Option<u64>,
189    #[serde(default, skip_serializing_if = "Option::is_none")]
190    #[schemars(
191        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.",
192        extend("default" = DEFAULT_AUTO_COMPACTIONS_PER_RUN)
193    )]
194    pub max_compactions_per_run: Option<u8>,
195}
196
197impl Default for AutoCompactionSettings {
198    fn default() -> Self {
199        Self {
200            enabled: true,
201            threshold_percent: Some(80),
202            threshold_tokens: None,
203            max_compactions_per_run: None,
204        }
205    }
206}
207
208impl AutoCompactionSettings {
209    pub(crate) fn is_enabled(&self) -> bool {
210        self.enabled
211    }
212
213    pub(crate) fn compaction_limit(&self) -> AutoCompactionLimit {
214        let configured = self
215            .max_compactions_per_run
216            .unwrap_or(DEFAULT_AUTO_COMPACTIONS_PER_RUN);
217        NonZeroUsize::new(usize::from(configured)).map_or(
218            AutoCompactionLimit::NoCountCap,
219            AutoCompactionLimit::Limited,
220        )
221    }
222
223    fn is_default(&self) -> bool {
224        self == &Self::default()
225    }
226
227    pub(crate) fn triggered(&self, projected_tokens: u64, model_max_tokens: u64) -> bool {
228        if !self.enabled {
229            return false;
230        }
231        self.threshold_percent.is_some_and(|percent| {
232            u128::from(projected_tokens) * 100 >= u128::from(model_max_tokens) * u128::from(percent)
233        }) || self
234            .threshold_tokens
235            .is_some_and(|threshold| projected_tokens >= threshold)
236    }
237
238    pub(crate) fn threshold_display(&self) -> Option<String> {
239        match (self.threshold_percent, self.threshold_tokens) {
240            (Some(percent), Some(tokens)) => Some(format!("{percent}% or {tokens} tokens")),
241            (Some(percent), None) => Some(format!("{percent}%")),
242            (None, Some(tokens)) => Some(format!("{tokens} tokens")),
243            (None, None) => None,
244        }
245    }
246}
247
248#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
249pub struct CompactionSettings {
250    #[serde(default, skip_serializing_if = "Option::is_none")]
251    pub provider: Option<String>,
252    #[serde(default, skip_serializing_if = "Option::is_none")]
253    pub model: Option<String>,
254    #[serde(default, skip_serializing_if = "AutoCompactionSettings::is_default")]
255    pub auto: AutoCompactionSettings,
256}
257
258#[derive(Debug, Clone, PartialEq, Eq)]
259pub(crate) struct CompactionConfig {
260    pub(crate) provider: String,
261    pub(crate) model: String,
262}
263
264impl CompactionSettings {
265    pub(crate) fn is_default(&self) -> bool {
266        self == &Self::default()
267    }
268
269    pub(crate) fn resolve_config(
270        &self,
271        active_provider: &str,
272        active_model: &str,
273    ) -> Result<CompactionConfig, String> {
274        let provider = self.provider.as_deref().map(str::trim);
275        let model = self.model.as_deref().map(str::trim);
276        match (provider, model) {
277            (None, None) => Ok(CompactionConfig {
278                provider: non_blank_active(active_provider, "provider")?.to_string(),
279                model: non_blank_active(active_model, "model")?.to_string(),
280            }),
281            (Some(provider), Some(model)) if !provider.is_empty() && !model.is_empty() => {
282                Ok(CompactionConfig {
283                    provider: provider.to_string(),
284                    model: model.to_string(),
285                })
286            }
287            _ => 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()),
288        }
289    }
290}
291
292fn non_blank_active<'a>(value: &'a str, field: &str) -> Result<&'a str, String> {
293    let trimmed = value.trim();
294    if trimmed.is_empty() {
295        Err(format!(
296            "active assistant {field} is blank; set active provider/model or configure both compaction.provider and compaction.model"
297        ))
298    } else {
299        Ok(trimmed)
300    }
301}
302
303#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
304pub struct SessionTitleSettings {
305    #[serde(default)]
306    pub enabled: bool,
307    #[serde(default, skip_serializing_if = "Option::is_none")]
308    pub provider: Option<String>,
309    #[serde(default, skip_serializing_if = "Option::is_none")]
310    pub model: Option<String>,
311}
312
313#[derive(Debug, Clone, PartialEq, Eq)]
314pub(crate) struct SessionTitleConfig {
315    pub(crate) provider: String,
316    pub(crate) model: String,
317}
318
319impl SessionTitleSettings {
320    pub(crate) fn is_default(&self) -> bool {
321        self == &Self::default()
322    }
323
324    pub(crate) fn eligible_config(&self) -> Result<Option<SessionTitleConfig>, String> {
325        if !self.enabled {
326            return Ok(None);
327        }
328        let provider = self
329            .provider
330            .as_deref()
331            .map(str::trim)
332            .filter(|value| !value.is_empty())
333            .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())?;
334        let model = self
335            .model
336            .as_deref()
337            .map(str::trim)
338            .filter(|value| !value.is_empty())
339            .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())?;
340        Ok(Some(SessionTitleConfig {
341            provider: provider.to_string(),
342            model: model.to_string(),
343        }))
344    }
345}
346
347/// Independent session summarizer options; omitted selection fields inherit at runtime.
348#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
349pub struct SummarizerSettings {
350    /// Enable the summarizer automatically for each session.
351    #[serde(default)]
352    pub auto_start: bool,
353    #[serde(default, skip_serializing_if = "Option::is_none")]
354    pub provider: Option<String>,
355    #[serde(default, skip_serializing_if = "Option::is_none")]
356    pub model: Option<String>,
357    #[serde(default, skip_serializing_if = "Option::is_none")]
358    pub reasoning: Option<crate::thinking::ThinkingLevel>,
359    /// Replace the built-in summarizer system prompt, rather than append to it.
360    #[serde(default, skip_serializing_if = "Option::is_none")]
361    pub prompt: Option<String>,
362}
363
364impl SummarizerSettings {
365    pub(crate) fn is_default(&self) -> bool {
366        self == &Self::default()
367    }
368
369    pub(crate) fn validate(&self) -> anyhow::Result<()> {
370        for (field, value) in [("provider", &self.provider), ("model", &self.model)] {
371            if let Some(value) = value {
372                super::tools::validate_model_identifier(
373                    &format!("agent.summarizer.{field}"),
374                    value,
375                )?;
376            }
377        }
378        if self
379            .prompt
380            .as_deref()
381            .is_some_and(|prompt| prompt.trim().is_empty())
382        {
383            anyhow::bail!(
384                "agent.summarizer.prompt must not be blank; omit it to use the built-in prompt"
385            );
386        }
387        Ok(())
388    }
389}