Skip to main content

mur_common/agent/
companion.rs

1use super::*;
2
3// ──────────────────────────────────────────────────────────────────────────
4// Voice I/O configuration (D1 — Kokoro 82M TTS + whisper.cpp STT)
5// ──────────────────────────────────────────────────────────────────────────
6
7/// Kokoro 82M voice identity. Maps to the per-voice style vector
8/// embedded in the Kokoro ONNX model.
9#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
10#[serde(rename_all = "snake_case")]
11pub enum VoiceId {
12    /// Default: Kokoro af_heart voice.
13    #[default]
14    AfHeart,
15    AfBella,
16    AfNicole,
17    AmAdam,
18    AmMichael,
19}
20
21impl VoiceId {
22    /// Index into the Kokoro voices.bin style matrix (row index).
23    pub fn style_index(&self) -> usize {
24        match self {
25            VoiceId::AfHeart => 0,
26            VoiceId::AfBella => 1,
27            VoiceId::AfNicole => 2,
28            VoiceId::AmAdam => 3,
29            VoiceId::AmMichael => 4,
30        }
31    }
32
33    /// Canonical lowercase string representation (matches `FromStr` inputs).
34    pub fn as_str(&self) -> &'static str {
35        match self {
36            VoiceId::AfHeart => "af_heart",
37            VoiceId::AfBella => "af_bella",
38            VoiceId::AfNicole => "af_nicole",
39            VoiceId::AmAdam => "am_adam",
40            VoiceId::AmMichael => "am_michael",
41        }
42    }
43}
44
45impl std::str::FromStr for VoiceId {
46    type Err = anyhow::Error;
47
48    fn from_str(s: &str) -> anyhow::Result<Self> {
49        match s {
50            "af_heart" => Ok(VoiceId::AfHeart),
51            "af_bella" => Ok(VoiceId::AfBella),
52            "af_nicole" => Ok(VoiceId::AfNicole),
53            "am_adam" => Ok(VoiceId::AmAdam),
54            "am_michael" => Ok(VoiceId::AmMichael),
55            other => anyhow::bail!(
56                "unknown voice ID '{other}' \
57                 (valid: af_heart, af_bella, af_nicole, am_adam, am_michael)"
58            ),
59        }
60    }
61}
62
63/// Per-agent voice I/O configuration (D1).
64/// Default = disabled so existing profiles continue to load unchanged.
65#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
66pub struct VoiceConfig {
67    /// Whether TTS (Kokoro) + STT (whisper.cpp) are enabled.
68    #[serde(default)]
69    pub enabled: bool,
70    /// Kokoro voice identity for TTS output. Default: af_heart.
71    #[serde(default)]
72    pub voice_id: VoiceId,
73    /// Optional cpal input device name for mic capture.
74    /// None means the OS default input device.
75    #[serde(default, skip_serializing_if = "Option::is_none")]
76    pub input_device: Option<String>,
77}
78
79// ──────────────────────────────────────────────────────────────────────────
80// Human-in-the-loop configuration (Phase 2)
81// ──────────────────────────────────────────────────────────────────────────
82
83#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
84pub struct HitlConfig {
85    #[serde(default = "default_hitl_timeout_secs")]
86    pub timeout_secs: u32,
87    /// IGNORED since 2.79 (kept so old profiles load; warned at agent start).
88    /// Bounds live in `limits:` — see `mur limits <agent>`.
89    #[serde(default)]
90    pub max_iterations: Option<u32>,
91    /// IGNORED since 2.79 (kept so old profiles load; warned at agent start).
92    /// Bounds live in `limits:` — see `mur limits <agent>`.
93    #[serde(default)]
94    pub max_tokens: Option<u64>,
95    /// How far this agent carries a turn before handing back (issue #001):
96    /// `continue` / `review` / `ask`. `None` = inherit the built-in default,
97    /// which is the strictest (`ask`) — turning an agent loose is a thing you
98    /// write down, never a thing you get by leaving a key out.
99    ///
100    /// Lives here, beside `timeout_secs`, because it is human-in-the-loop
101    /// vocabulary; it does NOT live in `limits:`, which is budgets. The two
102    /// are enforced at different seams and must not be confusable.
103    #[serde(default, skip_serializing_if = "Option::is_none")]
104    pub autonomy: Option<crate::hitl::Autonomy>,
105}
106
107fn default_hitl_timeout_secs() -> u32 {
108    300
109}
110
111impl Default for HitlConfig {
112    fn default() -> Self {
113        Self {
114            timeout_secs: default_hitl_timeout_secs(),
115            max_iterations: None,
116            max_tokens: None,
117            autonomy: None,
118        }
119    }
120}
121
122#[cfg(test)]
123mod hitl_tests {
124    use super::*;
125
126    /// #001: an agent profile that says nothing about autonomy inherits the
127    /// strict default. Absent must never read as "turn it loose".
128    #[test]
129    fn hitl_config_autonomy_absent_means_inherit_not_continue() {
130        let cfg: HitlConfig = serde_yaml::from_str("timeout_secs: 60").unwrap();
131        assert_eq!(cfg.autonomy, None);
132        assert_eq!(cfg.autonomy.unwrap_or_default(), crate::hitl::Autonomy::Ask);
133    }
134
135    #[test]
136    fn hitl_config_autonomy_parses_all_three_modes() {
137        for (yaml, want) in [
138            ("continue", crate::hitl::Autonomy::Continue),
139            ("review", crate::hitl::Autonomy::Review),
140            ("ask", crate::hitl::Autonomy::Ask),
141        ] {
142            let cfg: HitlConfig =
143                serde_yaml::from_str(&format!("timeout_secs: 60\nautonomy: {yaml}")).unwrap();
144            assert_eq!(cfg.autonomy, Some(want), "yaml={yaml}");
145        }
146    }
147
148    #[test]
149    fn hitl_config_default_max_iterations_is_none() {
150        let cfg = HitlConfig::default();
151        assert!(cfg.max_iterations.is_none());
152    }
153
154    #[test]
155    fn hitl_config_max_iterations_explicit() {
156        let cfg: HitlConfig = serde_yaml::from_str("timeout_secs: 60\nmax_iterations: 5").unwrap();
157        assert_eq!(cfg.max_iterations, Some(5));
158    }
159
160    #[test]
161    fn hitl_config_default_max_tokens_is_none() {
162        let cfg = HitlConfig::default();
163        assert!(cfg.max_tokens.is_none());
164    }
165
166    #[test]
167    fn hitl_config_max_tokens_explicit() {
168        let cfg: HitlConfig = serde_yaml::from_str("timeout_secs: 60\nmax_tokens: 250000").unwrap();
169        assert_eq!(cfg.max_tokens, Some(250_000));
170    }
171}
172
173// ──────────────────────────────────────────────────────────────────────────
174// Companion subsystem (Phase 1.1+) — see
175// docs/superpowers/specs/2026-04-29-mur-companion-phase-1-1-design.md §3.1
176// ──────────────────────────────────────────────────────────────────────────
177
178#[derive(Debug, Default, Clone, PartialEq, Serialize, Deserialize)]
179pub struct CompanionConfig {
180    #[serde(default)]
181    pub enabled: bool,
182    #[serde(default = "default_locale")]
183    pub locale: String,
184    #[serde(default)]
185    pub relationship: Relationship,
186    #[serde(default)]
187    pub voice_overrides: VoiceOverrides,
188    #[serde(default)]
189    pub onboarding: OnboardingState,
190    #[serde(default)]
191    pub rhythm: RhythmConfig,
192    #[serde(default)]
193    pub proactive: ProactiveConfig,
194}
195
196/// Resolve a default BCP-47 locale: the OS locale first (sys-locale already
197/// returns BCP-47, e.g. `zh-Hant-TW`), then the `LANG` environment variable
198/// (POSIX form `zh_TW.UTF-8` → `zh-TW`), then `en-US`.
199///
200/// OS-first matters because this is also the serde default for
201/// `AgentProfile.locale`: under launchd there is no `LANG`, so the old
202/// LANG-only resolution silently defaulted every headless agent to `en-US`
203/// even on a non-English system.
204pub fn default_locale() -> String {
205    sys_locale::get_locale()
206        .filter(|l| !l.is_empty())
207        .or_else(|| std::env::var("LANG").ok().and_then(|v| normalize_lang(&v)))
208        .unwrap_or_else(|| "en-US".into())
209}
210
211/// Parse a POSIX-style `LANG` value into BCP-47 (`zh_TW.UTF-8` → `zh-TW`).
212fn normalize_lang(v: &str) -> Option<String> {
213    v.split('.')
214        .next()
215        .map(|s| s.replace('_', "-"))
216        .filter(|s| !s.is_empty())
217}
218
219#[cfg(test)]
220mod locale_tests {
221    use super::normalize_lang;
222
223    #[test]
224    fn lang_with_encoding_and_region_normalizes() {
225        assert_eq!(normalize_lang("zh_TW.UTF-8").as_deref(), Some("zh-TW"));
226    }
227
228    #[test]
229    fn lang_without_encoding_normalizes() {
230        assert_eq!(normalize_lang("en_US").as_deref(), Some("en-US"));
231    }
232
233    #[test]
234    fn lang_with_script_keeps_script() {
235        assert_eq!(
236            normalize_lang("zh_Hant_TW.UTF-8").as_deref(),
237            Some("zh-Hant-TW")
238        );
239    }
240
241    #[test]
242    fn empty_lang_yields_none() {
243        assert_eq!(normalize_lang(""), None);
244    }
245}
246
247#[derive(Debug, Default, Clone, PartialEq, Serialize, Deserialize)]
248pub struct VoiceOverrides {
249    #[serde(default, skip_serializing_if = "Option::is_none")]
250    pub name_for_user: Option<String>,
251    #[serde(default, skip_serializing_if = "Option::is_none")]
252    pub formality: Option<Formality>,
253    #[serde(default, skip_serializing_if = "Option::is_none")]
254    pub extra_instructions: Option<String>,
255}
256
257#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
258pub struct FirstMemory {
259    pub text: String,
260    pub established_at: chrono::DateTime<chrono::Utc>,
261}
262
263#[derive(Debug, Default, Clone, PartialEq, Serialize, Deserialize)]
264pub struct OnboardingState {
265    #[serde(default, skip_serializing_if = "Option::is_none")]
266    pub completed_at: Option<chrono::DateTime<chrono::Utc>>,
267    #[serde(default)]
268    pub version: u32,
269    #[serde(default, skip_serializing_if = "Option::is_none")]
270    pub agent_display_name: Option<String>,
271    #[serde(default, skip_serializing_if = "Option::is_none")]
272    pub first_memory: Option<FirstMemory>,
273}
274
275/// Phase 1.2 reservation. 1.1 keeps `enabled = false` (rhythm collection is
276/// out of 1.1 scope).
277#[derive(Debug, Default, Clone, PartialEq, Serialize, Deserialize)]
278pub struct RhythmConfig {
279    #[serde(default)]
280    pub enabled: bool,
281}
282
283#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
284pub struct ProactiveConfig {
285    #[serde(default)]
286    pub enabled: bool,
287    /// 1.1 reserves the field; 1.2 will write `now + 7d` at rhythm-enable.
288    #[serde(default, skip_serializing_if = "Option::is_none")]
289    pub learning_until: Option<chrono::DateTime<chrono::Utc>>,
290    #[serde(default, skip_serializing_if = "Option::is_none")]
291    pub quiet_hours: Option<QuietHours>,
292    #[serde(default, skip_serializing_if = "Option::is_none")]
293    pub active_hours: Option<ActiveHours>,
294    #[serde(default = "default_daily_cap")]
295    pub daily_cap: u8,
296    #[serde(default = "default_channels")]
297    pub channels: Vec<String>,
298    #[serde(default, skip_serializing_if = "Option::is_none")]
299    pub paused_until: Option<chrono::DateTime<chrono::Utc>>,
300}
301
302impl Default for ProactiveConfig {
303    fn default() -> Self {
304        Self {
305            enabled: false,
306            learning_until: None,
307            quiet_hours: None,
308            active_hours: None,
309            daily_cap: default_daily_cap(),
310            channels: default_channels(),
311            paused_until: None,
312        }
313    }
314}
315
316fn default_daily_cap() -> u8 {
317    3
318}
319fn default_channels() -> Vec<String> {
320    vec!["stdout".into()]
321}
322
323#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
324pub struct QuietHours {
325    pub start: String,
326    pub end: String,
327}
328
329#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
330pub struct ActiveHours {
331    pub start: String,
332    pub end: String,
333}
334
335// ──────────────────────────────────────────────────────────────────────────
336// Hub companion appearance (M-h3)
337// ──────────────────────────────────────────────────────────────────────────
338
339#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
340pub struct AgentAppearance {
341    /// ID of the active style preset (e.g. "chiikawa", "default-blob").
342    #[serde(default = "default_style_preset")]
343    pub style_preset: String,
344    #[serde(default)]
345    pub behavior_preset: BehaviorPreset,
346    /// Required for the polaroid family; none for all others.
347    #[serde(default, skip_serializing_if = "Option::is_none")]
348    pub source_image_path: Option<std::path::PathBuf>,
349    /// Local dir where rendered .webp expression frames are stored.
350    #[serde(default = "default_expressions_dir")]
351    pub expressions_dir: std::path::PathBuf,
352    #[serde(default, skip_serializing_if = "Option::is_none")]
353    pub last_rendered_at: Option<chrono::DateTime<chrono::Utc>>,
354    #[serde(default)]
355    pub render_status: RenderStatus,
356}
357
358fn default_style_preset() -> String {
359    "default-blob".into()
360}
361
362fn default_expressions_dir() -> std::path::PathBuf {
363    std::path::PathBuf::from("expressions")
364}
365
366impl Default for AgentAppearance {
367    fn default() -> Self {
368        Self {
369            style_preset: default_style_preset(),
370            behavior_preset: BehaviorPreset::Normal,
371            source_image_path: None,
372            expressions_dir: default_expressions_dir(),
373            last_rendered_at: None,
374            render_status: RenderStatus::Pending,
375        }
376    }
377}
378
379#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
380#[serde(rename_all = "snake_case")]
381pub enum BehaviorPreset {
382    Quiet,
383    #[default]
384    Normal,
385    Lively,
386}
387
388#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
389#[serde(tag = "status", rename_all = "snake_case")]
390pub enum RenderStatus {
391    #[default]
392    Pending,
393    Rendering {
394        done: u8,
395        total: u8,
396    },
397    Ready,
398    Failed {
399        reason: String,
400    },
401}
402
403// ──────────────────────────────────────────────────────────────────────────
404// E6 — Agent Pattern Federation types
405// ──────────────────────────────────────────────────────────────────────────
406
407/// When the agent pulls an updated pattern snapshot from the daemon.
408#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
409#[serde(rename_all = "kebab-case")]
410pub enum SnapshotPolicy {
411    #[default]
412    PullOnStart,
413    PullPeriodic,
414    Manual,
415}
416
417/// Filter criteria for the pattern snapshot written to the agent's patterns_cache.
418#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
419pub struct PatternFilter {
420    #[serde(default, skip_serializing_if = "Vec::is_empty")]
421    pub applies_in: Vec<String>,
422    #[serde(default, skip_serializing_if = "Vec::is_empty")]
423    pub tier: Vec<String>,
424    #[serde(default, skip_serializing_if = "Vec::is_empty")]
425    pub maturity: Vec<String>,
426    #[serde(default)]
427    pub importance_min: f64,
428    #[serde(default = "default_max_snapshot_count")]
429    pub max_count: usize,
430    #[serde(default)]
431    pub snapshot_policy: SnapshotPolicy,
432}
433
434fn default_max_snapshot_count() -> usize {
435    200
436}
437
438impl Default for PatternFilter {
439    fn default() -> Self {
440        Self {
441            applies_in: vec![],
442            tier: vec![],
443            maturity: vec![],
444            importance_min: 0.0,
445            max_count: 200,
446            snapshot_policy: SnapshotPolicy::default(),
447        }
448    }
449}
450
451/// Points to the knowledge-layer commit this agent's patterns_cache was built from.
452#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
453pub struct SnapshotRef {
454    pub knowledge_commit: String,
455    pub taken_at: String,
456    pub filter: PatternFilter,
457}
458
459/// Federation configuration embedded in AgentProfile (E6).
460#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
461pub struct FederationConfig {
462    #[serde(default)]
463    pub filter: PatternFilter,
464    #[serde(default, skip_serializing_if = "Option::is_none")]
465    pub snapshot_ref: Option<SnapshotRef>,
466    #[serde(default)]
467    pub evidence_flush_interval_minutes: u32,
468}
469
470/// GUI-facing reification of the companion's three-layer permission toggle.
471///
472/// On-disk schema doesn't change — this helper just maps between the
473/// three independent booleans (`enabled`, `rhythm.enabled`,
474/// `proactive.enabled`) and a single ordered tier. Use
475/// [`ProactiveTier::from_config`] to read and [`ProactiveTier::apply`]
476/// to write.
477#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
478#[serde(rename_all = "snake_case")]
479pub enum ProactiveTier {
480    Off,
481    WarmOnly,
482    WarmAndBehavior,
483    All,
484}
485
486impl ProactiveTier {
487    pub fn from_config(c: &CompanionConfig) -> Self {
488        match (c.enabled, c.rhythm.enabled, c.proactive.enabled) {
489            (false, _, _) => Self::Off,
490            (true, false, false) => Self::WarmOnly,
491            (true, true, false) => Self::WarmAndBehavior,
492            (true, _, true) => Self::All,
493        }
494    }
495
496    pub fn apply(&self, c: &mut CompanionConfig) {
497        match self {
498            Self::Off => {
499                c.enabled = false;
500                c.rhythm.enabled = false;
501                c.proactive.enabled = false;
502            }
503            Self::WarmOnly => {
504                c.enabled = true;
505                c.rhythm.enabled = false;
506                c.proactive.enabled = false;
507            }
508            Self::WarmAndBehavior => {
509                c.enabled = true;
510                c.rhythm.enabled = true;
511                c.proactive.enabled = false;
512            }
513            Self::All => {
514                c.enabled = true;
515                c.rhythm.enabled = true;
516                c.proactive.enabled = true;
517            }
518        }
519    }
520}
521
522#[cfg(test)]
523mod voice_tests {
524    use super::*;
525    use std::str::FromStr;
526
527    #[test]
528    fn voice_config_round_trips() {
529        // Base: use the canonical minimal fixture and append a voice: block.
530        let base = include_str!("../../tests/fixtures/profile_p0a_minimal.yaml");
531        let yaml = format!("{base}voice:\n  enabled: true\n  voice_id: af_bella\n");
532
533        let profile: AgentProfile = serde_yaml_ng::from_str(&yaml).expect("parse with voice");
534        assert!(profile.voice.enabled);
535        assert_eq!(profile.voice.voice_id, VoiceId::AfBella);
536
537        // Legacy profiles (no voice: block) must still load.
538        let legacy: AgentProfile = serde_yaml_ng::from_str(base).expect("parse without voice");
539        assert!(!legacy.voice.enabled);
540        assert_eq!(legacy.voice.voice_id, VoiceId::AfHeart);
541    }
542
543    #[test]
544    fn voice_id_from_str_roundtrips() {
545        let cases = [
546            ("af_heart", VoiceId::AfHeart),
547            ("af_bella", VoiceId::AfBella),
548            ("af_nicole", VoiceId::AfNicole),
549            ("am_adam", VoiceId::AmAdam),
550            ("am_michael", VoiceId::AmMichael),
551        ];
552        for (s, expected) in cases {
553            assert_eq!(VoiceId::from_str(s).unwrap(), expected);
554            assert_eq!(expected.as_str(), s);
555        }
556    }
557
558    #[test]
559    fn voice_id_from_str_rejects_unknown() {
560        assert!(VoiceId::from_str("bogus").is_err());
561    }
562}
563
564#[cfg(test)]
565mod appearance_tests {
566    use super::*;
567
568    #[test]
569    fn appearance_default_style_preset_is_default_blob() {
570        assert_eq!(AgentAppearance::default().style_preset, "default-blob");
571    }
572
573    #[test]
574    fn appearance_default_behavior_is_normal() {
575        assert_eq!(
576            AgentAppearance::default().behavior_preset,
577            BehaviorPreset::Normal
578        );
579    }
580
581    #[test]
582    fn appearance_default_render_status_is_pending() {
583        assert_eq!(
584            AgentAppearance::default().render_status,
585            RenderStatus::Pending
586        );
587    }
588
589    #[test]
590    fn render_status_serde_round_trip() {
591        let cases = [
592            RenderStatus::Pending,
593            RenderStatus::Rendering { done: 3, total: 12 },
594            RenderStatus::Ready,
595            RenderStatus::Failed {
596                reason: "out of quota".into(),
597            },
598        ];
599        for status in cases {
600            let yaml = serde_yaml_ng::to_string(&status).expect("serialize");
601            let back: RenderStatus = serde_yaml_ng::from_str(&yaml).expect("deserialize");
602            assert_eq!(status, back);
603        }
604    }
605
606    #[test]
607    fn agent_profile_with_appearance_round_trips() {
608        let base = include_str!("../../tests/fixtures/profile_p0a_minimal.yaml");
609        let yaml = format!(
610            "{base}appearance:\n  style_preset: chiikawa\n  render_status:\n    status: ready\n"
611        );
612        let profile: AgentProfile = serde_yaml_ng::from_str(&yaml).expect("parse with appearance");
613        assert_eq!(profile.appearance.style_preset, "chiikawa");
614        assert_eq!(profile.appearance.render_status, RenderStatus::Ready);
615
616        let out = serde_yaml_ng::to_string(&profile).expect("serialize");
617        let back: AgentProfile = serde_yaml_ng::from_str(&out).expect("re-parse");
618        assert_eq!(profile.appearance, back.appearance);
619    }
620
621    #[test]
622    fn legacy_profile_without_appearance_uses_default() {
623        let yaml = include_str!("../../tests/fixtures/profile_p0a_minimal.yaml");
624        let profile: AgentProfile = serde_yaml_ng::from_str(yaml).expect("parse legacy");
625        assert_eq!(profile.appearance.style_preset, "default-blob");
626        assert_eq!(profile.appearance.behavior_preset, BehaviorPreset::Normal);
627        assert_eq!(profile.appearance.render_status, RenderStatus::Pending);
628    }
629
630    #[test]
631    fn legacy_profile_without_file_actions_or_action_pipeline_loads() {
632        let yaml = include_str!("../../tests/fixtures/profile_p0a_minimal.yaml");
633        let p: AgentProfile = serde_yaml_ng::from_str(yaml).unwrap();
634        assert!(p.file_actions.is_empty());
635        assert_eq!(p.action_pipeline.deletion.cancel_window_minutes, 10);
636        assert_eq!(p.action_pipeline.queue.max_concurrent, 3);
637    }
638}
639
640#[cfg(test)]
641mod federation_tests {
642    use super::*;
643
644    #[test]
645    fn test_pattern_filter_default() {
646        let f = PatternFilter::default();
647        assert_eq!(f.max_count, 200);
648        assert_eq!(f.importance_min, 0.0);
649        assert!(f.tier.is_empty());
650    }
651
652    #[test]
653    fn test_federation_config_roundtrip() {
654        let cfg = FederationConfig {
655            filter: PatternFilter {
656                tier: vec!["core".into()],
657                max_count: 50,
658                ..Default::default()
659            },
660            snapshot_ref: Some(SnapshotRef {
661                knowledge_commit: "abc123def456".into(),
662                taken_at: "2026-05-19T00:00:00Z".into(),
663                filter: PatternFilter::default(),
664            }),
665            evidence_flush_interval_minutes: 15,
666        };
667        let yaml = serde_yaml_ng::to_string(&cfg).unwrap();
668        let back: FederationConfig = serde_yaml_ng::from_str(&yaml).unwrap();
669        assert_eq!(cfg, back);
670    }
671
672    #[test]
673    fn test_agent_profile_federation_defaults() {
674        // AgentProfile without a federation block deserializes with FederationConfig::default().
675        // Use the minimal YAML that passes validation — just the required fields.
676        // (We check only that the field has its zero value, not full profile parse.)
677        let cfg = FederationConfig::default();
678        assert_eq!(cfg.evidence_flush_interval_minutes, 0);
679        assert!(cfg.snapshot_ref.is_none());
680    }
681}