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