1use 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#[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 #[serde(default, skip_serializing_if = "String::is_empty", rename = "abstract")]
48 pub abstract_text: String,
49 #[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, pub name: String,
68 pub display_name: String,
69 #[serde(default, skip_serializing_if = "Option::is_none")]
77 pub role: Option<String>,
78 #[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 #[serde(default, skip_serializing_if = "Option::is_none")]
96 pub model_ref: Option<String>,
97 #[serde(default, skip_serializing_if = "Vec::is_empty")]
100 pub fallback_chain: Vec<String>,
101 #[serde(default, skip_serializing_if = "Option::is_none")]
104 pub routing: Option<crate::config::RoutingOverride>,
105 #[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 #[serde(default, skip_serializing_if = "Vec::is_empty")]
119 pub installed_skills: Vec<SkillCardEntry>,
120 #[serde(default, skip_serializing_if = "Vec::is_empty")]
125 pub disabled_skills: Vec<String>,
126
127 #[serde(default, skip_serializing_if = "Vec::is_empty")]
131 pub disabled_mcp: Vec<String>,
132
133 #[serde(default, skip_serializing_if = "Vec::is_empty")]
139 pub secrets: Vec<String>,
140 #[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 #[serde(default)]
157 pub identity: IdentityConfig,
158 #[serde(default)]
159 pub file_transfer: FileTransferConfig,
160 #[serde(default)]
161 pub deployment: DeploymentConfig,
162 #[serde(default)]
165 pub companion: CompanionConfig,
166 #[serde(default)]
168 pub hitl: HitlConfig,
169 #[serde(default, skip_serializing_if = "Option::is_none")]
174 pub limits: Option<crate::limits::Limits>,
175 #[serde(default)]
177 pub voice: VoiceConfig,
178 #[serde(default)]
180 pub hooks: crate::HooksConfig,
181 #[serde(default)]
184 pub trusted_peers: Vec<crate::bridge::peer::TrustedPeer>,
185 pub created_at: String,
186 pub updated_at: String,
187 #[serde(default)]
189 pub appearance: AgentAppearance,
190 #[serde(default)]
192 pub federation: FederationConfig,
193
194 #[serde(default)]
198 pub file_actions: Vec<crate::action::FileAction>,
199
200 #[serde(default)]
202 pub action_pipeline: crate::action::ActionPipelineConfig,
203
204 #[serde(default, skip_serializing_if = "Vec::is_empty")]
207 pub requires_programs: Vec<ProgramDep>,
208
209 #[serde(default, skip_serializing_if = "Vec::is_empty")]
212 pub requires_capabilities: Vec<String>,
213
214 #[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
225pub const SUPPORTED_ALGORITHMS: &[&str] = &["ed25519"];
227
228#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
229pub struct IdentityConfig {
230 #[serde(default)]
233 pub pubkey: String,
234 #[serde(default, skip_serializing_if = "Option::is_none")]
236 pub owner: Option<String>,
237
238 #[serde(default = "default_algorithm")]
241 pub algorithm: String,
242 #[serde(default)]
244 pub key_version: u32,
245 #[serde(default, skip_serializing_if = "Option::is_none")]
247 pub created_at_key: Option<String>,
248 #[serde(default, skip_serializing_if = "Option::is_none")]
250 pub previous_pubkey: Option<String>,
251 #[serde(default, skip_serializing_if = "Option::is_none")]
253 pub previous_key_version: Option<u32>,
254 #[serde(default, skip_serializing_if = "Option::is_none")]
257 pub grace_expires_at: Option<String>,
258 #[serde(default, skip_serializing_if = "Option::is_none")]
260 pub rotated_at: Option<String>,
261 #[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 #[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 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 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 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 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 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 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 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 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 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 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 pub fn disable_all_addons(&mut self) {
427 for g in &mut self.addons {
428 g.enabled = false;
429 }
430 }
431
432 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 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 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 let p = AgentProfile::default_for_tests();
520 assert_eq!(p.effective_smart(&cfg), cfg.smart);
521
522 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 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 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 #[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}