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