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, Default, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
170pub struct AutoCompactionSettings {
171    #[serde(default, skip_serializing_if = "is_false")]
172    pub enabled: bool,
173    #[serde(default, skip_serializing_if = "Option::is_none")]
174    pub threshold_percent: Option<u8>,
175    #[serde(default, skip_serializing_if = "Option::is_none")]
176    pub threshold_tokens: Option<u64>,
177    #[serde(default, skip_serializing_if = "Option::is_none")]
178    #[schemars(
179        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.",
180        extend("default" = DEFAULT_AUTO_COMPACTIONS_PER_RUN)
181    )]
182    pub max_compactions_per_run: Option<u8>,
183}
184
185impl AutoCompactionSettings {
186    pub(crate) fn is_enabled(&self) -> bool {
187        self.enabled
188    }
189
190    pub(crate) fn compaction_limit(&self) -> AutoCompactionLimit {
191        let configured = self
192            .max_compactions_per_run
193            .unwrap_or(DEFAULT_AUTO_COMPACTIONS_PER_RUN);
194        NonZeroUsize::new(usize::from(configured)).map_or(
195            AutoCompactionLimit::NoCountCap,
196            AutoCompactionLimit::Limited,
197        )
198    }
199
200    fn is_default(&self) -> bool {
201        self == &Self::default()
202    }
203
204    pub(crate) fn triggered(&self, projected_tokens: u64, model_max_tokens: u64) -> bool {
205        if !self.enabled {
206            return false;
207        }
208        self.threshold_percent.is_some_and(|percent| {
209            u128::from(projected_tokens) * 100 >= u128::from(model_max_tokens) * u128::from(percent)
210        }) || self
211            .threshold_tokens
212            .is_some_and(|threshold| projected_tokens >= threshold)
213    }
214
215    pub(crate) fn threshold_display(&self) -> Option<String> {
216        match (self.threshold_percent, self.threshold_tokens) {
217            (Some(percent), Some(tokens)) => Some(format!("{percent}% or {tokens} tokens")),
218            (Some(percent), None) => Some(format!("{percent}%")),
219            (None, Some(tokens)) => Some(format!("{tokens} tokens")),
220            (None, None) => None,
221        }
222    }
223}
224
225#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
226pub struct CompactionSettings {
227    #[serde(default, skip_serializing_if = "Option::is_none")]
228    pub provider: Option<String>,
229    #[serde(default, skip_serializing_if = "Option::is_none")]
230    pub model: Option<String>,
231    #[serde(default, skip_serializing_if = "AutoCompactionSettings::is_default")]
232    pub auto: AutoCompactionSettings,
233}
234
235#[derive(Debug, Clone, PartialEq, Eq)]
236pub(crate) struct CompactionConfig {
237    pub(crate) provider: String,
238    pub(crate) model: String,
239}
240
241impl CompactionSettings {
242    pub(crate) fn is_default(&self) -> bool {
243        self == &Self::default()
244    }
245
246    pub(crate) fn resolve_config(
247        &self,
248        active_provider: &str,
249        active_model: &str,
250    ) -> Result<CompactionConfig, String> {
251        let provider = self.provider.as_deref().map(str::trim);
252        let model = self.model.as_deref().map(str::trim);
253        match (provider, model) {
254            (None, None) => Ok(CompactionConfig {
255                provider: non_blank_active(active_provider, "provider")?.to_string(),
256                model: non_blank_active(active_model, "model")?.to_string(),
257            }),
258            (Some(provider), Some(model)) if !provider.is_empty() && !model.is_empty() => {
259                Ok(CompactionConfig {
260                    provider: provider.to_string(),
261                    model: model.to_string(),
262                })
263            }
264            _ => 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()),
265        }
266    }
267}
268
269fn non_blank_active<'a>(value: &'a str, field: &str) -> Result<&'a str, String> {
270    let trimmed = value.trim();
271    if trimmed.is_empty() {
272        Err(format!(
273            "active assistant {field} is blank; set active provider/model or configure both compaction.provider and compaction.model"
274        ))
275    } else {
276        Ok(trimmed)
277    }
278}
279
280#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
281pub struct SessionTitleSettings {
282    #[serde(default)]
283    pub enabled: bool,
284    #[serde(default, skip_serializing_if = "Option::is_none")]
285    pub provider: Option<String>,
286    #[serde(default, skip_serializing_if = "Option::is_none")]
287    pub model: Option<String>,
288}
289
290#[derive(Debug, Clone, PartialEq, Eq)]
291pub(crate) struct SessionTitleConfig {
292    pub(crate) provider: String,
293    pub(crate) model: String,
294}
295
296impl SessionTitleSettings {
297    pub(crate) fn is_default(&self) -> bool {
298        self == &Self::default()
299    }
300
301    pub(crate) fn eligible_config(&self) -> Result<Option<SessionTitleConfig>, String> {
302        if !self.enabled {
303            return Ok(None);
304        }
305        let provider = self
306            .provider
307            .as_deref()
308            .map(str::trim)
309            .filter(|value| !value.is_empty())
310            .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())?;
311        let model = self
312            .model
313            .as_deref()
314            .map(str::trim)
315            .filter(|value| !value.is_empty())
316            .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())?;
317        Ok(Some(SessionTitleConfig {
318            provider: provider.to_string(),
319            model: model.to_string(),
320        }))
321    }
322}
323
324/// Independent session summarizer options; omitted selection fields inherit at runtime.
325#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
326pub struct SummarizerSettings {
327    /// Enable the summarizer automatically for each session.
328    #[serde(default)]
329    pub auto_start: bool,
330    #[serde(default, skip_serializing_if = "Option::is_none")]
331    pub provider: Option<String>,
332    #[serde(default, skip_serializing_if = "Option::is_none")]
333    pub model: Option<String>,
334    #[serde(default, skip_serializing_if = "Option::is_none")]
335    pub reasoning: Option<crate::thinking::ThinkingLevel>,
336    /// Replace the built-in summarizer system prompt, rather than append to it.
337    #[serde(default, skip_serializing_if = "Option::is_none")]
338    pub prompt: Option<String>,
339}
340
341impl SummarizerSettings {
342    pub(crate) fn is_default(&self) -> bool {
343        self == &Self::default()
344    }
345
346    pub(crate) fn validate(&self) -> anyhow::Result<()> {
347        for (field, value) in [("provider", &self.provider), ("model", &self.model)] {
348            if let Some(value) = value {
349                super::tools::validate_model_identifier(
350                    &format!("agent.summarizer.{field}"),
351                    value,
352                )?;
353            }
354        }
355        if self
356            .prompt
357            .as_deref()
358            .is_some_and(|prompt| prompt.trim().is_empty())
359        {
360            anyhow::bail!(
361                "agent.summarizer.prompt must not be blank; omit it to use the built-in prompt"
362            );
363        }
364        Ok(())
365    }
366}