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