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, 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, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
262#[serde(default)]
263pub struct ClaudeSubscriptionCompactionSettings {
264    pub allow_compaction: bool,
265    pub reserve_tokens: usize,
266    pub threshold_percent: Option<u8>,
267    pub threshold_tokens: Option<u64>,
268}
269
270impl Default for ClaudeSubscriptionCompactionSettings {
271    fn default() -> Self {
272        Self {
273            allow_compaction: true,
274            reserve_tokens: 16_384,
275            threshold_percent: Some(100),
276            threshold_tokens: Some(crate::providers::CLAUDE_SUBSCRIPTION_MAX_PROMPT_TOKENS as u64),
277        }
278    }
279}
280
281impl ClaudeSubscriptionCompactionSettings {
282    fn is_default(&self) -> bool {
283        self == &Self::default()
284    }
285}
286
287#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
288pub struct CompactionSettings {
289    #[serde(default, skip_serializing_if = "Option::is_none")]
290    pub provider: Option<String>,
291    #[serde(default, skip_serializing_if = "Option::is_none")]
292    pub model: Option<String>,
293    #[serde(default, skip_serializing_if = "AutoCompactionSettings::is_default")]
294    pub auto: AutoCompactionSettings,
295    #[serde(
296        default,
297        skip_serializing_if = "ClaudeSubscriptionCompactionSettings::is_default"
298    )]
299    pub claude_subscription: ClaudeSubscriptionCompactionSettings,
300}
301
302#[derive(Debug, Clone, PartialEq, Eq)]
303pub(crate) struct CompactionConfig {
304    pub(crate) provider: String,
305    pub(crate) model: String,
306}
307
308impl CompactionSettings {
309    fn claude_subscription_compaction_reserve(&self) -> usize {
310        let compacts_with_subscription = self.claude_subscription.allow_compaction
311            && self
312                .provider
313                .as_deref()
314                .map(str::trim)
315                .unwrap_or(crate::providers::CLAUDE_SUBSCRIPTION_PROVIDER)
316                == crate::providers::CLAUDE_SUBSCRIPTION_PROVIDER;
317        if compacts_with_subscription {
318            self.claude_subscription.reserve_tokens
319        } else {
320            0
321        }
322    }
323
324    pub(crate) fn claude_subscription_context_limit(&self) -> usize {
325        crate::providers::CLAUDE_SUBSCRIPTION_MAX_PROMPT_TOKENS
326            .saturating_sub(self.claude_subscription_compaction_reserve())
327    }
328
329    pub(crate) fn apply_conversation_limit(
330        &self,
331        budget: &mut crate::context::ContextBudget,
332        provider: &str,
333    ) {
334        if provider == crate::providers::CLAUDE_SUBSCRIPTION_PROVIDER {
335            budget.max_tokens = budget
336                .max_tokens
337                .min(crate::providers::CLAUDE_SUBSCRIPTION_MAX_PROMPT_TOKENS)
338                .saturating_sub(self.claude_subscription_compaction_reserve());
339        }
340    }
341
342    pub(crate) fn auto_for_provider(
343        &self,
344        provider: &str,
345        max_tokens: usize,
346    ) -> AutoCompactionSettings {
347        if provider != crate::providers::CLAUDE_SUBSCRIPTION_PROVIDER {
348            return self.auto.clone();
349        }
350        let subscription = &self.claude_subscription;
351        let ceiling = self.claude_subscription_context_limit().min(max_tokens) as u64;
352        AutoCompactionSettings {
353            enabled: true,
354            threshold_percent: subscription.threshold_percent,
355            threshold_tokens: Some(
356                subscription
357                    .threshold_tokens
358                    .unwrap_or(ceiling)
359                    .min(ceiling),
360            ),
361            // Claude subscription compaction has no per-run count cap.
362            max_compactions_per_run: Some(0),
363        }
364    }
365
366    pub(crate) fn is_default(&self) -> bool {
367        self == &Self::default()
368    }
369
370    pub(crate) fn resolve_config(
371        &self,
372        active_provider: &str,
373        active_model: &str,
374    ) -> Result<CompactionConfig, String> {
375        if !self.claude_subscription.allow_compaction
376            && (self.provider.as_deref().is_none_or(|provider| {
377                provider.trim() == crate::providers::CLAUDE_SUBSCRIPTION_PROVIDER
378            }) || self
379                .model
380                .as_deref()
381                .is_none_or(|model| model.trim().is_empty()))
382        {
383            return Err("Claude subscription compaction is disabled; select a different provider's Compaction model in Settings → Agent settings".to_string());
384        }
385        let provider = self.provider.as_deref().map(str::trim);
386        let model = self.model.as_deref().map(str::trim);
387        match (provider, model) {
388            (None, None) => Ok(CompactionConfig {
389                provider: non_blank_active(active_provider, "provider")?.to_string(),
390                model: non_blank_active(active_model, "model")?.to_string(),
391            }),
392            (Some(provider), Some(model)) if !provider.is_empty() && !model.is_empty() => {
393                Ok(CompactionConfig {
394                    provider: provider.to_string(),
395                    model: model.to_string(),
396                })
397            }
398            _ => 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()),
399        }
400    }
401}
402
403fn non_blank_active<'a>(value: &'a str, field: &str) -> Result<&'a str, String> {
404    let trimmed = value.trim();
405    if trimmed.is_empty() {
406        Err(format!(
407            "active assistant {field} is blank; set active provider/model or configure both compaction.provider and compaction.model"
408        ))
409    } else {
410        Ok(trimmed)
411    }
412}
413
414#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
415pub struct SessionTitleSettings {
416    #[serde(default)]
417    pub enabled: bool,
418    #[serde(default, skip_serializing_if = "Option::is_none")]
419    pub provider: Option<String>,
420    #[serde(default, skip_serializing_if = "Option::is_none")]
421    pub model: Option<String>,
422}
423
424#[derive(Debug, Clone, PartialEq, Eq)]
425pub(crate) struct SessionTitleConfig {
426    pub(crate) provider: String,
427    pub(crate) model: String,
428}
429
430impl SessionTitleSettings {
431    pub(crate) fn is_default(&self) -> bool {
432        self == &Self::default()
433    }
434
435    pub(crate) fn eligible_config(&self) -> Result<Option<SessionTitleConfig>, String> {
436        if !self.enabled {
437            return Ok(None);
438        }
439        let provider = self
440            .provider
441            .as_deref()
442            .map(str::trim)
443            .filter(|value| !value.is_empty())
444            .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())?;
445        let model = self
446            .model
447            .as_deref()
448            .map(str::trim)
449            .filter(|value| !value.is_empty())
450            .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())?;
451        Ok(Some(SessionTitleConfig {
452            provider: provider.to_string(),
453            model: model.to_string(),
454        }))
455    }
456}
457
458/// Independent session summarizer options; omitted selection fields inherit at runtime.
459#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
460pub struct SummarizerSettings {
461    /// Enable the summarizer automatically for each session.
462    #[serde(default)]
463    pub auto_start: bool,
464    #[serde(default, skip_serializing_if = "Option::is_none")]
465    pub provider: Option<String>,
466    #[serde(default, skip_serializing_if = "Option::is_none")]
467    pub model: Option<String>,
468    #[serde(default, skip_serializing_if = "Option::is_none")]
469    pub reasoning: Option<crate::thinking::ThinkingLevel>,
470    /// Replace the built-in summarizer system prompt, rather than append to it.
471    #[serde(default, skip_serializing_if = "Option::is_none")]
472    pub prompt: Option<String>,
473}
474
475impl SummarizerSettings {
476    pub(crate) fn is_default(&self) -> bool {
477        self == &Self::default()
478    }
479
480    pub(crate) fn validate(&self) -> anyhow::Result<()> {
481        for (field, value) in [("provider", &self.provider), ("model", &self.model)] {
482            if let Some(value) = value {
483                super::tools::validate_model_identifier(
484                    &format!("agent.summarizer.{field}"),
485                    value,
486                )?;
487            }
488        }
489        if self
490            .prompt
491            .as_deref()
492            .is_some_and(|prompt| prompt.trim().is_empty())
493        {
494            anyhow::bail!(
495                "agent.summarizer.prompt must not be blank; omit it to use the built-in prompt"
496            );
497        }
498        Ok(())
499    }
500}