Skip to main content

magi_code/config/settings/
wire.rs

1use schemars::JsonSchema;
2use serde::{
3    Deserialize, Serialize,
4    de::{self, DeserializeOwned},
5    ser::Serializer,
6};
7use serde_json::{Map, Value, json};
8use std::collections::BTreeMap;
9
10use super::{
11    agent::{
12        CompactionSettings, InstructionsSettings, IntegrationsSettings, ModelsSettings,
13        SessionTitleSettings, SkillsSettings,
14    },
15    core::{
16        AnthropicCacheTtl, AppearanceSettings, FastSettings, JevSettings, OpenAiCodexSettings,
17        OpenAiResponsesSettings, SessionSettings, Settings, default_anthropic_cache_ttl,
18    },
19    helpers::default_file_autocomplete_respects_gitignore,
20    providers::{ProviderStreamSettings, SelectedModelSettings},
21    services::LspSettings,
22    tools::{SubagentsToolSettings, ToolSettings},
23};
24use crate::config::{CustomProviderConfig, HookSettings};
25
26pub(crate) const SETTINGS_SCHEMA_VERSION: u64 = 2;
27
28fn default_session_retention_days() -> u64 {
29    super::core::DEFAULT_SESSION_RETENTION_DAYS
30}
31
32#[expect(dead_code)]
33#[derive(Default, JsonSchema)]
34struct SettingsSchema {
35    #[schemars(range(min = 2, max = 2))]
36    pub schema_version: u64,
37    #[serde(default)]
38    #[schemars(skip_serializing_if = "omit_schema_default")]
39    pub agent: AgentSettingsSchema,
40    #[serde(default)]
41    #[schemars(skip_serializing_if = "omit_schema_default")]
42    pub providers: ProvidersSettingsSchema,
43    #[serde(default)]
44    #[schemars(skip_serializing_if = "omit_schema_default")]
45    pub capabilities: CapabilitiesSettingsSchema,
46    #[serde(default)]
47    #[schemars(skip_serializing_if = "omit_schema_default")]
48    pub knowledge: KnowledgeSettingsSchema,
49    #[serde(default)]
50    #[schemars(skip_serializing_if = "omit_schema_default")]
51    pub automation: AutomationSettingsSchema,
52    #[serde(default)]
53    #[schemars(skip_serializing_if = "omit_schema_default")]
54    pub internal_tooling: InternalToolingSettingsSchema,
55    #[serde(default)]
56    pub sessions: SessionsSettingsSchema,
57    #[serde(default)]
58    #[schemars(skip_serializing_if = "omit_schema_default")]
59    pub interface: InterfaceSettingsSchema,
60}
61
62// These private wire groups are shared by reading, writing, and schema generation.
63#[derive(Default, Serialize, Deserialize, JsonSchema)]
64struct AgentSettingsSchema {
65    #[serde(default, skip_serializing_if = "Option::is_none")]
66    #[schemars(!skip_serializing_if)]
67    primary_agent: Option<String>,
68    #[serde(default, skip_serializing_if = "SelectedModelSettings::is_default")]
69    #[schemars(!skip_serializing_if)]
70    model: SelectedModelSettings,
71    #[serde(default, skip_serializing_if = "FastSettings::is_default")]
72    #[schemars(!skip_serializing_if)]
73    fast: FastSettings,
74    #[serde(default, skip_serializing_if = "Option::is_none")]
75    #[schemars(!skip_serializing_if)]
76    context: Option<crate::context::ContextBudget>,
77    #[serde(default, skip_serializing_if = "CompactionSettings::is_default")]
78    #[schemars(!skip_serializing_if)]
79    compaction: CompactionSettings,
80    #[serde(
81        default,
82        skip_serializing_if = "super::agent::SummarizerSettings::is_default"
83    )]
84    #[schemars(!skip_serializing_if)]
85    summarizer: super::agent::SummarizerSettings,
86    #[serde(
87        default,
88        skip_serializing_if = "super::agent::SideAgentSettings::is_default"
89    )]
90    #[schemars(!skip_serializing_if)]
91    side: super::agent::SideAgentSettings,
92    #[serde(
93        default,
94        skip_serializing_if = "AgentSubagentsSettingsSchema::is_default"
95    )]
96    subagents: AgentSubagentsSettingsSchema,
97}
98
99#[derive(Default, Serialize, Deserialize, JsonSchema)]
100#[schemars(transform = subagents_schema_defaults)]
101struct AgentSubagentsSettingsSchema {
102    #[serde(flatten)]
103    settings: super::agent::SubagentsSettings,
104    #[serde(default, skip_serializing_if = "SubagentsToolSettings::is_default")]
105    #[schemars(!skip_serializing_if)]
106    execution: SubagentsToolSettings,
107}
108
109impl AgentSubagentsSettingsSchema {
110    fn is_default(&self) -> bool {
111        self.settings == super::agent::SubagentsSettings::default() && self.execution.is_default()
112    }
113}
114
115#[derive(Default, Serialize, Deserialize, JsonSchema)]
116struct ProvidersSettingsSchema {
117    #[serde(default, skip_serializing_if = "OpenAiCodexSettings::is_default")]
118    #[schemars(!skip_serializing_if)]
119    openai_codex: OpenAiCodexSettings,
120    #[serde(default, skip_serializing_if = "OpenAiResponsesSettings::is_default")]
121    #[schemars(!skip_serializing_if)]
122    openai_responses: OpenAiResponsesSettings,
123    #[serde(
124        default,
125        skip_serializing_if = "AnthropicProviderSettingsSchema::is_default"
126    )]
127    anthropic: AnthropicProviderSettingsSchema,
128    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
129    #[schemars(!skip_serializing_if)]
130    custom: BTreeMap<String, CustomProviderConfig>,
131    #[serde(default, skip_serializing_if = "ModelsSettings::is_default")]
132    #[schemars(!skip_serializing_if)]
133    catalog: ModelsSettings,
134    #[serde(default, skip_serializing_if = "ProviderStreamSettings::is_default")]
135    #[schemars(!skip_serializing_if)]
136    streaming: ProviderStreamSettings,
137}
138
139#[derive(Serialize, Deserialize, JsonSchema)]
140struct AnthropicProviderSettingsSchema {
141    #[serde(default = "default_anthropic_cache_ttl")]
142    cache_ttl: Option<AnthropicCacheTtl>,
143}
144
145impl Default for AnthropicProviderSettingsSchema {
146    fn default() -> Self {
147        Self {
148            cache_ttl: default_anthropic_cache_ttl(),
149        }
150    }
151}
152
153impl AnthropicProviderSettingsSchema {
154    fn is_default(&self) -> bool {
155        self.cache_ttl == default_anthropic_cache_ttl()
156    }
157}
158
159#[derive(Default, Serialize, Deserialize, JsonSchema)]
160struct InternalToolingSettingsSchema {
161    #[serde(default, skip_serializing_if = "JevSettings::is_default")]
162    #[schemars(!skip_serializing_if)]
163    jev: JevSettings,
164}
165
166#[derive(Default, Serialize, Deserialize, JsonSchema)]
167struct CapabilitiesSettingsSchema {
168    #[serde(default, skip_serializing_if = "is_default")]
169    #[schemars(with = "ToolCapabilitiesSettingsSchema")]
170    tools: ToolSettings,
171    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
172    #[schemars(!skip_serializing_if)]
173    mcp_approvals: BTreeMap<String, BTreeMap<String, bool>>,
174    #[serde(default, skip_serializing_if = "LspSettings::is_default")]
175    #[schemars(!skip_serializing_if)]
176    lsp: LspSettings,
177}
178
179// Reuse the tool wire schema, excluding execution (owned by agent.subagents).
180struct ToolCapabilitiesSettingsSchema;
181
182impl JsonSchema for ToolCapabilitiesSettingsSchema {
183    fn schema_name() -> std::borrow::Cow<'static, str> {
184        "ToolCapabilitiesSettingsSchema".into()
185    }
186
187    fn json_schema(generator: &mut schemars::SchemaGenerator) -> schemars::Schema {
188        let mut schema = ToolSettings::json_schema(generator);
189        if let Some(Value::Object(properties)) = schema.get_mut("properties") {
190            properties.remove("subagents");
191        }
192        schema
193    }
194}
195
196#[derive(Default, Serialize, Deserialize, JsonSchema)]
197struct KnowledgeSettingsSchema {
198    #[serde(default, skip_serializing_if = "InstructionsSettings::is_default")]
199    #[schemars(!skip_serializing_if)]
200    instructions: InstructionsSettings,
201    #[serde(default, skip_serializing_if = "SkillsSettings::is_default")]
202    #[schemars(!skip_serializing_if)]
203    skills: SkillsSettings,
204}
205
206#[derive(Default, Serialize, Deserialize, JsonSchema)]
207struct AutomationSettingsSchema {
208    #[serde(default, skip_serializing_if = "HookSettings::is_default")]
209    #[schemars(!skip_serializing_if)]
210    hooks: HookSettings,
211    #[serde(default, skip_serializing_if = "IntegrationsSettings::is_default")]
212    #[schemars(!skip_serializing_if)]
213    integrations: IntegrationsSettings,
214}
215
216#[expect(dead_code)]
217#[derive(Default, JsonSchema)]
218struct SessionsSettingsSchema {
219    #[serde(default = "default_session_retention_days")]
220    pub retention_days: u64,
221    #[serde(flatten)]
222    titles: SessionTitlesWire,
223}
224
225// Retention belongs to SessionSettings, not the mutable Settings projection.
226#[derive(Default, Serialize, Deserialize, JsonSchema)]
227struct SessionTitlesWire {
228    #[serde(default, skip_serializing_if = "SessionTitleSettings::is_default")]
229    #[schemars(!skip_serializing_if)]
230    titles: SessionTitleSettings,
231    #[serde(
232        default,
233        skip_serializing_if = "super::psm::ProgressiveSessionMaintenanceSettings::is_default"
234    )]
235    #[schemars(!skip_serializing_if)]
236    psm: super::psm::ProgressiveSessionMaintenanceSettings,
237}
238
239#[derive(Default, Serialize, Deserialize, JsonSchema)]
240struct InterfaceSettingsSchema {
241    #[serde(default, skip_serializing_if = "is_default")]
242    #[schemars(!skip_serializing_if)]
243    appearance: AppearanceSettings,
244    #[serde(default, skip_serializing_if = "Option::is_none")]
245    #[schemars(!skip_serializing_if)]
246    no_color: Option<bool>,
247    #[serde(default, skip_serializing_if = "is_default")]
248    tui: InterfaceTuiSettingsSchema,
249}
250
251#[derive(Default, PartialEq, Serialize, Deserialize, JsonSchema)]
252#[schemars(transform = tui_schema_defaults)]
253struct InterfaceTuiSettingsSchema {
254    #[serde(flatten)]
255    settings: super::agent::TuiSettings,
256    #[serde(default, skip_serializing_if = "is_default")]
257    autocomplete: AutocompleteSettingsSchema,
258}
259
260#[derive(PartialEq, Serialize, Deserialize, JsonSchema)]
261#[schemars(transform = implicit_additional_properties)]
262struct AutocompleteSettingsSchema {
263    #[serde(
264        default = "default_file_autocomplete_respects_gitignore",
265        skip_serializing_if = "is_true"
266    )]
267    #[schemars(!skip_serializing_if)]
268    respects_gitignore: bool,
269    #[serde(default, flatten)]
270    extra: BTreeMap<String, Value>,
271}
272
273impl Default for AutocompleteSettingsSchema {
274    fn default() -> Self {
275        Self {
276            respects_gitignore: default_file_autocomplete_respects_gitignore(),
277            extra: BTreeMap::new(),
278        }
279    }
280}
281
282// Keep the published schema's default annotations independent of sparse writes.
283fn omit_schema_default<T>(_: &T) -> bool {
284    true
285}
286
287fn implicit_additional_properties(schema: &mut schemars::Schema) {
288    schema.remove("additionalProperties");
289}
290
291fn subagents_schema_defaults(schema: &mut schemars::Schema) {
292    implicit_additional_properties(schema);
293    if let Some(Value::Object(properties)) = schema.get_mut("properties") {
294        properties["disabled"]["default"] = json!([]);
295        properties["schema_validation_max_retries"]["default"] = Value::Null;
296    }
297}
298
299fn tui_schema_defaults(schema: &mut schemars::Schema) {
300    implicit_additional_properties(schema);
301    if let Some(Value::Object(properties)) = schema.get_mut("properties") {
302        properties["subagent_card_rows"]["default"] = Value::Null;
303    }
304}
305
306fn is_default<T: Default + PartialEq>(value: &T) -> bool {
307    *value == T::default()
308}
309
310fn is_true(value: &bool) -> bool {
311    *value
312}
313
314impl JsonSchema for Settings {
315    fn schema_name() -> std::borrow::Cow<'static, str> {
316        "Settings".into()
317    }
318
319    fn json_schema(generator: &mut schemars::SchemaGenerator) -> schemars::Schema {
320        SettingsSchema::json_schema(generator)
321    }
322}
323
324impl Serialize for Settings {
325    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
326    where
327        S: Serializer,
328    {
329        let value = canonical_settings_value(self).map_err(serde::ser::Error::custom)?;
330        value.serialize(serializer)
331    }
332}
333
334#[derive(Debug, Clone, PartialEq)]
335pub(crate) struct SettingsDocument {
336    pub(crate) settings: Settings,
337    pub(crate) appearance: AppearanceSettings,
338}
339
340impl SettingsDocument {
341    pub(super) fn from_value(value: &Value) -> anyhow::Result<Self> {
342        let settings = settings_from_value(value)?;
343        let root = value
344            .as_object()
345            .ok_or_else(|| anyhow::anyhow!("settings must be a JSON object"))?;
346        let interface = group_object(root, "interface", "interface")?;
347        let appearance = parse_group(interface, "interface.appearance")?;
348        Ok(Self {
349            settings,
350            appearance,
351        })
352    }
353}
354
355pub(super) fn session_settings_from_value(value: &Value) -> anyhow::Result<SessionSettings> {
356    let root = value
357        .as_object()
358        .ok_or_else(|| anyhow::anyhow!("settings must be a JSON object"))?;
359    let sessions = group_object(root, "sessions", "sessions")?;
360    if sessions.is_empty() {
361        Ok(SessionSettings::default())
362    } else {
363        serde_json::from_value(Value::Object(sessions.clone())).map_err(Into::into)
364    }
365}
366
367pub(super) fn canonical_settings_value(settings: &Settings) -> anyhow::Result<Value> {
368    let mut root = Map::new();
369    root.insert(
370        "schema_version".to_string(),
371        Value::Number(SETTINGS_SCHEMA_VERSION.into()),
372    );
373
374    insert_wire_group(
375        &mut root,
376        "agent",
377        &AgentSettingsSchema {
378            primary_agent: settings.selected_primary_agent.clone(),
379            model: settings.selected_model.clone(),
380            fast: settings.fast.clone(),
381            context: settings.context.clone(),
382            compaction: settings.compaction.clone(),
383            summarizer: settings.summarizer.clone(),
384            side: settings.side.clone(),
385            subagents: AgentSubagentsSettingsSchema {
386                settings: settings.subagents.clone(),
387                execution: settings.tools.subagents.clone(),
388            },
389        },
390    )?;
391    insert_wire_group(
392        &mut root,
393        "providers",
394        &ProvidersSettingsSchema {
395            openai_codex: settings.openai_codex.clone(),
396            openai_responses: settings.openai_responses.clone(),
397            anthropic: AnthropicProviderSettingsSchema {
398                cache_ttl: settings.anthropic_cache_ttl,
399            },
400            custom: settings.custom_providers.clone(),
401            catalog: settings.models.clone(),
402            streaming: settings.provider_stream.clone(),
403        },
404    )?;
405
406    let mut tools = settings.tools.clone();
407    tools.subagents = SubagentsToolSettings::default();
408    insert_wire_group(
409        &mut root,
410        "capabilities",
411        &CapabilitiesSettingsSchema {
412            tools,
413            mcp_approvals: settings.mcp_approvals.clone(),
414            lsp: settings.lsp.clone(),
415        },
416    )?;
417
418    insert_wire_group(
419        &mut root,
420        "internal_tooling",
421        &InternalToolingSettingsSchema {
422            jev: settings.jev.clone(),
423        },
424    )?;
425
426    insert_wire_group(
427        &mut root,
428        "knowledge",
429        &KnowledgeSettingsSchema {
430            instructions: settings.instructions.clone(),
431            skills: settings.skills.clone(),
432        },
433    )?;
434    insert_wire_group(
435        &mut root,
436        "automation",
437        &AutomationSettingsSchema {
438            hooks: settings.hooks.clone(),
439            integrations: settings.integrations.clone(),
440        },
441    )?;
442
443    insert_wire_group(
444        &mut root,
445        "sessions",
446        &SessionTitlesWire {
447            titles: settings.session_titles.clone(),
448            psm: settings.psm.clone(),
449        },
450    )?;
451
452    insert_wire_group(
453        &mut root,
454        "interface",
455        &InterfaceSettingsSchema {
456            appearance: AppearanceSettings::default(),
457            no_color: settings.no_color,
458            tui: {
459                let mut tui: InterfaceTuiSettingsSchema =
460                    serde_json::from_value(serde_json::to_value(&settings.tui)?)?;
461                tui.autocomplete.respects_gitignore = settings.file_autocomplete_respects_gitignore;
462                tui
463            },
464        },
465    )?;
466    // The domain TUI retains raw autocomplete keys, including explicit defaults.
467    // Preserve its historical explicit empty projection rather than dropping the group.
468    if !settings.tui.is_default() {
469        let interface = root.entry("interface").or_insert_with(|| json!({}));
470        interface
471            .as_object_mut()
472            .expect("serialized interface is an object")
473            .entry("tui")
474            .or_insert_with(|| json!({}));
475    }
476
477    Ok(Value::Object(root))
478}
479
480fn insert_wire_group(
481    root: &mut Map<String, Value>,
482    key: &str,
483    group: &impl Serialize,
484) -> anyhow::Result<()> {
485    let value = serde_json::to_value(group)?;
486    if !value_is_empty_object(&value) {
487        root.insert(key.to_string(), value);
488    }
489    Ok(())
490}
491
492pub(crate) fn validate_settings_schema_version(value: &Value) -> anyhow::Result<()> {
493    let object = value
494        .as_object()
495        .ok_or_else(|| anyhow::anyhow!("settings must be a JSON object"))?;
496    match settings_schema_version(object)? {
497        None | Some(SETTINGS_SCHEMA_VERSION) => Ok(()),
498        Some(version) => anyhow::bail!(
499            "unsupported settings schema_version {version}; expected {SETTINGS_SCHEMA_VERSION}"
500        ),
501    }
502}
503
504fn settings_schema_version(object: &Map<String, Value>) -> anyhow::Result<Option<u64>> {
505    let Some(value) = object.get("schema_version") else {
506        return Ok(None);
507    };
508    let Some(number) = value.as_u64() else {
509        anyhow::bail!("settings schema_version must be an integer")
510    };
511    Ok(Some(number))
512}
513
514fn settings_from_value(value: &Value) -> anyhow::Result<Settings> {
515    validate_settings_schema_version(value)?;
516    let root = value
517        .as_object()
518        .ok_or_else(|| anyhow::anyhow!("settings must be a JSON object"))?;
519    // Serde structs also accept positional arrays; settings groups must remain objects.
520    for key in [
521        "agent",
522        "providers",
523        "capabilities",
524        "knowledge",
525        "automation",
526        "sessions",
527        "interface",
528    ] {
529        group_object(root, key, key)?;
530    }
531    let providers_group = group_object(root, "providers", "providers")?;
532    group_object(providers_group, "anthropic", "providers.anthropic")?;
533    let interface_group = group_object(root, "interface", "interface")?;
534    let tui_group = group_object(interface_group, "tui", "interface.tui")?;
535    group_object(tui_group, "autocomplete", "interface.tui.autocomplete")?;
536    group_object(root, "internal_tooling", "internal_tooling")?;
537    let agent: AgentSettingsSchema = parse_group(root, "agent")?;
538    let providers: ProvidersSettingsSchema = parse_group(root, "providers")?;
539    let capabilities: CapabilitiesSettingsSchema = parse_group(root, "capabilities")?;
540    let knowledge: KnowledgeSettingsSchema = parse_group(root, "knowledge")?;
541    let automation: AutomationSettingsSchema = parse_group(root, "automation")?;
542    let sessions: SessionTitlesWire = parse_group(root, "sessions")?;
543    let interface: InterfaceSettingsSchema = parse_group(root, "interface")?;
544
545    let internal_tooling: InternalToolingSettingsSchema = parse_group(root, "internal_tooling")?;
546    let mut tools = capabilities.tools;
547    tools.subagents = agent.subagents.execution;
548
549    let settings = Settings {
550        selected_model: agent.model,
551        fast: agent.fast,
552        openai_codex: providers.openai_codex,
553        openai_responses: providers.openai_responses,
554        no_color: interface.no_color,
555        anthropic_cache_ttl: providers.anthropic.cache_ttl,
556        file_autocomplete_respects_gitignore: interface.tui.autocomplete.respects_gitignore,
557        context: agent.context,
558        session_titles: sessions.titles,
559        psm: sessions.psm,
560        compaction: agent.compaction,
561        summarizer: agent.summarizer,
562        side: agent.side,
563        tools,
564        subagents: agent.subagents.settings,
565        models: providers.catalog,
566        hooks: automation.hooks,
567        instructions: knowledge.instructions,
568        skills: knowledge.skills,
569        custom_providers: providers.custom,
570        mcp_servers: BTreeMap::new(),
571        mcp_approvals: capabilities.mcp_approvals,
572        lsp: capabilities.lsp,
573        integrations: automation.integrations,
574        tui: parse_group(
575            group_object(root, "interface", "interface")?,
576            "interface.tui",
577        )?,
578        provider_stream: providers.streaming,
579        selected_primary_agent: agent.primary_agent,
580        jev: internal_tooling.jev,
581    };
582    Ok(settings)
583}
584
585fn group_object<'a>(
586    parent: &'a Map<String, Value>,
587    key: &str,
588    path: &str,
589) -> anyhow::Result<&'a Map<String, Value>> {
590    match parent.get(key) {
591        None => Ok(empty_object()),
592        Some(value) => value
593            .as_object()
594            .ok_or_else(|| anyhow::anyhow!("{path} must be a JSON object")),
595    }
596}
597
598fn empty_object() -> &'static Map<String, Value> {
599    static EMPTY: std::sync::OnceLock<Map<String, Value>> = std::sync::OnceLock::new();
600    EMPTY.get_or_init(Map::new)
601}
602
603/// `path` is the dotted settings location; its last segment is the key inside `parent`.
604fn parse_group<T>(parent: &Map<String, Value>, path: &str) -> anyhow::Result<T>
605where
606    T: DeserializeOwned + Default,
607{
608    let key = path.rsplit('.').next().unwrap_or(path);
609    let Some(value) = parent.get(key) else {
610        return Ok(T::default());
611    };
612    serde_path_to_error::deserialize(value.clone()).map_err(|error| {
613        let inner_path = error.path().to_string();
614        let location = if inner_path == "." {
615            path.to_string()
616        } else {
617            format!("{path}.{inner_path}")
618        };
619        anyhow::anyhow!("settings {location}: {}", error.into_inner())
620    })
621}
622
623fn value_is_empty_object(value: &Value) -> bool {
624    value.as_object().is_some_and(Map::is_empty)
625}
626
627impl<'de> Deserialize<'de> for Settings {
628    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
629    where
630        D: serde::Deserializer<'de>,
631    {
632        let value = Value::deserialize(deserializer)?;
633        SettingsDocument::from_value(&value)
634            .map(|document| document.settings)
635            .map_err(de::Error::custom)
636    }
637}