Skip to main content

mur_common/agent/
mod.rs

1//! Agent profile, Agent Card, and LockFile types shared between
2//! mur-agent-runtime and mur-core.
3
4use crate::companion::{Formality, Relationship};
5use crate::deps::ProgramDep;
6use serde::{Deserialize, Serialize};
7use std::collections::BTreeMap;
8
9mod companion;
10mod entitlements;
11mod lifecycle;
12mod mcp;
13mod mcp_status;
14mod reload;
15mod transport;
16
17pub use companion::*;
18pub use entitlements::*;
19pub use lifecycle::*;
20pub use mcp::*;
21pub use mcp_status::{MCP_STATUS_FILE, McpServerStatus, McpStatus};
22pub use reload::{HOT_PATHS, RELOAD_STATE_FILE, ReloadDiff, ReloadState};
23pub use transport::*;
24
25/// Skill metadata broadcast in the Agent Card (Layer 1 + Layer 2).
26///
27/// Populated by `mur skill install` (registry or agent:// URL). Distinct from
28/// `AgentProfile.skills`, which is the legacy per-agent-path list managed by
29/// `mur agent skill add`.
30#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
31pub struct SkillCardEntry {
32    pub name: String,
33    #[serde(default, skip_serializing_if = "String::is_empty")]
34    pub version: String,
35    #[serde(default, skip_serializing_if = "String::is_empty")]
36    pub publisher: String,
37    #[serde(default, skip_serializing_if = "String::is_empty")]
38    pub description: String,
39    #[serde(default, skip_serializing_if = "String::is_empty")]
40    pub category: String,
41    #[serde(default, skip_serializing_if = "Vec::is_empty")]
42    pub tags: Vec<String>,
43    #[serde(default, skip_serializing_if = "Vec::is_empty")]
44    pub triggers: Vec<SkillCardTrigger>,
45    /// Layer 2 abstract — injected at session start (~200 tokens).
46    /// On-disk YAML key is `abstract` (a Rust reserved word).
47    #[serde(default, skip_serializing_if = "String::is_empty", rename = "abstract")]
48    pub abstract_text: String,
49    /// Provenance chain copied from the installed manifest. Empty for
50    /// registry-installed skills.
51    #[serde(default, skip_serializing_if = "Vec::is_empty")]
52    pub transfer_chain: Vec<String>,
53}
54
55#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
56pub struct SkillCardTrigger {
57    #[serde(rename = "type")]
58    pub kind: String,
59    #[serde(default, skip_serializing_if = "String::is_empty")]
60    pub pattern: String,
61}
62
63#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
64pub struct AgentProfile {
65    pub schema: u32,
66    pub id: String, // UUIDv7
67    pub name: String,
68    pub display_name: String,
69    /// Coarse human-facing role for grouping/filtering (e.g. "Engineer").
70    /// A free label, not a registry — bundled defaults are UI suggestions and
71    /// users can type their own. Also the SOFT signal in the dispatch index
72    /// (`agent_facts`), where it explains and ranks candidates but never
73    /// filters them: what an agent may actually do is decided by
74    /// `entitlements`, which the kernel enforces and a stale label cannot
75    /// overstate.
76    #[serde(default, skip_serializing_if = "Option::is_none")]
77    pub role: Option<String>,
78    /// How hard this agent's model should work per turn
79    /// (`low`/`medium`/`high`/`xhigh`/`max`). `None` leaves the field off,
80    /// which is the API default (`high`) — not "no effort".
81    ///
82    /// Set it where the agent's JOB is known: a single-purpose build
83    /// specialist earns `xhigh`, a fan-out research worker `medium`, a
84    /// classifier `low`. Narrowed to what the resolved model accepts at the
85    /// client boundary, so an agent pinned to an older model degrades rather
86    /// than 400s.
87    #[serde(default, skip_serializing_if = "Option::is_none")]
88    pub effort: Option<crate::llm::Effort>,
89    pub version: String,
90    pub persona: Persona,
91    pub sys_prompt_file: String,
92    pub model: ModelConfig,
93    /// Optional pointer into ~/.mur/models.yaml. When set, the runtime
94    /// prefers the registry entry over the inline `model:` block.
95    #[serde(default, skip_serializing_if = "Option::is_none")]
96    pub model_ref: Option<String>,
97    /// Per-agent fallback chain (ordered model_refs). Overrides the global
98    /// `models.fallback_chain` when non-empty. See the model-switch spec.
99    #[serde(default, skip_serializing_if = "Vec::is_empty")]
100    pub fallback_chain: Vec<String>,
101    /// Per-agent difficulty-routing override. Absent fields inherit the global
102    /// `models.routing`.
103    #[serde(default, skip_serializing_if = "Option::is_none")]
104    pub routing: Option<crate::config::RoutingOverride>,
105    /// Per-agent Smart background-routing override. Absent fields inherit the
106    /// global `models.smart`; `None` means "follow the global setting".
107    /// Promoted out of `routing` — nesting it there meant overriding Smart
108    /// silently rewrote this agent's difficulty routing as a side effect.
109    #[serde(default, skip_serializing_if = "Option::is_none")]
110    pub smart: Option<crate::config::SmartOverride>,
111    #[serde(default)]
112    pub mcp_servers: Vec<McpServerEntry>,
113    #[serde(default)]
114    pub skills: Vec<String>,
115    /// Skills installed via `mur skill install`. Distinct from `skills`
116    /// (which holds legacy per-agent paths from `mur agent skill add`).
117    /// Broadcast in the Agent Card alongside `skills`.
118    #[serde(default, skip_serializing_if = "Vec::is_empty")]
119    pub installed_skills: Vec<SkillCardEntry>,
120    /// Per-agent skill denylist (add-on Phase 1). Skill names that are
121    /// installed/visible to this agent but suppressed from injection.
122    /// Non-destructive: the skill's files/stats are untouched. Empty = all
123    /// visible skills enabled (back-compat: absent in old profiles).
124    #[serde(default, skip_serializing_if = "Vec::is_empty")]
125    pub disabled_skills: Vec<String>,
126
127    /// Per-agent MCP denylist (add-on Phase 1). `McpServerEntry` names not
128    /// spawned for this agent. Non-destructive: the entry + its pin stay in
129    /// the profile. Empty = all configured servers enabled.
130    #[serde(default, skip_serializing_if = "Vec::is_empty")]
131    pub disabled_mcp: Vec<String>,
132
133    /// Names of per-agent secrets the user handed this agent (murmur
134    /// `/secret`, `mur agent secret set`). NAMES ONLY — the values live in the
135    /// keychain under `mur-agent/<name>/<NAME>`. The list exists because the
136    /// keychain cannot be enumerated: the supervisor reads it pre-seal to know
137    /// which accounts to load. Empty = nothing to load (back-compat).
138    #[serde(default, skip_serializing_if = "Vec::is_empty")]
139    pub secrets: Vec<String>,
140    /// Plugin-groups imported by this agent (add-on Phase 2). Each is
141    /// self-contained (members installed per-agent). Absent/empty in
142    /// legacy profiles (back-compat).
143    #[serde(default, skip_serializing_if = "Vec::is_empty")]
144    pub addons: Vec<AddonRef>,
145    pub transport: TransportConfig,
146    pub communication: CommunicationConfig,
147    #[serde(default)]
148    pub capabilities: Vec<String>,
149    pub entitlements: Entitlements,
150    #[serde(default)]
151    pub notifications: NotificationsConfig,
152    pub retry: RetryConfig,
153    pub lifecycle: LifecycleConfig,
154    /// Cryptographic identity for cross-host A2A (P0a.5+). Default = empty
155    /// (legacy P0a profiles continue to load without this block).
156    #[serde(default)]
157    pub identity: IdentityConfig,
158    #[serde(default)]
159    pub file_transfer: FileTransferConfig,
160    #[serde(default)]
161    pub deployment: DeploymentConfig,
162    /// Companion subsystem (Phase 1.1+). Default = disabled (legacy profiles
163    /// continue to load without this block).
164    #[serde(default)]
165    pub companion: CompanionConfig,
166    /// Human-in-the-loop configuration (Phase 2). Default = disabled.
167    #[serde(default)]
168    pub hitl: HitlConfig,
169    /// Execution limits for this agent's own tasks (spec 2026-09-12 §3.1).
170    /// Absent → inherit. Replaces `hitl.max_iterations` / `hitl.max_tokens`,
171    /// which stay readable for the migration warning until the runtime
172    /// switch (step 4) stops applying them.
173    #[serde(default, skip_serializing_if = "Option::is_none")]
174    pub limits: Option<crate::limits::Limits>,
175    /// Voice I/O configuration (D1). Default = disabled.
176    #[serde(default)]
177    pub voice: VoiceConfig,
178    /// A1: config-driven handler picker. Absent block = all defaults.
179    #[serde(default)]
180    pub hooks: crate::HooksConfig,
181    /// Pubkeys of bridges (and other LLM-less peers) this agent will accept
182    /// signed envelopes from. Empty = accept no bridge traffic. Default = empty.
183    #[serde(default)]
184    pub trusted_peers: Vec<crate::bridge::peer::TrustedPeer>,
185    pub created_at: String,
186    pub updated_at: String,
187    /// Hub companion visual identity (M-h3). Default = default-blob / Normal / Pending.
188    #[serde(default)]
189    pub appearance: AgentAppearance,
190    /// E6: Pattern federation — snapshot filter + outbox config.
191    #[serde(default)]
192    pub federation: FederationConfig,
193
194    /// A1: declarative UI action list — file_actions rendered as action
195    /// buttons in the pending-item selection UI. New top-level key; NOT
196    /// nested under `capabilities:`.
197    #[serde(default)]
198    pub file_actions: Vec<crate::action::FileAction>,
199
200    /// A2 + A3: action pipeline configuration (deletion safety + queue limits).
201    #[serde(default)]
202    pub action_pipeline: crate::action::ActionPipelineConfig,
203
204    /// External programs this artifact needs at runtime (portable-deps spec).
205    /// Absent → empty; resolved by `mur agent/fleet doctor` + `install-deps`.
206    #[serde(default, skip_serializing_if = "Vec::is_empty")]
207    pub requires_programs: Vec<ProgramDep>,
208
209    /// Capability refs installed into this agent (Pack S3). Absent → empty;
210    /// resolved against the local capability registry / bundle store.
211    #[serde(default, skip_serializing_if = "Vec::is_empty")]
212    pub requires_capabilities: Vec<String>,
213
214    /// Keys this build does not know (written by a newer `mur`). Kept so a
215    /// rewrite by an older binary round-trips them instead of dropping them
216    /// (#1713). Never read by logic; empty for every profile this build wrote.
217    #[serde(flatten, default, skip_serializing_if = "BTreeMap::is_empty")]
218    pub extra: BTreeMap<String, serde_yaml_ng::Value>,
219}
220
221fn default_algorithm() -> String {
222    "ed25519".into()
223}
224
225/// Algorithms the runtime can generate + verify.
226pub const SUPPORTED_ALGORITHMS: &[&str] = &["ed25519"];
227
228#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
229pub struct IdentityConfig {
230    /// Multibase-encoded Ed25519 public key (base58btc, `z` prefix).
231    /// Empty string for legacy P0a profiles; filled on P0a.5 `mur agent create`.
232    #[serde(default)]
233    pub pubkey: String,
234    /// Free-form owner identity (email / SSO sub). None for legacy profiles.
235    #[serde(default, skip_serializing_if = "Option::is_none")]
236    pub owner: Option<String>,
237
238    // P0a.6 rekey extensions (all #[serde(default)] — back-compat)
239    /// Cryptographic algorithm for this key. Defaults to "ed25519".
240    #[serde(default = "default_algorithm")]
241    pub algorithm: String,
242    /// Monotonic version counter; 0 = initial create, increments on each rotation.
243    #[serde(default)]
244    pub key_version: u32,
245    /// RFC3339 timestamp of when this key was created.
246    #[serde(default, skip_serializing_if = "Option::is_none")]
247    pub created_at_key: Option<String>,
248    /// Previous public key (before most recent rotation). None if not rotated yet.
249    #[serde(default, skip_serializing_if = "Option::is_none")]
250    pub previous_pubkey: Option<String>,
251    /// Version of the previous key. None if not rotated yet.
252    #[serde(default, skip_serializing_if = "Option::is_none")]
253    pub previous_key_version: Option<u32>,
254    /// RFC3339 timestamp when grace period expires and old key is fully retired.
255    /// Only set during rotation; cleared once grace period ends.
256    #[serde(default, skip_serializing_if = "Option::is_none")]
257    pub grace_expires_at: Option<String>,
258    /// RFC3339 timestamp of the most recent key rotation (normal, not emergency).
259    #[serde(default, skip_serializing_if = "Option::is_none")]
260    pub rotated_at: Option<String>,
261    /// RFC3339 timestamp of emergency key rotation (set only if emergency rekey occurred).
262    #[serde(default, skip_serializing_if = "Option::is_none")]
263    pub emergency_rekey_at: Option<String>,
264}
265
266impl Default for IdentityConfig {
267    fn default() -> Self {
268        Self {
269            pubkey: String::new(),
270            owner: None,
271            algorithm: default_algorithm(),
272            key_version: 0,
273            created_at_key: None,
274            previous_pubkey: None,
275            previous_key_version: None,
276            grace_expires_at: None,
277            rotated_at: None,
278            emergency_rekey_at: None,
279        }
280    }
281}
282
283#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
284pub struct Persona {
285    pub category: PersonaCategory,
286    pub description: String,
287    pub traits: PersonaTraits,
288}
289
290#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
291#[serde(rename_all = "lowercase")]
292pub enum PersonaCategory {
293    Research,
294    Automation,
295    Monitor,
296    Notify,
297    Commerce,
298    Custom,
299}
300
301#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
302pub struct PersonaTraits {
303    pub tone: String,
304    pub risk: String,
305    pub verbosity: String,
306}
307
308#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
309pub struct ModelConfig {
310    pub provider: String,
311    pub name: String,
312    #[serde(default)]
313    pub params: BTreeMap<String, serde_yaml_ng::Value>,
314}
315
316fn default_true() -> bool {
317    true
318}
319
320impl AgentProfile {
321    /// Minimal valid profile for tests — no voice, no MCP, no skills.
322    ///
323    /// Available in all compilation modes so integration tests in
324    /// dependent crates can call it (unlike `#[cfg(test)]` items which
325    /// are invisible to downstream test binaries).
326    #[doc(hidden)]
327    pub fn default_for_tests() -> Self {
328        serde_yaml_ng::from_str(include_str!("../../tests/fixtures/minimal_profile.yaml"))
329            .expect("minimal profile fixture")
330    }
331
332    /// This agent's Smart override, wherever it lives: the promoted `smart`
333    /// field, else the legacy `routing.smart` nesting that older profiles and
334    /// exported `.muragent` bundles still carry. `None` = follow the global
335    /// setting.
336    ///
337    /// Every reader goes through here. A surface that checked only the
338    /// promoted field would report "follows global" for an agent whose legacy
339    /// override is actually in force — one fact, two answers.
340    pub fn smart_override(&self) -> Option<&crate::config::SmartOverride> {
341        self.smart
342            .as_ref()
343            .or_else(|| self.routing.as_ref().and_then(|r| r.smart.as_ref()))
344    }
345
346    /// This agent's effective Smart config: the global values with the agent's
347    /// override layered on.
348    pub fn effective_smart(
349        &self,
350        cfg: &crate::config::ModelSwitchConfig,
351    ) -> crate::config::SmartConfig {
352        cfg.smart.merged(self.smart_override())
353    }
354
355    /// This agent's effective difficulty-routing config.
356    pub fn effective_routing(
357        &self,
358        cfg: &crate::config::ModelSwitchConfig,
359    ) -> crate::config::RoutingConfig {
360        cfg.routing.merged(self.routing.as_ref())
361    }
362
363    /// Load an agent's profile from `<mur_home>/agents/<name>/profile.yaml`.
364    ///
365    /// Canonical read-path counterpart to the atomic-write path used by
366    /// `mur agent create`/`mur agent mcp add` (`write_atomic` in
367    /// `mur-core::cmd::agent`) — callers that already have `mur_home` in
368    /// hand (e.g. provisioning flows, tests) can load a profile without
369    /// going through the `MUR_HOME`-env-var-based `resolve_mur_home`.
370    pub fn load(mur_home: &std::path::Path, name: &str) -> anyhow::Result<Self> {
371        let path = mur_home.join("agents").join(name).join("profile.yaml");
372        let yaml = std::fs::read_to_string(&path)
373            .map_err(|e| anyhow::anyhow!("read {}: {e}", path.display()))?;
374        serde_yaml_ng::from_str(&yaml).map_err(|e| anyhow::anyhow!("parse {}: {e}", path.display()))
375    }
376
377    /// The imported add-on group a skill/mcp/command name belongs to.
378    pub fn group_of(&self, name: &str) -> Option<&AddonRef> {
379        self.addons.iter().find(|g| {
380            g.skills.iter().any(|n| n == name)
381                || g.mcp.iter().any(|n| n == name)
382                || g.commands.iter().any(|n| n == name)
383        })
384    }
385
386    /// Whether `skill_name` is enabled (§3.3): not denied AND, if it
387    /// belongs to an imported group, that group is enabled.
388    pub fn skill_enabled(&self, skill_name: &str) -> bool {
389        name_enabled(&self.disabled_skills, skill_name)
390            && self.group_of(skill_name).is_none_or(|g| g.enabled)
391    }
392
393    /// Whether MCP server `server_id` is enabled (§3.3).
394    pub fn mcp_enabled(&self, server_id: &str) -> bool {
395        name_enabled(&self.disabled_mcp, server_id)
396            && self.group_of(server_id).is_none_or(|g| g.enabled)
397    }
398
399    /// Toggle a skill for this agent without uninstalling it.
400    pub fn set_skill_enabled(&mut self, skill_name: &str, enabled: bool) {
401        set_denylist(&mut self.disabled_skills, skill_name, enabled);
402    }
403
404    /// Toggle an MCP server for this agent without removing it.
405    pub fn set_mcp_enabled(&mut self, server_id: &str, enabled: bool) {
406        set_denylist(&mut self.disabled_mcp, server_id, enabled);
407    }
408
409    /// Toggle an imported plugin-group as a unit. Returns false if no
410    /// add-on has that id.
411    pub fn set_addon_enabled(&mut self, addon_id: &str, enabled: bool) -> bool {
412        match self.addons.iter_mut().find(|g| g.id == addon_id) {
413            Some(g) => {
414                g.enabled = enabled;
415                true
416            }
417            None => false,
418        }
419    }
420
421    /// Emergency kill-switch (§7): clears every add-on group's `enabled` flag.
422    /// Members are already forced off by the group AND-gate in `skill_enabled` /
423    /// `mcp_enabled`, so no denylist push is needed — and avoiding it means
424    /// `set_addon_enabled(id, true)` fully restores the group without leftover
425    /// per-member denials.
426    pub fn disable_all_addons(&mut self) {
427        for g in &mut self.addons {
428            g.enabled = false;
429        }
430    }
431
432    /// This agent's MCP servers minus any disabled for it.
433    pub fn enabled_mcp_servers(&self) -> Vec<McpServerEntry> {
434        self.mcp_servers
435            .iter()
436            .filter(|m| self.mcp_enabled(&m.name))
437            .cloned()
438            .collect()
439    }
440}
441
442#[cfg(test)]
443mod model_ref_tests {
444    use super::*;
445
446    #[test]
447    fn legacy_profile_without_model_ref_still_parses() {
448        let yaml = include_str!("../../tests/fixtures/profile_p0a_minimal.yaml");
449        let p: AgentProfile = serde_yaml_ng::from_str(yaml).unwrap();
450        assert!(
451            p.model_ref.is_none(),
452            "legacy profile must not have model_ref"
453        );
454    }
455
456    #[test]
457    fn round_trip_with_model_ref_preserves_field() {
458        let yaml = include_str!("../../tests/fixtures/profile_p0a_minimal.yaml");
459        let mut p: AgentProfile = serde_yaml_ng::from_str(yaml).unwrap();
460        p.model_ref = Some("anthropic_opus_4_7".into());
461        let s = serde_yaml_ng::to_string(&p).unwrap();
462        assert!(s.contains("model_ref: anthropic_opus_4_7"), "yaml: {s}");
463        let p2: AgentProfile = serde_yaml_ng::from_str(&s).unwrap();
464        assert_eq!(p2.model_ref.as_deref(), Some("anthropic_opus_4_7"));
465    }
466
467    #[test]
468    fn per_agent_fallback_and_routing_optional_and_legacy_safe() {
469        // Load fixture (no fallback_chain / routing) — legacy safe.
470        let yaml = include_str!("../../tests/fixtures/profile_p0a_minimal.yaml");
471        let p: AgentProfile = serde_yaml_ng::from_str(yaml).unwrap();
472        assert!(
473            p.fallback_chain.is_empty(),
474            "legacy profile must have empty fallback_chain"
475        );
476        assert!(
477            p.routing.is_none(),
478            "legacy profile must have no routing override"
479        );
480
481        // Round-trip with fallback_chain and routing.
482        let mut p = p.clone();
483        p.fallback_chain = vec!["claude_opus".into(), "claude_sonnet".into()];
484        p.routing = Some(crate::config::RoutingOverride {
485            enabled: Some(true),
486            ..Default::default()
487        });
488        let s = serde_yaml_ng::to_string(&p).unwrap();
489        assert!(
490            s.contains("fallback_chain:"),
491            "yaml must contain fallback_chain"
492        );
493        assert!(s.contains("routing:"), "yaml must contain routing");
494        let p2: AgentProfile = serde_yaml_ng::from_str(&s).unwrap();
495        assert_eq!(
496            p2.fallback_chain,
497            vec!["claude_opus", "claude_sonnet"],
498            "fallback_chain must round-trip"
499        );
500        assert_eq!(
501            p2.routing.as_ref().unwrap().enabled,
502            Some(true),
503            "routing.enabled must round-trip"
504        );
505    }
506
507    #[test]
508    fn effective_smart_prefers_the_promoted_field_then_the_legacy_nesting() {
509        use crate::config::{ModelSwitchConfig, SmartConfig, SmartOverride};
510        let cfg = ModelSwitchConfig {
511            smart: SmartConfig {
512                enabled: false,
513                cheap: Some("g".into()),
514                max_escalations: 2,
515            },
516            ..Default::default()
517        };
518        // No override at all → the global values, untouched.
519        let p = AgentProfile::default_for_tests();
520        assert_eq!(p.effective_smart(&cfg), cfg.smart);
521
522        // Legacy profiles carry the override nested under `routing`.
523        let mut legacy = AgentProfile::default_for_tests();
524        legacy.routing = Some(crate::config::RoutingOverride {
525            smart: Some(SmartOverride {
526                enabled: Some(true),
527                ..Default::default()
528            }),
529            ..Default::default()
530        });
531        assert!(
532            legacy.effective_smart(&cfg).enabled,
533            "legacy nesting is read"
534        );
535        assert_eq!(
536            legacy.effective_smart(&cfg).cheap.as_deref(),
537            Some("g"),
538            "unset fields still inherit"
539        );
540
541        // The promoted field wins when both are present.
542        let mut both = legacy.clone();
543        both.smart = Some(SmartOverride {
544            enabled: Some(false),
545            ..Default::default()
546        });
547        assert!(!both.effective_smart(&cfg).enabled);
548    }
549}
550
551#[cfg(test)]
552mod skill_card_tests {
553    use super::*;
554
555    #[test]
556    fn installed_skills_default_to_empty_when_absent() {
557        let yaml = include_str!("../../tests/fixtures/profile_p0a_minimal.yaml");
558        let p: AgentProfile = serde_yaml_ng::from_str(yaml).unwrap();
559        assert!(p.installed_skills.is_empty());
560    }
561
562    #[test]
563    fn installed_skills_roundtrip_preserves_entries() {
564        let base = include_str!("../../tests/fixtures/profile_p0a_minimal.yaml");
565        let yaml = format!(
566            "{base}installed_skills:\n  - name: s1\n    version: 1.0.0\n    publisher: human:d\n    description: desc\n    category: workflow\n    tags: [web]\n    triggers:\n      - type: command\n        pattern: /find\n    abstract: does things\n    transfer_chain:\n      - agent://alice\n"
567        );
568        let p: AgentProfile = serde_yaml_ng::from_str(&yaml).unwrap();
569        assert_eq!(p.installed_skills.len(), 1);
570        assert_eq!(p.installed_skills[0].name, "s1");
571        assert_eq!(p.installed_skills[0].abstract_text, "does things");
572        assert_eq!(p.installed_skills[0].transfer_chain, vec!["agent://alice"]);
573
574        let out = serde_yaml_ng::to_string(&p).unwrap();
575        assert!(out.contains("abstract: does things"));
576        assert!(out.contains("pattern: /find"));
577
578        let back: AgentProfile = serde_yaml_ng::from_str(&out).unwrap();
579        assert_eq!(p.installed_skills, back.installed_skills);
580    }
581
582    #[test]
583    fn installed_skills_minimal_entry_serializes_compactly() {
584        // A name-only entry must NOT emit empty string fields.
585        let entry = SkillCardEntry {
586            name: "minimal".into(),
587            ..Default::default()
588        };
589        let yaml = serde_yaml_ng::to_string(&entry).unwrap();
590        assert!(yaml.contains("name: minimal"));
591        assert!(
592            !yaml.contains("version:"),
593            "empty version must be skipped: {yaml}"
594        );
595        assert!(
596            !yaml.contains("publisher:"),
597            "empty publisher must be skipped: {yaml}"
598        );
599        assert!(
600            !yaml.contains("abstract:"),
601            "empty abstract must be skipped: {yaml}"
602        );
603    }
604}
605
606#[cfg(test)]
607mod secrets_field_tests {
608    /// The list is NAMES only and must stay absent from the YAML when empty:
609    /// every existing profile on disk is rewritten by unrelated edits, and a
610    /// new always-present key would churn all of them.
611    #[test]
612    fn secrets_names_round_trip_and_are_absent_when_empty() {
613        let mut p = crate::agent::AgentProfile::default_for_tests();
614        let yaml = serde_yaml::to_string(&p).unwrap();
615        assert!(
616            !yaml.contains("secrets:"),
617            "empty list must not be written: {yaml}"
618        );
619        p.secrets = vec!["GITEA_TOKEN".into()];
620        let yaml = serde_yaml::to_string(&p).unwrap();
621        let back: crate::agent::AgentProfile = serde_yaml::from_str(&yaml).unwrap();
622        assert_eq!(back.secrets, vec!["GITEA_TOKEN".to_string()]);
623    }
624}