1use crate::companion::{Formality, Relationship};
5use serde::{Deserialize, Serialize};
6use std::collections::BTreeMap;
7
8#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
14pub struct SkillCardEntry {
15 pub name: String,
16 #[serde(default, skip_serializing_if = "String::is_empty")]
17 pub version: String,
18 #[serde(default, skip_serializing_if = "String::is_empty")]
19 pub publisher: String,
20 #[serde(default, skip_serializing_if = "String::is_empty")]
21 pub description: String,
22 #[serde(default, skip_serializing_if = "String::is_empty")]
23 pub category: String,
24 #[serde(default, skip_serializing_if = "Vec::is_empty")]
25 pub tags: Vec<String>,
26 #[serde(default, skip_serializing_if = "Vec::is_empty")]
27 pub triggers: Vec<SkillCardTrigger>,
28 #[serde(default, skip_serializing_if = "String::is_empty", rename = "abstract")]
31 pub abstract_text: String,
32 #[serde(default, skip_serializing_if = "Vec::is_empty")]
35 pub transfer_chain: Vec<String>,
36}
37
38#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
39pub struct SkillCardTrigger {
40 #[serde(rename = "type")]
41 pub kind: String,
42 #[serde(default, skip_serializing_if = "String::is_empty")]
43 pub pattern: String,
44}
45
46#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
47pub struct AgentProfile {
48 pub schema: u32,
49 pub id: String, pub name: String,
51 pub display_name: String,
52 pub version: String,
53 pub persona: Persona,
54 pub sys_prompt_file: String,
55 pub model: ModelConfig,
56 #[serde(default, skip_serializing_if = "Option::is_none")]
59 pub model_ref: Option<String>,
60 #[serde(default)]
61 pub mcp_servers: Vec<McpServerEntry>,
62 #[serde(default)]
63 pub skills: Vec<String>,
64 #[serde(default, skip_serializing_if = "Vec::is_empty")]
68 pub installed_skills: Vec<SkillCardEntry>,
69 pub transport: TransportConfig,
70 pub communication: CommunicationConfig,
71 #[serde(default)]
72 pub capabilities: Vec<String>,
73 pub entitlements: Entitlements,
74 #[serde(default)]
75 pub notifications: NotificationsConfig,
76 pub retry: RetryConfig,
77 pub lifecycle: LifecycleConfig,
78 #[serde(default)]
81 pub identity: IdentityConfig,
82 #[serde(default)]
83 pub file_transfer: FileTransferConfig,
84 #[serde(default)]
85 pub deployment: DeploymentConfig,
86 #[serde(default)]
89 pub companion: CompanionConfig,
90 #[serde(default)]
92 pub voice: VoiceConfig,
93 #[serde(default)]
95 pub hooks: crate::HooksConfig,
96 #[serde(default)]
99 pub trusted_peers: Vec<crate::bridge::peer::TrustedPeer>,
100 pub created_at: String,
101 pub updated_at: String,
102 #[serde(default)]
104 pub appearance: AgentAppearance,
105 #[serde(default)]
107 pub federation: FederationConfig,
108}
109
110fn default_algorithm() -> String {
111 "ed25519".into()
112}
113
114pub const SUPPORTED_ALGORITHMS: &[&str] = &["ed25519"];
116
117#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
118pub struct IdentityConfig {
119 #[serde(default)]
122 pub pubkey: String,
123 #[serde(default, skip_serializing_if = "Option::is_none")]
125 pub owner: Option<String>,
126
127 #[serde(default = "default_algorithm")]
130 pub algorithm: String,
131 #[serde(default)]
133 pub key_version: u32,
134 #[serde(default, skip_serializing_if = "Option::is_none")]
136 pub created_at_key: Option<String>,
137 #[serde(default, skip_serializing_if = "Option::is_none")]
139 pub previous_pubkey: Option<String>,
140 #[serde(default, skip_serializing_if = "Option::is_none")]
142 pub previous_key_version: Option<u32>,
143 #[serde(default, skip_serializing_if = "Option::is_none")]
146 pub grace_expires_at: Option<String>,
147 #[serde(default, skip_serializing_if = "Option::is_none")]
149 pub rotated_at: Option<String>,
150 #[serde(default, skip_serializing_if = "Option::is_none")]
152 pub emergency_rekey_at: Option<String>,
153}
154
155impl Default for IdentityConfig {
156 fn default() -> Self {
157 Self {
158 pubkey: String::new(),
159 owner: None,
160 algorithm: default_algorithm(),
161 key_version: 0,
162 created_at_key: None,
163 previous_pubkey: None,
164 previous_key_version: None,
165 grace_expires_at: None,
166 rotated_at: None,
167 emergency_rekey_at: None,
168 }
169 }
170}
171
172#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
173pub struct Persona {
174 pub category: PersonaCategory,
175 pub description: String,
176 pub traits: PersonaTraits,
177}
178
179#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
180#[serde(rename_all = "lowercase")]
181pub enum PersonaCategory {
182 Research,
183 Automation,
184 Monitor,
185 Notify,
186 Commerce,
187 Custom,
188}
189
190#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
191pub struct PersonaTraits {
192 pub tone: String,
193 pub risk: String,
194 pub verbosity: String,
195}
196
197#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
198pub struct ModelConfig {
199 pub provider: String,
200 pub name: String,
201 #[serde(default)]
202 pub params: BTreeMap<String, serde_yaml_ng::Value>,
203}
204
205#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
206pub struct McpServerEntry {
207 pub name: String,
208 pub command: String,
209 #[serde(default)]
210 pub args: Vec<String>,
211
212 #[serde(default, skip_serializing_if = "Option::is_none")]
218 pub binary_sha256: Option<String>,
219
220 #[serde(default, skip_serializing_if = "Option::is_none")]
226 pub description_hash: Option<String>,
227
228 #[serde(default, skip_serializing_if = "Option::is_none")]
232 pub publisher: Option<McpPublisherInfo>,
233
234 #[serde(default, skip_serializing_if = "Option::is_none")]
238 pub installed_at: Option<chrono::DateTime<chrono::Utc>>,
239}
240
241#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
247pub struct McpPublisherInfo {
248 pub name: String,
251
252 #[serde(default, skip_serializing_if = "Option::is_none")]
256 pub homepage: Option<String>,
257
258 #[serde(default, skip_serializing_if = "Option::is_none")]
261 pub registry_id: Option<String>,
262}
263
264#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
265pub struct TransportConfig {
266 pub stdio: bool,
267 pub socket: SocketTransportConfig,
268 #[serde(default)]
269 pub tcp: TcpTransportConfig,
270 #[serde(default)]
274 pub webhook: WebhookTransportConfig,
275}
276
277#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
278pub struct TcpTransportConfig {
279 #[serde(default)]
280 pub enabled: bool,
281 #[serde(default)]
282 pub bind: String,
283 #[serde(default)]
284 pub noise: NoiseConfig,
285}
286
287#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
301pub struct WebhookTransportConfig {
302 #[serde(default)]
303 pub enabled: bool,
304 #[serde(default = "default_webhook_bind")]
305 pub bind: String,
306 #[serde(default = "default_webhook_port")]
307 pub port: u16,
308 #[serde(default)]
312 pub hmac_secret_ref: String,
313}
314
315fn default_webhook_bind() -> String {
316 "127.0.0.1".to_string()
317}
318
319fn default_webhook_port() -> u16 {
320 6789
321}
322
323impl Default for WebhookTransportConfig {
324 fn default() -> Self {
325 Self {
326 enabled: false,
327 bind: default_webhook_bind(),
328 port: default_webhook_port(),
329 hmac_secret_ref: String::new(),
330 }
331 }
332}
333
334#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
335pub struct NoiseConfig {
336 pub pattern: String,
337}
338
339impl Default for NoiseConfig {
340 fn default() -> Self {
341 Self {
342 pattern: "Noise_XK_25519_ChaChaPoly_BLAKE2s".into(),
343 }
344 }
345}
346
347#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
348pub struct SocketTransportConfig {
349 pub enabled: bool,
350 pub bind: String, #[serde(default, skip_serializing_if = "Option::is_none")]
352 pub auth: Option<AuthConfig>,
353}
354
355#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
356pub struct AuthConfig {
357 pub scheme: String,
358 pub token_file: String,
359}
360
361#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
362pub struct CommunicationConfig {
363 #[serde(default = "default_accepts_all")]
364 pub accepts_from: Vec<String>,
365 #[serde(default)]
366 pub sends_to: Vec<String>,
367}
368fn default_accepts_all() -> Vec<String> {
369 vec!["*".to_string()]
370}
371
372#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
373pub struct Entitlements {
374 pub network: NetworkEntitlement,
375 pub filesystem: FilesystemEntitlement,
376 pub processes: ProcessesEntitlement,
377 #[serde(default)]
378 pub syscalls: SyscallsEntitlement,
379 #[serde(default)]
380 pub limits: LimitsEntitlement,
381 #[serde(default)]
384 pub llm: crate::bridge::llm_entitlement::LlmEntitlement,
385}
386
387#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
388pub struct NetworkEntitlement {
389 pub inbound: InboundNetwork,
390 pub outbound: OutboundNetwork,
391}
392
393#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
394pub struct InboundNetwork {
395 #[serde(default)]
396 pub ports: Vec<u16>,
397}
398
399#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
400pub struct OutboundNetwork {
401 pub mode: NetworkOutboundMode,
402 #[serde(default)]
403 pub allow_hosts: Vec<String>,
404 #[serde(default = "default_protocols")]
405 pub protocols: Vec<String>,
406 #[serde(default)]
407 pub resolve_dns: ResolveDnsConfig,
408}
409fn default_protocols() -> Vec<String> {
410 vec!["tcp".to_string()]
411}
412
413#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
414#[serde(rename_all = "lowercase")]
415pub enum NetworkOutboundMode {
416 Unrestricted,
417 Restricted,
418 Off,
419}
420
421#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
422pub struct ResolveDnsConfig {
423 #[serde(default = "default_dns_mode")]
424 pub mode: String,
425 #[serde(default)]
426 pub servers: Vec<String>,
427}
428impl Default for ResolveDnsConfig {
429 fn default() -> Self {
430 Self {
431 mode: default_dns_mode(),
432 servers: vec![],
433 }
434 }
435}
436fn default_dns_mode() -> String {
437 "system".to_string()
438}
439
440#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
441pub struct FilesystemEntitlement {
442 #[serde(default)]
443 pub read: Vec<String>,
444 #[serde(default)]
445 pub write: Vec<String>,
446 #[serde(default)]
447 pub deny: Vec<String>,
448}
449
450#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
451pub struct ProcessesEntitlement {
452 pub spawn: SpawnEntitlement,
453}
454
455#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
456pub struct SpawnEntitlement {
457 pub mode: SpawnMode,
458 #[serde(default)]
459 pub allowed: Vec<String>,
460}
461
462#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
463#[serde(rename_all = "lowercase")]
464pub enum SpawnMode {
465 Allowlist,
466 Any,
467 None,
468}
469
470#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
471pub struct SyscallsEntitlement {
472 #[serde(default = "default_syscalls_mode")]
473 pub mode: String,
474 #[serde(default)]
475 pub extra_deny: Vec<String>,
476}
477fn default_syscalls_mode() -> String {
478 "default".to_string()
479}
480
481#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
482pub struct LimitsEntitlement {
483 #[serde(default)]
484 pub cpu_seconds: Option<u64>,
485 #[serde(default = "default_memory_mb")]
486 pub memory_mb: u64,
487 #[serde(default = "default_fds")]
488 pub file_descriptors: u32,
489 #[serde(default = "default_procs")]
490 pub processes: u32,
491}
492fn default_memory_mb() -> u64 {
493 512
494}
495fn default_fds() -> u32 {
496 1024
497}
498fn default_procs() -> u32 {
499 32
500}
501
502#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
503pub struct NotificationsConfig {
504 #[serde(default)]
505 pub on_task_complete: Vec<NotificationTarget>,
506 #[serde(default)]
507 pub on_error: Vec<NotificationTarget>,
508 #[serde(default)]
509 pub on_shutdown: Vec<NotificationTarget>,
510}
511
512#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
513#[serde(tag = "target", rename_all = "lowercase")]
514pub enum NotificationTarget {
515 Agent {
516 name: String,
517 },
518 Commander,
519 Email {
520 address: String,
521 #[serde(default)]
522 smtp_config_file: Option<String>,
523 },
524 Slack {
525 #[serde(default)]
526 channel: Option<String>,
527 #[serde(default)]
528 webhook_url_env: Option<String>,
529 },
530 Webpush {
531 url: String,
532 },
533 Webhook {
534 url: String,
535 #[serde(default = "default_post")]
536 method: String,
537 #[serde(default)]
538 auth: Option<String>,
539 },
540}
541fn default_post() -> String {
542 "POST".to_string()
543}
544
545#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
546pub struct RetryConfig {
547 pub llm: RetryPolicy,
548 pub tool: RetryPolicy,
549}
550
551#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
552pub struct RetryPolicy {
553 pub max_retries: u32,
554 pub backoff: BackoffStrategy,
555 pub initial_delay_ms: u64,
556 #[serde(default)]
557 pub max_delay_ms: Option<u64>,
558 #[serde(default)]
559 pub retry_on: Vec<String>,
560}
561
562#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
563#[serde(rename_all = "lowercase")]
564pub enum BackoffStrategy {
565 Linear,
566 Exponential,
567 Fixed,
568}
569
570#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
571pub struct LifecycleConfig {
572 pub restart: RestartPolicy,
573 #[serde(default = "default_max_restarts")]
574 pub max_restarts: u32,
575 #[serde(default = "default_window")]
576 pub restart_window_secs: u64,
577 #[serde(default = "default_stop_timeout")]
578 pub stop_timeout_secs: u64,
579 #[serde(default = "default_mcp_required")]
580 pub mcp_required: bool,
581 #[serde(default)]
582 pub execution: ExecutionMode,
583 #[serde(default)]
584 pub schedule: Vec<ScheduleEntry>,
585 #[serde(default)]
586 pub idle_triggers: Vec<IdleTrigger>,
587}
588fn default_max_restarts() -> u32 {
589 3
590}
591fn default_window() -> u64 {
592 600
593}
594fn default_stop_timeout() -> u64 {
595 15
596}
597fn default_mcp_required() -> bool {
598 true
599}
600
601#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
602#[serde(rename_all = "snake_case")]
603pub enum RestartPolicy {
604 Never,
605 OnFailure,
606 Always,
607}
608
609#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
610#[serde(rename_all = "snake_case")]
611pub enum ExecutionMode {
612 #[default]
613 Daemon,
614 OnDemand,
615}
616
617#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
618pub struct ScheduleEntry {
619 pub cron: String,
620 pub message: String,
621 #[serde(default, skip_serializing_if = "Option::is_none")]
622 pub sends_to: Option<String>,
623}
624
625#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
626pub struct IdleTrigger {
627 pub after_secs: u64,
629 pub message: String,
631 #[serde(default, skip_serializing_if = "Option::is_none")]
633 pub sends_to: Option<String>,
634 #[serde(default = "default_idle_cooldown")]
637 pub cooldown_secs: u64,
638 #[serde(default = "default_true")]
641 pub respect_quiet_hours: bool,
642}
643
644fn default_idle_cooldown() -> u64 {
645 600
646}
647fn default_true() -> bool {
648 true
649}
650
651#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
652pub struct FileTransferConfig {
653 #[serde(default = "default_accept_max")]
654 pub accept_incoming_file_max_bytes: u64,
655 #[serde(default = "default_accept_total")]
656 pub accept_incoming_total_per_hour: u64,
657 #[serde(default = "default_approval_threshold")]
658 pub require_approval_above_bytes: u64,
659 #[serde(default = "default_reject_paths")]
660 pub reject_paths: Vec<String>,
661 #[serde(default = "default_allowed_mime")]
662 pub allowed_mime_types: Vec<String>,
663}
664
665impl Default for FileTransferConfig {
666 fn default() -> Self {
667 Self {
668 accept_incoming_file_max_bytes: default_accept_max(),
669 accept_incoming_total_per_hour: default_accept_total(),
670 require_approval_above_bytes: default_approval_threshold(),
671 reject_paths: default_reject_paths(),
672 allowed_mime_types: default_allowed_mime(),
673 }
674 }
675}
676
677fn default_accept_max() -> u64 {
678 10_485_760
679}
680fn default_accept_total() -> u64 {
681 104_857_600
682}
683fn default_approval_threshold() -> u64 {
684 10_485_760
685}
686fn default_reject_paths() -> Vec<String> {
687 vec!["~/.ssh".into(), "~/.aws".into(), "~/.gnupg".into()]
688}
689fn default_allowed_mime() -> Vec<String> {
690 vec!["*".into()]
691}
692
693#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
694#[serde(rename_all = "snake_case")]
695pub enum DeploymentType {
696 #[default]
697 Laptop,
698 Vm,
699 Docker,
700 K8s,
701 Lambda,
702}
703
704#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
705pub struct DeploymentConfig {
706 #[serde(rename = "type", default)]
707 pub deployment_type: DeploymentType,
708 #[serde(default, skip_serializing_if = "Option::is_none")]
709 pub region: Option<String>,
710 #[serde(default = "default_env")]
711 pub environment: Option<String>,
712}
713
714impl Default for DeploymentConfig {
715 fn default() -> Self {
716 Self {
717 deployment_type: DeploymentType::default(),
718 region: None,
719 environment: default_env(),
720 }
721 }
722}
723
724fn default_env() -> Option<String> {
725 Some("dev".into())
726}
727
728#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
729pub struct LockFile {
730 pub schema: u32,
731 pub uuid: String,
732 pub name: String,
733 pub pid: u32,
734 pub ppid: u32,
735 pub started_at: String,
736 pub binary_version: String,
737 pub transports: LockTransports,
738 pub card_digest: String,
739 pub capabilities: Vec<String>,
740}
741
742#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
743pub struct LockTransports {
744 pub stdio: bool,
745 #[serde(default)]
746 pub unix_socket: Option<String>,
747 #[serde(default)]
748 pub tcp: Option<String>,
749 #[serde(default)]
754 pub webhook: Option<String>,
755}
756
757#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
764#[serde(rename_all = "snake_case")]
765pub enum VoiceId {
766 #[default]
768 AfHeart,
769 AfBella,
770 AfNicole,
771 AmAdam,
772 AmMichael,
773}
774
775impl VoiceId {
776 pub fn style_index(&self) -> usize {
778 match self {
779 VoiceId::AfHeart => 0,
780 VoiceId::AfBella => 1,
781 VoiceId::AfNicole => 2,
782 VoiceId::AmAdam => 3,
783 VoiceId::AmMichael => 4,
784 }
785 }
786
787 pub fn as_str(&self) -> &'static str {
789 match self {
790 VoiceId::AfHeart => "af_heart",
791 VoiceId::AfBella => "af_bella",
792 VoiceId::AfNicole => "af_nicole",
793 VoiceId::AmAdam => "am_adam",
794 VoiceId::AmMichael => "am_michael",
795 }
796 }
797}
798
799impl std::str::FromStr for VoiceId {
800 type Err = anyhow::Error;
801
802 fn from_str(s: &str) -> anyhow::Result<Self> {
803 match s {
804 "af_heart" => Ok(VoiceId::AfHeart),
805 "af_bella" => Ok(VoiceId::AfBella),
806 "af_nicole" => Ok(VoiceId::AfNicole),
807 "am_adam" => Ok(VoiceId::AmAdam),
808 "am_michael" => Ok(VoiceId::AmMichael),
809 other => anyhow::bail!(
810 "unknown voice ID '{other}' \
811 (valid: af_heart, af_bella, af_nicole, am_adam, am_michael)"
812 ),
813 }
814 }
815}
816
817#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
820pub struct VoiceConfig {
821 #[serde(default)]
823 pub enabled: bool,
824 #[serde(default)]
826 pub voice_id: VoiceId,
827 #[serde(default, skip_serializing_if = "Option::is_none")]
830 pub input_device: Option<String>,
831}
832
833#[derive(Debug, Default, Clone, PartialEq, Serialize, Deserialize)]
839pub struct CompanionConfig {
840 #[serde(default)]
841 pub enabled: bool,
842 #[serde(default = "default_locale")]
843 pub locale: String,
844 #[serde(default)]
845 pub relationship: Relationship,
846 #[serde(default)]
847 pub voice_overrides: VoiceOverrides,
848 #[serde(default)]
849 pub onboarding: OnboardingState,
850 #[serde(default)]
851 pub rhythm: RhythmConfig,
852 #[serde(default)]
853 pub proactive: ProactiveConfig,
854}
855
856pub fn default_locale() -> String {
859 std::env::var("LANG")
860 .ok()
861 .and_then(|v| v.split('.').next().map(|s| s.replace('_', "-")))
862 .unwrap_or_else(|| "en-US".into())
863}
864
865#[derive(Debug, Default, Clone, PartialEq, Serialize, Deserialize)]
866pub struct VoiceOverrides {
867 #[serde(default, skip_serializing_if = "Option::is_none")]
868 pub name_for_user: Option<String>,
869 #[serde(default, skip_serializing_if = "Option::is_none")]
870 pub formality: Option<Formality>,
871 #[serde(default, skip_serializing_if = "Option::is_none")]
872 pub extra_instructions: Option<String>,
873}
874
875#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
876pub struct FirstMemory {
877 pub text: String,
878 pub established_at: chrono::DateTime<chrono::Utc>,
879}
880
881#[derive(Debug, Default, Clone, PartialEq, Serialize, Deserialize)]
882pub struct OnboardingState {
883 #[serde(default, skip_serializing_if = "Option::is_none")]
884 pub completed_at: Option<chrono::DateTime<chrono::Utc>>,
885 #[serde(default)]
886 pub version: u32,
887 #[serde(default, skip_serializing_if = "Option::is_none")]
888 pub agent_display_name: Option<String>,
889 #[serde(default, skip_serializing_if = "Option::is_none")]
890 pub first_memory: Option<FirstMemory>,
891}
892
893#[derive(Debug, Default, Clone, PartialEq, Serialize, Deserialize)]
896pub struct RhythmConfig {
897 #[serde(default)]
898 pub enabled: bool,
899}
900
901#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
902pub struct ProactiveConfig {
903 #[serde(default)]
904 pub enabled: bool,
905 #[serde(default, skip_serializing_if = "Option::is_none")]
907 pub learning_until: Option<chrono::DateTime<chrono::Utc>>,
908 #[serde(default, skip_serializing_if = "Option::is_none")]
909 pub quiet_hours: Option<QuietHours>,
910 #[serde(default, skip_serializing_if = "Option::is_none")]
911 pub active_hours: Option<ActiveHours>,
912 #[serde(default = "default_daily_cap")]
913 pub daily_cap: u8,
914 #[serde(default = "default_channels")]
915 pub channels: Vec<String>,
916 #[serde(default, skip_serializing_if = "Option::is_none")]
917 pub paused_until: Option<chrono::DateTime<chrono::Utc>>,
918}
919
920impl Default for ProactiveConfig {
921 fn default() -> Self {
922 Self {
923 enabled: false,
924 learning_until: None,
925 quiet_hours: None,
926 active_hours: None,
927 daily_cap: default_daily_cap(),
928 channels: default_channels(),
929 paused_until: None,
930 }
931 }
932}
933
934fn default_daily_cap() -> u8 {
935 3
936}
937fn default_channels() -> Vec<String> {
938 vec!["stdout".into()]
939}
940
941#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
942pub struct QuietHours {
943 pub start: String,
944 pub end: String,
945}
946
947#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
948pub struct ActiveHours {
949 pub start: String,
950 pub end: String,
951}
952
953#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
958pub struct AgentAppearance {
959 #[serde(default = "default_style_preset")]
961 pub style_preset: String,
962 #[serde(default)]
963 pub behavior_preset: BehaviorPreset,
964 #[serde(default, skip_serializing_if = "Option::is_none")]
966 pub source_image_path: Option<std::path::PathBuf>,
967 #[serde(default = "default_expressions_dir")]
969 pub expressions_dir: std::path::PathBuf,
970 #[serde(default, skip_serializing_if = "Option::is_none")]
971 pub last_rendered_at: Option<chrono::DateTime<chrono::Utc>>,
972 #[serde(default)]
973 pub render_status: RenderStatus,
974}
975
976fn default_style_preset() -> String {
977 "default-blob".into()
978}
979
980fn default_expressions_dir() -> std::path::PathBuf {
981 std::path::PathBuf::from("expressions")
982}
983
984impl Default for AgentAppearance {
985 fn default() -> Self {
986 Self {
987 style_preset: default_style_preset(),
988 behavior_preset: BehaviorPreset::Normal,
989 source_image_path: None,
990 expressions_dir: default_expressions_dir(),
991 last_rendered_at: None,
992 render_status: RenderStatus::Pending,
993 }
994 }
995}
996
997#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
998#[serde(rename_all = "snake_case")]
999pub enum BehaviorPreset {
1000 Quiet,
1001 #[default]
1002 Normal,
1003 Lively,
1004}
1005
1006#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
1007#[serde(tag = "status", rename_all = "snake_case")]
1008pub enum RenderStatus {
1009 #[default]
1010 Pending,
1011 Rendering {
1012 done: u8,
1013 total: u8,
1014 },
1015 Ready,
1016 Failed {
1017 reason: String,
1018 },
1019}
1020
1021#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
1027#[serde(rename_all = "kebab-case")]
1028pub enum SnapshotPolicy {
1029 #[default]
1030 PullOnStart,
1031 PullPeriodic,
1032 Manual,
1033}
1034
1035#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
1037pub struct PatternFilter {
1038 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1039 pub applies_in: Vec<String>,
1040 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1041 pub tier: Vec<String>,
1042 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1043 pub maturity: Vec<String>,
1044 #[serde(default)]
1045 pub importance_min: f64,
1046 #[serde(default = "default_max_snapshot_count")]
1047 pub max_count: usize,
1048 #[serde(default)]
1049 pub snapshot_policy: SnapshotPolicy,
1050}
1051
1052fn default_max_snapshot_count() -> usize {
1053 200
1054}
1055
1056impl Default for PatternFilter {
1057 fn default() -> Self {
1058 Self {
1059 applies_in: vec![],
1060 tier: vec![],
1061 maturity: vec![],
1062 importance_min: 0.0,
1063 max_count: 200,
1064 snapshot_policy: SnapshotPolicy::default(),
1065 }
1066 }
1067}
1068
1069#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
1071pub struct SnapshotRef {
1072 pub knowledge_commit: String,
1073 pub taken_at: String,
1074 pub filter: PatternFilter,
1075}
1076
1077#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
1079pub struct FederationConfig {
1080 #[serde(default)]
1081 pub filter: PatternFilter,
1082 #[serde(default, skip_serializing_if = "Option::is_none")]
1083 pub snapshot_ref: Option<SnapshotRef>,
1084 #[serde(default)]
1085 pub evidence_flush_interval_minutes: u32,
1086}
1087
1088impl AgentProfile {
1089 #[doc(hidden)]
1095 pub fn default_for_tests() -> Self {
1096 serde_yaml_ng::from_str(include_str!("../tests/fixtures/minimal_profile.yaml"))
1097 .expect("minimal profile fixture")
1098 }
1099}
1100
1101#[cfg(test)]
1102mod tests {
1103 use super::*;
1104
1105 #[test]
1106 fn profile_round_trip_yaml() {
1107 let yaml = r#"
1108schema: 1
1109id: 01JQX4TM8Y9K7VQH6B2N3R5DPE
1110name: agent_a
1111display_name: "Price Hunter"
1112version: "0.1.0"
1113persona:
1114 category: research
1115 description: "Finds prices"
1116 traits: { tone: concise, risk: cautious, verbosity: low }
1117sys_prompt_file: "sys_prompt.md"
1118model: { provider: ollama, name: "llama3.2:3b", params: { temperature: 0.2, max_tokens: 4096 } }
1119mcp_servers: []
1120skills: []
1121transport:
1122 stdio: true
1123 socket: { enabled: true, bind: "unix:///tmp/a.sock" }
1124communication: { accepts_from: ["*"], sends_to: [] }
1125capabilities: ["a2a.message.send", "a2a.tasks"]
1126entitlements:
1127 network:
1128 inbound: { ports: [] }
1129 outbound: { mode: restricted, allow_hosts: [], protocols: ["tcp"], resolve_dns: { mode: system } }
1130 filesystem: { read: [], write: [], deny: [] }
1131 processes: { spawn: { mode: allowlist, allowed: [] } }
1132 syscalls: { mode: default }
1133 limits: { memory_mb: 512, file_descriptors: 1024, processes: 32 }
1134notifications: { on_task_complete: [], on_error: [], on_shutdown: [] }
1135retry:
1136 llm: { max_retries: 3, backoff: exponential, initial_delay_ms: 1000, max_delay_ms: 30000, retry_on: [rate_limit, timeout, connection_error] }
1137 tool: { max_retries: 1, backoff: fixed, initial_delay_ms: 500 }
1138lifecycle: { restart: on_failure, max_restarts: 3, restart_window_secs: 600, stop_timeout_secs: 15, mcp_required: true }
1139created_at: "2026-04-22T10:00:00+08:00"
1140updated_at: "2026-04-22T10:00:00+08:00"
1141"#;
1142 let profile: AgentProfile = serde_yaml_ng::from_str(yaml).expect("parse");
1143 assert_eq!(profile.name, "agent_a");
1144 assert_eq!(profile.persona.category, PersonaCategory::Research);
1145 assert_eq!(
1146 profile.entitlements.network.outbound.mode,
1147 NetworkOutboundMode::Restricted
1148 );
1149 let reserialized = serde_yaml_ng::to_string(&profile).expect("emit");
1150 let round_tripped: AgentProfile = serde_yaml_ng::from_str(&reserialized).expect("re-parse");
1151 assert_eq!(profile.id, round_tripped.id);
1152 }
1153}
1154
1155#[cfg(test)]
1156mod model_ref_tests {
1157 use super::*;
1158
1159 #[test]
1160 fn legacy_profile_without_model_ref_still_parses() {
1161 let yaml = include_str!("../tests/fixtures/profile_p0a_minimal.yaml");
1162 let p: AgentProfile = serde_yaml_ng::from_str(yaml).unwrap();
1163 assert!(
1164 p.model_ref.is_none(),
1165 "legacy profile must not have model_ref"
1166 );
1167 }
1168
1169 #[test]
1170 fn round_trip_with_model_ref_preserves_field() {
1171 let yaml = include_str!("../tests/fixtures/profile_p0a_minimal.yaml");
1172 let mut p: AgentProfile = serde_yaml_ng::from_str(yaml).unwrap();
1173 p.model_ref = Some("anthropic_opus_4_7".into());
1174 let s = serde_yaml_ng::to_string(&p).unwrap();
1175 assert!(s.contains("model_ref: anthropic_opus_4_7"), "yaml: {s}");
1176 let p2: AgentProfile = serde_yaml_ng::from_str(&s).unwrap();
1177 assert_eq!(p2.model_ref.as_deref(), Some("anthropic_opus_4_7"));
1178 }
1179}
1180
1181#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1189#[serde(rename_all = "snake_case")]
1190pub enum ProactiveTier {
1191 Off,
1192 WarmOnly,
1193 WarmAndBehavior,
1194 All,
1195}
1196
1197impl ProactiveTier {
1198 pub fn from_config(c: &CompanionConfig) -> Self {
1199 match (c.enabled, c.rhythm.enabled, c.proactive.enabled) {
1200 (false, _, _) => Self::Off,
1201 (true, false, false) => Self::WarmOnly,
1202 (true, true, false) => Self::WarmAndBehavior,
1203 (true, _, true) => Self::All,
1204 }
1205 }
1206
1207 pub fn apply(&self, c: &mut CompanionConfig) {
1208 match self {
1209 Self::Off => {
1210 c.enabled = false;
1211 c.rhythm.enabled = false;
1212 c.proactive.enabled = false;
1213 }
1214 Self::WarmOnly => {
1215 c.enabled = true;
1216 c.rhythm.enabled = false;
1217 c.proactive.enabled = false;
1218 }
1219 Self::WarmAndBehavior => {
1220 c.enabled = true;
1221 c.rhythm.enabled = true;
1222 c.proactive.enabled = false;
1223 }
1224 Self::All => {
1225 c.enabled = true;
1226 c.rhythm.enabled = true;
1227 c.proactive.enabled = true;
1228 }
1229 }
1230 }
1231}
1232
1233#[cfg(test)]
1234mod mcp_pin_tests {
1235 use super::*;
1236
1237 #[test]
1241 fn pre_m9_entry_roundtrips_without_pin_fields() {
1242 let yaml = r#"
1243name: weather
1244command: /opt/mcp/weather
1245args: ["--port", "0"]
1246"#;
1247 let entry: McpServerEntry = serde_yaml_ng::from_str(yaml).unwrap();
1248 assert_eq!(entry.name, "weather");
1249 assert_eq!(entry.binary_sha256, None);
1250 assert_eq!(entry.description_hash, None);
1251 assert_eq!(entry.publisher, None);
1252 assert_eq!(entry.installed_at, None);
1253
1254 let out = serde_yaml_ng::to_string(&entry).unwrap();
1257 assert!(!out.contains("binary_sha256"), "got {out}");
1258 assert!(!out.contains("description_hash"), "got {out}");
1259 assert!(!out.contains("publisher"), "got {out}");
1260 assert!(!out.contains("installed_at"), "got {out}");
1261 }
1262
1263 #[test]
1265 fn full_m9_entry_roundtrips_all_fields() {
1266 let yaml = r#"
1267name: weather
1268command: /opt/mcp/weather
1269args: []
1270binary_sha256: "3f4abca8b0e6e2c1d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b81c"
1271description_hash: "9a01b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9c7e2"
1272publisher:
1273 name: "@anthropic-mcp/weather"
1274 homepage: "https://github.com/anthropic-mcp/weather"
1275 registry_id: "@anthropic-mcp/weather@1.2.3"
1276installed_at: "2026-05-06T08:00:00Z"
1277"#;
1278 let entry: McpServerEntry = serde_yaml_ng::from_str(yaml).unwrap();
1279 assert!(
1280 entry
1281 .binary_sha256
1282 .as_deref()
1283 .unwrap()
1284 .starts_with("3f4abca8")
1285 );
1286 assert!(
1287 entry
1288 .description_hash
1289 .as_deref()
1290 .unwrap()
1291 .starts_with("9a01b2c3")
1292 );
1293 let pub_info = entry.publisher.clone().unwrap();
1294 assert_eq!(pub_info.name, "@anthropic-mcp/weather");
1295 assert_eq!(
1296 pub_info.homepage.as_deref(),
1297 Some("https://github.com/anthropic-mcp/weather"),
1298 );
1299 assert_eq!(
1300 pub_info.registry_id.as_deref(),
1301 Some("@anthropic-mcp/weather@1.2.3"),
1302 );
1303 let installed = entry.installed_at.unwrap();
1304 assert_eq!(installed.to_rfc3339(), "2026-05-06T08:00:00+00:00");
1305 }
1306
1307 #[test]
1311 fn partial_pin_only_binary_sha_roundtrips() {
1312 let yaml = r#"
1313name: weather
1314command: /opt/mcp/weather
1315args: []
1316binary_sha256: "deadbeef00112233445566778899aabbccddeeff00112233445566778899aabb"
1317"#;
1318 let entry: McpServerEntry = serde_yaml_ng::from_str(yaml).unwrap();
1319 assert_eq!(
1320 entry.binary_sha256.as_deref(),
1321 Some("deadbeef00112233445566778899aabbccddeeff00112233445566778899aabb"),
1322 );
1323 assert_eq!(entry.description_hash, None);
1324 assert_eq!(entry.publisher, None);
1325 }
1326
1327 #[test]
1330 fn publisher_minimal_just_name() {
1331 let yaml = r#"
1332name: weather
1333command: /opt/mcp/weather
1334args: []
1335publisher:
1336 name: "alice"
1337"#;
1338 let entry: McpServerEntry = serde_yaml_ng::from_str(yaml).unwrap();
1339 let p = entry.publisher.as_ref().unwrap();
1340 assert_eq!(p.name, "alice");
1341 assert_eq!(p.homepage, None);
1342 assert_eq!(p.registry_id, None);
1343
1344 let out = serde_yaml_ng::to_string(&entry).unwrap();
1346 assert!(!out.contains("homepage:"), "got {out}");
1347 assert!(!out.contains("registry_id:"), "got {out}");
1348 }
1349}
1350
1351#[cfg(test)]
1352mod voice_tests {
1353 use super::*;
1354 use std::str::FromStr;
1355
1356 #[test]
1357 fn voice_config_round_trips() {
1358 let base = include_str!("../tests/fixtures/profile_p0a_minimal.yaml");
1360 let yaml = format!("{base}voice:\n enabled: true\n voice_id: af_bella\n");
1361
1362 let profile: AgentProfile = serde_yaml_ng::from_str(&yaml).expect("parse with voice");
1363 assert!(profile.voice.enabled);
1364 assert_eq!(profile.voice.voice_id, VoiceId::AfBella);
1365
1366 let legacy: AgentProfile = serde_yaml_ng::from_str(base).expect("parse without voice");
1368 assert!(!legacy.voice.enabled);
1369 assert_eq!(legacy.voice.voice_id, VoiceId::AfHeart);
1370 }
1371
1372 #[test]
1373 fn voice_id_from_str_roundtrips() {
1374 let cases = [
1375 ("af_heart", VoiceId::AfHeart),
1376 ("af_bella", VoiceId::AfBella),
1377 ("af_nicole", VoiceId::AfNicole),
1378 ("am_adam", VoiceId::AmAdam),
1379 ("am_michael", VoiceId::AmMichael),
1380 ];
1381 for (s, expected) in cases {
1382 assert_eq!(VoiceId::from_str(s).unwrap(), expected);
1383 assert_eq!(expected.as_str(), s);
1384 }
1385 }
1386
1387 #[test]
1388 fn voice_id_from_str_rejects_unknown() {
1389 assert!(VoiceId::from_str("bogus").is_err());
1390 }
1391}
1392
1393#[cfg(test)]
1394mod idle_trigger_tests {
1395 use super::*;
1396
1397 #[test]
1398 fn idle_trigger_yaml_round_trip() {
1399 let yaml = r#"
1400restart: on_failure
1401idle_triggers:
1402 - after_secs: 3600
1403 message: "still there?"
1404 sends_to: other_agent
1405 cooldown_secs: 1800
1406 respect_quiet_hours: true
1407"#;
1408 let cfg: LifecycleConfig = serde_yaml_ng::from_str(yaml).unwrap();
1409 assert_eq!(cfg.idle_triggers.len(), 1);
1410 assert_eq!(cfg.idle_triggers[0].after_secs, 3600);
1411 assert_eq!(cfg.idle_triggers[0].message, "still there?");
1412 assert_eq!(
1413 cfg.idle_triggers[0].sends_to.as_deref(),
1414 Some("other_agent")
1415 );
1416 assert_eq!(cfg.idle_triggers[0].cooldown_secs, 1800);
1417 assert!(cfg.idle_triggers[0].respect_quiet_hours);
1418 }
1419
1420 #[test]
1421 fn idle_trigger_defaults_when_omitted() {
1422 let yaml = "restart: on_failure\n";
1423 let cfg: LifecycleConfig = serde_yaml_ng::from_str(yaml).unwrap();
1424 assert!(cfg.idle_triggers.is_empty());
1425 }
1426}
1427
1428#[cfg(test)]
1429mod appearance_tests {
1430 use super::*;
1431
1432 #[test]
1433 fn appearance_default_style_preset_is_default_blob() {
1434 assert_eq!(AgentAppearance::default().style_preset, "default-blob");
1435 }
1436
1437 #[test]
1438 fn appearance_default_behavior_is_normal() {
1439 assert_eq!(
1440 AgentAppearance::default().behavior_preset,
1441 BehaviorPreset::Normal
1442 );
1443 }
1444
1445 #[test]
1446 fn appearance_default_render_status_is_pending() {
1447 assert_eq!(
1448 AgentAppearance::default().render_status,
1449 RenderStatus::Pending
1450 );
1451 }
1452
1453 #[test]
1454 fn render_status_serde_round_trip() {
1455 let cases = [
1456 RenderStatus::Pending,
1457 RenderStatus::Rendering { done: 3, total: 12 },
1458 RenderStatus::Ready,
1459 RenderStatus::Failed {
1460 reason: "out of quota".into(),
1461 },
1462 ];
1463 for status in cases {
1464 let yaml = serde_yaml_ng::to_string(&status).expect("serialize");
1465 let back: RenderStatus = serde_yaml_ng::from_str(&yaml).expect("deserialize");
1466 assert_eq!(status, back);
1467 }
1468 }
1469
1470 #[test]
1471 fn agent_profile_with_appearance_round_trips() {
1472 let base = include_str!("../tests/fixtures/profile_p0a_minimal.yaml");
1473 let yaml = format!(
1474 "{base}appearance:\n style_preset: chiikawa\n render_status:\n status: ready\n"
1475 );
1476 let profile: AgentProfile = serde_yaml_ng::from_str(&yaml).expect("parse with appearance");
1477 assert_eq!(profile.appearance.style_preset, "chiikawa");
1478 assert_eq!(profile.appearance.render_status, RenderStatus::Ready);
1479
1480 let out = serde_yaml_ng::to_string(&profile).expect("serialize");
1481 let back: AgentProfile = serde_yaml_ng::from_str(&out).expect("re-parse");
1482 assert_eq!(profile.appearance, back.appearance);
1483 }
1484
1485 #[test]
1486 fn legacy_profile_without_appearance_uses_default() {
1487 let yaml = include_str!("../tests/fixtures/profile_p0a_minimal.yaml");
1488 let profile: AgentProfile = serde_yaml_ng::from_str(yaml).expect("parse legacy");
1489 assert_eq!(profile.appearance.style_preset, "default-blob");
1490 assert_eq!(profile.appearance.behavior_preset, BehaviorPreset::Normal);
1491 assert_eq!(profile.appearance.render_status, RenderStatus::Pending);
1492 }
1493}
1494
1495#[cfg(test)]
1496mod federation_tests {
1497 use super::*;
1498
1499 #[test]
1500 fn test_pattern_filter_default() {
1501 let f = PatternFilter::default();
1502 assert_eq!(f.max_count, 200);
1503 assert_eq!(f.importance_min, 0.0);
1504 assert!(f.tier.is_empty());
1505 }
1506
1507 #[test]
1508 fn test_federation_config_roundtrip() {
1509 let cfg = FederationConfig {
1510 filter: PatternFilter {
1511 tier: vec!["core".into()],
1512 max_count: 50,
1513 ..Default::default()
1514 },
1515 snapshot_ref: Some(SnapshotRef {
1516 knowledge_commit: "abc123def456".into(),
1517 taken_at: "2026-05-19T00:00:00Z".into(),
1518 filter: PatternFilter::default(),
1519 }),
1520 evidence_flush_interval_minutes: 15,
1521 };
1522 let yaml = serde_yaml_ng::to_string(&cfg).unwrap();
1523 let back: FederationConfig = serde_yaml_ng::from_str(&yaml).unwrap();
1524 assert_eq!(cfg, back);
1525 }
1526
1527 #[test]
1528 fn test_agent_profile_federation_defaults() {
1529 let cfg = FederationConfig::default();
1533 assert_eq!(cfg.evidence_flush_interval_minutes, 0);
1534 assert!(cfg.snapshot_ref.is_none());
1535 }
1536}
1537
1538#[cfg(test)]
1539mod skill_card_tests {
1540 use super::*;
1541
1542 #[test]
1543 fn installed_skills_default_to_empty_when_absent() {
1544 let yaml = include_str!("../tests/fixtures/profile_p0a_minimal.yaml");
1545 let p: AgentProfile = serde_yaml_ng::from_str(yaml).unwrap();
1546 assert!(p.installed_skills.is_empty());
1547 }
1548
1549 #[test]
1550 fn installed_skills_roundtrip_preserves_entries() {
1551 let base = include_str!("../tests/fixtures/profile_p0a_minimal.yaml");
1552 let yaml = format!(
1553 "{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"
1554 );
1555 let p: AgentProfile = serde_yaml_ng::from_str(&yaml).unwrap();
1556 assert_eq!(p.installed_skills.len(), 1);
1557 assert_eq!(p.installed_skills[0].name, "s1");
1558 assert_eq!(p.installed_skills[0].abstract_text, "does things");
1559 assert_eq!(p.installed_skills[0].transfer_chain, vec!["agent://alice"]);
1560
1561 let out = serde_yaml_ng::to_string(&p).unwrap();
1562 assert!(out.contains("abstract: does things"));
1563 assert!(out.contains("pattern: /find"));
1564
1565 let back: AgentProfile = serde_yaml_ng::from_str(&out).unwrap();
1566 assert_eq!(p.installed_skills, back.installed_skills);
1567 }
1568
1569 #[test]
1570 fn installed_skills_minimal_entry_serializes_compactly() {
1571 let entry = SkillCardEntry {
1573 name: "minimal".into(),
1574 ..Default::default()
1575 };
1576 let yaml = serde_yaml_ng::to_string(&entry).unwrap();
1577 assert!(yaml.contains("name: minimal"));
1578 assert!(
1579 !yaml.contains("version:"),
1580 "empty version must be skipped: {yaml}"
1581 );
1582 assert!(
1583 !yaml.contains("publisher:"),
1584 "empty publisher must be skipped: {yaml}"
1585 );
1586 assert!(
1587 !yaml.contains("abstract:"),
1588 "empty abstract must be skipped: {yaml}"
1589 );
1590 }
1591}