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 transport;
14
15pub use companion::*;
16pub use entitlements::*;
17pub use lifecycle::*;
18pub use mcp::*;
19pub use transport::*;
20
21#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
27pub struct SkillCardEntry {
28 pub name: String,
29 #[serde(default, skip_serializing_if = "String::is_empty")]
30 pub version: String,
31 #[serde(default, skip_serializing_if = "String::is_empty")]
32 pub publisher: String,
33 #[serde(default, skip_serializing_if = "String::is_empty")]
34 pub description: String,
35 #[serde(default, skip_serializing_if = "String::is_empty")]
36 pub category: String,
37 #[serde(default, skip_serializing_if = "Vec::is_empty")]
38 pub tags: Vec<String>,
39 #[serde(default, skip_serializing_if = "Vec::is_empty")]
40 pub triggers: Vec<SkillCardTrigger>,
41 #[serde(default, skip_serializing_if = "String::is_empty", rename = "abstract")]
44 pub abstract_text: String,
45 #[serde(default, skip_serializing_if = "Vec::is_empty")]
48 pub transfer_chain: Vec<String>,
49}
50
51#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
52pub struct SkillCardTrigger {
53 #[serde(rename = "type")]
54 pub kind: String,
55 #[serde(default, skip_serializing_if = "String::is_empty")]
56 pub pattern: String,
57}
58
59#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
60pub struct AgentProfile {
61 pub schema: u32,
62 pub id: String, pub name: String,
64 pub display_name: String,
65 #[serde(default, skip_serializing_if = "Option::is_none")]
73 pub role: Option<String>,
74 #[serde(default, skip_serializing_if = "Option::is_none")]
84 pub effort: Option<crate::llm::Effort>,
85 pub version: String,
86 pub persona: Persona,
87 pub sys_prompt_file: String,
88 pub model: ModelConfig,
89 #[serde(default, skip_serializing_if = "Option::is_none")]
92 pub model_ref: Option<String>,
93 #[serde(default, skip_serializing_if = "Vec::is_empty")]
96 pub fallback_chain: Vec<String>,
97 #[serde(default, skip_serializing_if = "Option::is_none")]
100 pub routing: Option<crate::config::RoutingOverride>,
101 #[serde(default, skip_serializing_if = "Option::is_none")]
106 pub smart: Option<crate::config::SmartOverride>,
107 #[serde(default)]
108 pub mcp_servers: Vec<McpServerEntry>,
109 #[serde(default)]
110 pub skills: Vec<String>,
111 #[serde(default, skip_serializing_if = "Vec::is_empty")]
115 pub installed_skills: Vec<SkillCardEntry>,
116 #[serde(default, skip_serializing_if = "Vec::is_empty")]
121 pub disabled_skills: Vec<String>,
122
123 #[serde(default, skip_serializing_if = "Vec::is_empty")]
127 pub disabled_mcp: Vec<String>,
128
129 #[serde(default, skip_serializing_if = "Vec::is_empty")]
135 pub secrets: Vec<String>,
136 #[serde(default, skip_serializing_if = "Vec::is_empty")]
140 pub addons: Vec<AddonRef>,
141 pub transport: TransportConfig,
142 pub communication: CommunicationConfig,
143 #[serde(default)]
144 pub capabilities: Vec<String>,
145 pub entitlements: Entitlements,
146 #[serde(default)]
147 pub notifications: NotificationsConfig,
148 pub retry: RetryConfig,
149 pub lifecycle: LifecycleConfig,
150 #[serde(default)]
153 pub identity: IdentityConfig,
154 #[serde(default)]
155 pub file_transfer: FileTransferConfig,
156 #[serde(default)]
157 pub deployment: DeploymentConfig,
158 #[serde(default)]
161 pub companion: CompanionConfig,
162 #[serde(default)]
164 pub hitl: HitlConfig,
165 #[serde(default, skip_serializing_if = "Option::is_none")]
170 pub limits: Option<crate::limits::Limits>,
171 #[serde(default)]
173 pub voice: VoiceConfig,
174 #[serde(default)]
176 pub hooks: crate::HooksConfig,
177 #[serde(default)]
180 pub trusted_peers: Vec<crate::bridge::peer::TrustedPeer>,
181 pub created_at: String,
182 pub updated_at: String,
183 #[serde(default)]
185 pub appearance: AgentAppearance,
186 #[serde(default)]
188 pub federation: FederationConfig,
189
190 #[serde(default)]
194 pub file_actions: Vec<crate::action::FileAction>,
195
196 #[serde(default)]
198 pub action_pipeline: crate::action::ActionPipelineConfig,
199
200 #[serde(default, skip_serializing_if = "Vec::is_empty")]
203 pub requires_programs: Vec<ProgramDep>,
204
205 #[serde(default, skip_serializing_if = "Vec::is_empty")]
208 pub requires_capabilities: Vec<String>,
209}
210
211fn default_algorithm() -> String {
212 "ed25519".into()
213}
214
215pub const SUPPORTED_ALGORITHMS: &[&str] = &["ed25519"];
217
218#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
219pub struct IdentityConfig {
220 #[serde(default)]
223 pub pubkey: String,
224 #[serde(default, skip_serializing_if = "Option::is_none")]
226 pub owner: Option<String>,
227
228 #[serde(default = "default_algorithm")]
231 pub algorithm: String,
232 #[serde(default)]
234 pub key_version: u32,
235 #[serde(default, skip_serializing_if = "Option::is_none")]
237 pub created_at_key: Option<String>,
238 #[serde(default, skip_serializing_if = "Option::is_none")]
240 pub previous_pubkey: Option<String>,
241 #[serde(default, skip_serializing_if = "Option::is_none")]
243 pub previous_key_version: Option<u32>,
244 #[serde(default, skip_serializing_if = "Option::is_none")]
247 pub grace_expires_at: Option<String>,
248 #[serde(default, skip_serializing_if = "Option::is_none")]
250 pub rotated_at: Option<String>,
251 #[serde(default, skip_serializing_if = "Option::is_none")]
253 pub emergency_rekey_at: Option<String>,
254}
255
256impl Default for IdentityConfig {
257 fn default() -> Self {
258 Self {
259 pubkey: String::new(),
260 owner: None,
261 algorithm: default_algorithm(),
262 key_version: 0,
263 created_at_key: None,
264 previous_pubkey: None,
265 previous_key_version: None,
266 grace_expires_at: None,
267 rotated_at: None,
268 emergency_rekey_at: None,
269 }
270 }
271}
272
273#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
274pub struct Persona {
275 pub category: PersonaCategory,
276 pub description: String,
277 pub traits: PersonaTraits,
278}
279
280#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
281#[serde(rename_all = "lowercase")]
282pub enum PersonaCategory {
283 Research,
284 Automation,
285 Monitor,
286 Notify,
287 Commerce,
288 Custom,
289}
290
291#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
292pub struct PersonaTraits {
293 pub tone: String,
294 pub risk: String,
295 pub verbosity: String,
296}
297
298#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
299pub struct ModelConfig {
300 pub provider: String,
301 pub name: String,
302 #[serde(default)]
303 pub params: BTreeMap<String, serde_yaml_ng::Value>,
304}
305
306fn default_true() -> bool {
307 true
308}
309
310impl AgentProfile {
311 #[doc(hidden)]
317 pub fn default_for_tests() -> Self {
318 serde_yaml_ng::from_str(include_str!("../../tests/fixtures/minimal_profile.yaml"))
319 .expect("minimal profile fixture")
320 }
321
322 pub fn smart_override(&self) -> Option<&crate::config::SmartOverride> {
331 self.smart
332 .as_ref()
333 .or_else(|| self.routing.as_ref().and_then(|r| r.smart.as_ref()))
334 }
335
336 pub fn effective_smart(
339 &self,
340 cfg: &crate::config::ModelSwitchConfig,
341 ) -> crate::config::SmartConfig {
342 cfg.smart.merged(self.smart_override())
343 }
344
345 pub fn effective_routing(
347 &self,
348 cfg: &crate::config::ModelSwitchConfig,
349 ) -> crate::config::RoutingConfig {
350 cfg.routing.merged(self.routing.as_ref())
351 }
352
353 pub fn load(mur_home: &std::path::Path, name: &str) -> anyhow::Result<Self> {
361 let path = mur_home.join("agents").join(name).join("profile.yaml");
362 let yaml = std::fs::read_to_string(&path)
363 .map_err(|e| anyhow::anyhow!("read {}: {e}", path.display()))?;
364 serde_yaml_ng::from_str(&yaml).map_err(|e| anyhow::anyhow!("parse {}: {e}", path.display()))
365 }
366
367 pub fn group_of(&self, name: &str) -> Option<&AddonRef> {
369 self.addons.iter().find(|g| {
370 g.skills.iter().any(|n| n == name)
371 || g.mcp.iter().any(|n| n == name)
372 || g.commands.iter().any(|n| n == name)
373 })
374 }
375
376 pub fn skill_enabled(&self, skill_name: &str) -> bool {
379 name_enabled(&self.disabled_skills, skill_name)
380 && self.group_of(skill_name).is_none_or(|g| g.enabled)
381 }
382
383 pub fn mcp_enabled(&self, server_id: &str) -> bool {
385 name_enabled(&self.disabled_mcp, server_id)
386 && self.group_of(server_id).is_none_or(|g| g.enabled)
387 }
388
389 pub fn set_skill_enabled(&mut self, skill_name: &str, enabled: bool) {
391 set_denylist(&mut self.disabled_skills, skill_name, enabled);
392 }
393
394 pub fn set_mcp_enabled(&mut self, server_id: &str, enabled: bool) {
396 set_denylist(&mut self.disabled_mcp, server_id, enabled);
397 }
398
399 pub fn set_addon_enabled(&mut self, addon_id: &str, enabled: bool) -> bool {
402 match self.addons.iter_mut().find(|g| g.id == addon_id) {
403 Some(g) => {
404 g.enabled = enabled;
405 true
406 }
407 None => false,
408 }
409 }
410
411 pub fn disable_all_addons(&mut self) {
417 for g in &mut self.addons {
418 g.enabled = false;
419 }
420 }
421
422 pub fn enabled_mcp_servers(&self) -> Vec<McpServerEntry> {
424 self.mcp_servers
425 .iter()
426 .filter(|m| self.mcp_enabled(&m.name))
427 .cloned()
428 .collect()
429 }
430}
431
432#[cfg(test)]
433mod model_ref_tests {
434 use super::*;
435
436 #[test]
437 fn legacy_profile_without_model_ref_still_parses() {
438 let yaml = include_str!("../../tests/fixtures/profile_p0a_minimal.yaml");
439 let p: AgentProfile = serde_yaml_ng::from_str(yaml).unwrap();
440 assert!(
441 p.model_ref.is_none(),
442 "legacy profile must not have model_ref"
443 );
444 }
445
446 #[test]
447 fn round_trip_with_model_ref_preserves_field() {
448 let yaml = include_str!("../../tests/fixtures/profile_p0a_minimal.yaml");
449 let mut p: AgentProfile = serde_yaml_ng::from_str(yaml).unwrap();
450 p.model_ref = Some("anthropic_opus_4_7".into());
451 let s = serde_yaml_ng::to_string(&p).unwrap();
452 assert!(s.contains("model_ref: anthropic_opus_4_7"), "yaml: {s}");
453 let p2: AgentProfile = serde_yaml_ng::from_str(&s).unwrap();
454 assert_eq!(p2.model_ref.as_deref(), Some("anthropic_opus_4_7"));
455 }
456
457 #[test]
458 fn per_agent_fallback_and_routing_optional_and_legacy_safe() {
459 let yaml = include_str!("../../tests/fixtures/profile_p0a_minimal.yaml");
461 let p: AgentProfile = serde_yaml_ng::from_str(yaml).unwrap();
462 assert!(
463 p.fallback_chain.is_empty(),
464 "legacy profile must have empty fallback_chain"
465 );
466 assert!(
467 p.routing.is_none(),
468 "legacy profile must have no routing override"
469 );
470
471 let mut p = p.clone();
473 p.fallback_chain = vec!["claude_opus".into(), "claude_sonnet".into()];
474 p.routing = Some(crate::config::RoutingOverride {
475 enabled: Some(true),
476 ..Default::default()
477 });
478 let s = serde_yaml_ng::to_string(&p).unwrap();
479 assert!(
480 s.contains("fallback_chain:"),
481 "yaml must contain fallback_chain"
482 );
483 assert!(s.contains("routing:"), "yaml must contain routing");
484 let p2: AgentProfile = serde_yaml_ng::from_str(&s).unwrap();
485 assert_eq!(
486 p2.fallback_chain,
487 vec!["claude_opus", "claude_sonnet"],
488 "fallback_chain must round-trip"
489 );
490 assert_eq!(
491 p2.routing.as_ref().unwrap().enabled,
492 Some(true),
493 "routing.enabled must round-trip"
494 );
495 }
496
497 #[test]
498 fn effective_smart_prefers_the_promoted_field_then_the_legacy_nesting() {
499 use crate::config::{ModelSwitchConfig, SmartConfig, SmartOverride};
500 let cfg = ModelSwitchConfig {
501 smart: SmartConfig {
502 enabled: false,
503 cheap: Some("g".into()),
504 max_escalations: 2,
505 },
506 ..Default::default()
507 };
508 let p = AgentProfile::default_for_tests();
510 assert_eq!(p.effective_smart(&cfg), cfg.smart);
511
512 let mut legacy = AgentProfile::default_for_tests();
514 legacy.routing = Some(crate::config::RoutingOverride {
515 smart: Some(SmartOverride {
516 enabled: Some(true),
517 ..Default::default()
518 }),
519 ..Default::default()
520 });
521 assert!(
522 legacy.effective_smart(&cfg).enabled,
523 "legacy nesting is read"
524 );
525 assert_eq!(
526 legacy.effective_smart(&cfg).cheap.as_deref(),
527 Some("g"),
528 "unset fields still inherit"
529 );
530
531 let mut both = legacy.clone();
533 both.smart = Some(SmartOverride {
534 enabled: Some(false),
535 ..Default::default()
536 });
537 assert!(!both.effective_smart(&cfg).enabled);
538 }
539}
540
541#[cfg(test)]
542mod skill_card_tests {
543 use super::*;
544
545 #[test]
546 fn installed_skills_default_to_empty_when_absent() {
547 let yaml = include_str!("../../tests/fixtures/profile_p0a_minimal.yaml");
548 let p: AgentProfile = serde_yaml_ng::from_str(yaml).unwrap();
549 assert!(p.installed_skills.is_empty());
550 }
551
552 #[test]
553 fn installed_skills_roundtrip_preserves_entries() {
554 let base = include_str!("../../tests/fixtures/profile_p0a_minimal.yaml");
555 let yaml = format!(
556 "{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"
557 );
558 let p: AgentProfile = serde_yaml_ng::from_str(&yaml).unwrap();
559 assert_eq!(p.installed_skills.len(), 1);
560 assert_eq!(p.installed_skills[0].name, "s1");
561 assert_eq!(p.installed_skills[0].abstract_text, "does things");
562 assert_eq!(p.installed_skills[0].transfer_chain, vec!["agent://alice"]);
563
564 let out = serde_yaml_ng::to_string(&p).unwrap();
565 assert!(out.contains("abstract: does things"));
566 assert!(out.contains("pattern: /find"));
567
568 let back: AgentProfile = serde_yaml_ng::from_str(&out).unwrap();
569 assert_eq!(p.installed_skills, back.installed_skills);
570 }
571
572 #[test]
573 fn installed_skills_minimal_entry_serializes_compactly() {
574 let entry = SkillCardEntry {
576 name: "minimal".into(),
577 ..Default::default()
578 };
579 let yaml = serde_yaml_ng::to_string(&entry).unwrap();
580 assert!(yaml.contains("name: minimal"));
581 assert!(
582 !yaml.contains("version:"),
583 "empty version must be skipped: {yaml}"
584 );
585 assert!(
586 !yaml.contains("publisher:"),
587 "empty publisher must be skipped: {yaml}"
588 );
589 assert!(
590 !yaml.contains("abstract:"),
591 "empty abstract must be skipped: {yaml}"
592 );
593 }
594}
595
596#[cfg(test)]
597mod secrets_field_tests {
598 #[test]
602 fn secrets_names_round_trip_and_are_absent_when_empty() {
603 let mut p = crate::agent::AgentProfile::default_for_tests();
604 let yaml = serde_yaml::to_string(&p).unwrap();
605 assert!(
606 !yaml.contains("secrets:"),
607 "empty list must not be written: {yaml}"
608 );
609 p.secrets = vec!["GITEA_TOKEN".into()];
610 let yaml = serde_yaml::to_string(&p).unwrap();
611 let back: crate::agent::AgentProfile = serde_yaml::from_str(&yaml).unwrap();
612 assert_eq!(back.secrets, vec!["GITEA_TOKEN".to_string()]);
613 }
614}