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}
396
397#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
398pub struct NetworkEntitlement {
399 pub inbound: InboundNetwork,
400 pub outbound: OutboundNetwork,
401}
402
403#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
404pub struct InboundNetwork {
405 #[serde(default)]
406 pub ports: Vec<u16>,
407}
408
409#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
410pub struct OutboundNetwork {
411 pub mode: NetworkOutboundMode,
412 #[serde(default)]
413 pub allow_hosts: Vec<String>,
414 #[serde(default = "default_protocols")]
415 pub protocols: Vec<String>,
416 #[serde(default)]
417 pub resolve_dns: ResolveDnsConfig,
418}
419fn default_protocols() -> Vec<String> {
420 vec!["tcp".to_string()]
421}
422
423#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
424#[serde(rename_all = "lowercase")]
425pub enum NetworkOutboundMode {
426 Unrestricted,
427 Restricted,
428 Off,
429}
430
431#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
432pub struct ResolveDnsConfig {
433 #[serde(default = "default_dns_mode")]
434 pub mode: String,
435 #[serde(default)]
436 pub servers: Vec<String>,
437}
438impl Default for ResolveDnsConfig {
439 fn default() -> Self {
440 Self {
441 mode: default_dns_mode(),
442 servers: vec![],
443 }
444 }
445}
446fn default_dns_mode() -> String {
447 "system".to_string()
448}
449
450#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
451pub struct FilesystemEntitlement {
452 #[serde(default)]
453 pub read: Vec<String>,
454 #[serde(default)]
455 pub write: Vec<String>,
456 #[serde(default)]
457 pub deny: Vec<String>,
458}
459
460#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
461pub struct ProcessesEntitlement {
462 pub spawn: SpawnEntitlement,
463}
464
465#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
466pub struct SpawnEntitlement {
467 pub mode: SpawnMode,
468 #[serde(default)]
469 pub allowed: Vec<String>,
470}
471
472#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
473#[serde(rename_all = "lowercase")]
474pub enum SpawnMode {
475 Allowlist,
476 Any,
477 None,
478}
479
480#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
481pub struct SyscallsEntitlement {
482 #[serde(default = "default_syscalls_mode")]
483 pub mode: String,
484 #[serde(default)]
485 pub extra_deny: Vec<String>,
486}
487fn default_syscalls_mode() -> String {
488 "default".to_string()
489}
490
491#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
492pub struct LimitsEntitlement {
493 #[serde(default)]
494 pub cpu_seconds: Option<u64>,
495 #[serde(default = "default_memory_mb")]
496 pub memory_mb: u64,
497 #[serde(default = "default_fds")]
498 pub file_descriptors: u32,
499 #[serde(default = "default_procs")]
500 pub processes: u32,
501}
502fn default_memory_mb() -> u64 {
503 512
504}
505fn default_fds() -> u32 {
506 1024
507}
508fn default_procs() -> u32 {
509 32
510}
511
512#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
513pub struct NotificationsConfig {
514 #[serde(default)]
515 pub on_task_complete: Vec<NotificationTarget>,
516 #[serde(default)]
517 pub on_error: Vec<NotificationTarget>,
518 #[serde(default)]
519 pub on_shutdown: Vec<NotificationTarget>,
520}
521
522#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
523#[serde(tag = "target", rename_all = "lowercase")]
524pub enum NotificationTarget {
525 Agent {
526 name: String,
527 },
528 Commander,
529 Email {
530 address: String,
531 #[serde(default)]
532 smtp_config_file: Option<String>,
533 },
534 Slack {
535 #[serde(default)]
536 channel: Option<String>,
537 #[serde(default)]
538 webhook_url_env: Option<String>,
539 },
540 Webpush {
541 url: String,
542 },
543 Webhook {
544 url: String,
545 #[serde(default = "default_post")]
546 method: String,
547 #[serde(default)]
548 auth: Option<String>,
549 },
550}
551fn default_post() -> String {
552 "POST".to_string()
553}
554
555#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
556pub struct RetryConfig {
557 pub llm: RetryPolicy,
558 pub tool: RetryPolicy,
559}
560
561#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
562pub struct RetryPolicy {
563 pub max_retries: u32,
564 pub backoff: BackoffStrategy,
565 pub initial_delay_ms: u64,
566 #[serde(default)]
567 pub max_delay_ms: Option<u64>,
568 #[serde(default)]
569 pub retry_on: Vec<String>,
570}
571
572#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
573#[serde(rename_all = "lowercase")]
574pub enum BackoffStrategy {
575 Linear,
576 Exponential,
577 Fixed,
578}
579
580#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
581pub struct LifecycleConfig {
582 pub restart: RestartPolicy,
583 #[serde(default = "default_max_restarts")]
584 pub max_restarts: u32,
585 #[serde(default = "default_window")]
586 pub restart_window_secs: u64,
587 #[serde(default = "default_stop_timeout")]
588 pub stop_timeout_secs: u64,
589 #[serde(default = "default_mcp_required")]
590 pub mcp_required: bool,
591 #[serde(default)]
592 pub execution: ExecutionMode,
593 #[serde(default)]
594 pub schedule: Vec<ScheduleEntry>,
595 #[serde(default)]
596 pub idle_triggers: Vec<IdleTrigger>,
597}
598fn default_max_restarts() -> u32 {
599 3
600}
601fn default_window() -> u64 {
602 600
603}
604fn default_stop_timeout() -> u64 {
605 15
606}
607fn default_mcp_required() -> bool {
608 true
609}
610
611#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
612#[serde(rename_all = "snake_case")]
613pub enum RestartPolicy {
614 Never,
615 OnFailure,
616 Always,
617}
618
619#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
620#[serde(rename_all = "snake_case")]
621pub enum ExecutionMode {
622 #[default]
623 Daemon,
624 OnDemand,
625}
626
627#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
628pub struct ScheduleEntry {
629 pub cron: String,
630 pub message: String,
631 #[serde(default, skip_serializing_if = "Option::is_none")]
632 pub sends_to: Option<String>,
633}
634
635#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
636pub struct IdleTrigger {
637 pub after_secs: u64,
639 pub message: String,
641 #[serde(default, skip_serializing_if = "Option::is_none")]
643 pub sends_to: Option<String>,
644 #[serde(default = "default_idle_cooldown")]
647 pub cooldown_secs: u64,
648 #[serde(default = "default_true")]
651 pub respect_quiet_hours: bool,
652}
653
654fn default_idle_cooldown() -> u64 {
655 600
656}
657fn default_true() -> bool {
658 true
659}
660
661#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
662pub struct FileTransferConfig {
663 #[serde(default = "default_accept_max")]
664 pub accept_incoming_file_max_bytes: u64,
665 #[serde(default = "default_accept_total")]
666 pub accept_incoming_total_per_hour: u64,
667 #[serde(default = "default_approval_threshold")]
668 pub require_approval_above_bytes: u64,
669 #[serde(default = "default_reject_paths")]
670 pub reject_paths: Vec<String>,
671 #[serde(default = "default_allowed_mime")]
672 pub allowed_mime_types: Vec<String>,
673}
674
675impl Default for FileTransferConfig {
676 fn default() -> Self {
677 Self {
678 accept_incoming_file_max_bytes: default_accept_max(),
679 accept_incoming_total_per_hour: default_accept_total(),
680 require_approval_above_bytes: default_approval_threshold(),
681 reject_paths: default_reject_paths(),
682 allowed_mime_types: default_allowed_mime(),
683 }
684 }
685}
686
687fn default_accept_max() -> u64 {
688 10_485_760
689}
690fn default_accept_total() -> u64 {
691 104_857_600
692}
693fn default_approval_threshold() -> u64 {
694 10_485_760
695}
696fn default_reject_paths() -> Vec<String> {
697 vec!["~/.ssh".into(), "~/.aws".into(), "~/.gnupg".into()]
698}
699fn default_allowed_mime() -> Vec<String> {
700 vec!["*".into()]
701}
702
703#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
704#[serde(rename_all = "snake_case")]
705pub enum DeploymentType {
706 #[default]
707 Laptop,
708 Vm,
709 Docker,
710 K8s,
711 Lambda,
712}
713
714#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
715pub struct DeploymentConfig {
716 #[serde(rename = "type", default)]
717 pub deployment_type: DeploymentType,
718 #[serde(default, skip_serializing_if = "Option::is_none")]
719 pub region: Option<String>,
720 #[serde(default = "default_env")]
721 pub environment: Option<String>,
722}
723
724impl Default for DeploymentConfig {
725 fn default() -> Self {
726 Self {
727 deployment_type: DeploymentType::default(),
728 region: None,
729 environment: default_env(),
730 }
731 }
732}
733
734fn default_env() -> Option<String> {
735 Some("dev".into())
736}
737
738#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
739pub struct LockFile {
740 pub schema: u32,
741 pub uuid: String,
742 pub name: String,
743 pub pid: u32,
744 pub ppid: u32,
745 pub started_at: String,
746 pub binary_version: String,
747 pub transports: LockTransports,
748 pub card_digest: String,
749 pub capabilities: Vec<String>,
750}
751
752#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
753pub struct LockTransports {
754 pub stdio: bool,
755 #[serde(default)]
756 pub unix_socket: Option<String>,
757 #[serde(default)]
758 pub tcp: Option<String>,
759 #[serde(default)]
764 pub webhook: Option<String>,
765}
766
767#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
774#[serde(rename_all = "snake_case")]
775pub enum VoiceId {
776 #[default]
778 AfHeart,
779 AfBella,
780 AfNicole,
781 AmAdam,
782 AmMichael,
783}
784
785impl VoiceId {
786 pub fn style_index(&self) -> usize {
788 match self {
789 VoiceId::AfHeart => 0,
790 VoiceId::AfBella => 1,
791 VoiceId::AfNicole => 2,
792 VoiceId::AmAdam => 3,
793 VoiceId::AmMichael => 4,
794 }
795 }
796
797 pub fn as_str(&self) -> &'static str {
799 match self {
800 VoiceId::AfHeart => "af_heart",
801 VoiceId::AfBella => "af_bella",
802 VoiceId::AfNicole => "af_nicole",
803 VoiceId::AmAdam => "am_adam",
804 VoiceId::AmMichael => "am_michael",
805 }
806 }
807}
808
809impl std::str::FromStr for VoiceId {
810 type Err = anyhow::Error;
811
812 fn from_str(s: &str) -> anyhow::Result<Self> {
813 match s {
814 "af_heart" => Ok(VoiceId::AfHeart),
815 "af_bella" => Ok(VoiceId::AfBella),
816 "af_nicole" => Ok(VoiceId::AfNicole),
817 "am_adam" => Ok(VoiceId::AmAdam),
818 "am_michael" => Ok(VoiceId::AmMichael),
819 other => anyhow::bail!(
820 "unknown voice ID '{other}' \
821 (valid: af_heart, af_bella, af_nicole, am_adam, am_michael)"
822 ),
823 }
824 }
825}
826
827#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
830pub struct VoiceConfig {
831 #[serde(default)]
833 pub enabled: bool,
834 #[serde(default)]
836 pub voice_id: VoiceId,
837 #[serde(default, skip_serializing_if = "Option::is_none")]
840 pub input_device: Option<String>,
841}
842
843#[derive(Debug, Default, Clone, PartialEq, Serialize, Deserialize)]
849pub struct CompanionConfig {
850 #[serde(default)]
851 pub enabled: bool,
852 #[serde(default = "default_locale")]
853 pub locale: String,
854 #[serde(default)]
855 pub relationship: Relationship,
856 #[serde(default)]
857 pub voice_overrides: VoiceOverrides,
858 #[serde(default)]
859 pub onboarding: OnboardingState,
860 #[serde(default)]
861 pub rhythm: RhythmConfig,
862 #[serde(default)]
863 pub proactive: ProactiveConfig,
864}
865
866pub fn default_locale() -> String {
869 std::env::var("LANG")
870 .ok()
871 .and_then(|v| v.split('.').next().map(|s| s.replace('_', "-")))
872 .unwrap_or_else(|| "en-US".into())
873}
874
875#[derive(Debug, Default, Clone, PartialEq, Serialize, Deserialize)]
876pub struct VoiceOverrides {
877 #[serde(default, skip_serializing_if = "Option::is_none")]
878 pub name_for_user: Option<String>,
879 #[serde(default, skip_serializing_if = "Option::is_none")]
880 pub formality: Option<Formality>,
881 #[serde(default, skip_serializing_if = "Option::is_none")]
882 pub extra_instructions: Option<String>,
883}
884
885#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
886pub struct FirstMemory {
887 pub text: String,
888 pub established_at: chrono::DateTime<chrono::Utc>,
889}
890
891#[derive(Debug, Default, Clone, PartialEq, Serialize, Deserialize)]
892pub struct OnboardingState {
893 #[serde(default, skip_serializing_if = "Option::is_none")]
894 pub completed_at: Option<chrono::DateTime<chrono::Utc>>,
895 #[serde(default)]
896 pub version: u32,
897 #[serde(default, skip_serializing_if = "Option::is_none")]
898 pub agent_display_name: Option<String>,
899 #[serde(default, skip_serializing_if = "Option::is_none")]
900 pub first_memory: Option<FirstMemory>,
901}
902
903#[derive(Debug, Default, Clone, PartialEq, Serialize, Deserialize)]
906pub struct RhythmConfig {
907 #[serde(default)]
908 pub enabled: bool,
909}
910
911#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
912pub struct ProactiveConfig {
913 #[serde(default)]
914 pub enabled: bool,
915 #[serde(default, skip_serializing_if = "Option::is_none")]
917 pub learning_until: Option<chrono::DateTime<chrono::Utc>>,
918 #[serde(default, skip_serializing_if = "Option::is_none")]
919 pub quiet_hours: Option<QuietHours>,
920 #[serde(default, skip_serializing_if = "Option::is_none")]
921 pub active_hours: Option<ActiveHours>,
922 #[serde(default = "default_daily_cap")]
923 pub daily_cap: u8,
924 #[serde(default = "default_channels")]
925 pub channels: Vec<String>,
926 #[serde(default, skip_serializing_if = "Option::is_none")]
927 pub paused_until: Option<chrono::DateTime<chrono::Utc>>,
928}
929
930impl Default for ProactiveConfig {
931 fn default() -> Self {
932 Self {
933 enabled: false,
934 learning_until: None,
935 quiet_hours: None,
936 active_hours: None,
937 daily_cap: default_daily_cap(),
938 channels: default_channels(),
939 paused_until: None,
940 }
941 }
942}
943
944fn default_daily_cap() -> u8 {
945 3
946}
947fn default_channels() -> Vec<String> {
948 vec!["stdout".into()]
949}
950
951#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
952pub struct QuietHours {
953 pub start: String,
954 pub end: String,
955}
956
957#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
958pub struct ActiveHours {
959 pub start: String,
960 pub end: String,
961}
962
963#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
968pub struct AgentAppearance {
969 #[serde(default = "default_style_preset")]
971 pub style_preset: String,
972 #[serde(default)]
973 pub behavior_preset: BehaviorPreset,
974 #[serde(default, skip_serializing_if = "Option::is_none")]
976 pub source_image_path: Option<std::path::PathBuf>,
977 #[serde(default = "default_expressions_dir")]
979 pub expressions_dir: std::path::PathBuf,
980 #[serde(default, skip_serializing_if = "Option::is_none")]
981 pub last_rendered_at: Option<chrono::DateTime<chrono::Utc>>,
982 #[serde(default)]
983 pub render_status: RenderStatus,
984}
985
986fn default_style_preset() -> String {
987 "default-blob".into()
988}
989
990fn default_expressions_dir() -> std::path::PathBuf {
991 std::path::PathBuf::from("expressions")
992}
993
994impl Default for AgentAppearance {
995 fn default() -> Self {
996 Self {
997 style_preset: default_style_preset(),
998 behavior_preset: BehaviorPreset::Normal,
999 source_image_path: None,
1000 expressions_dir: default_expressions_dir(),
1001 last_rendered_at: None,
1002 render_status: RenderStatus::Pending,
1003 }
1004 }
1005}
1006
1007#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
1008#[serde(rename_all = "snake_case")]
1009pub enum BehaviorPreset {
1010 Quiet,
1011 #[default]
1012 Normal,
1013 Lively,
1014}
1015
1016#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
1017#[serde(tag = "status", rename_all = "snake_case")]
1018pub enum RenderStatus {
1019 #[default]
1020 Pending,
1021 Rendering {
1022 done: u8,
1023 total: u8,
1024 },
1025 Ready,
1026 Failed {
1027 reason: String,
1028 },
1029}
1030
1031#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
1037#[serde(rename_all = "kebab-case")]
1038pub enum SnapshotPolicy {
1039 #[default]
1040 PullOnStart,
1041 PullPeriodic,
1042 Manual,
1043}
1044
1045#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
1047pub struct PatternFilter {
1048 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1049 pub applies_in: Vec<String>,
1050 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1051 pub tier: Vec<String>,
1052 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1053 pub maturity: Vec<String>,
1054 #[serde(default)]
1055 pub importance_min: f64,
1056 #[serde(default = "default_max_snapshot_count")]
1057 pub max_count: usize,
1058 #[serde(default)]
1059 pub snapshot_policy: SnapshotPolicy,
1060}
1061
1062fn default_max_snapshot_count() -> usize {
1063 200
1064}
1065
1066impl Default for PatternFilter {
1067 fn default() -> Self {
1068 Self {
1069 applies_in: vec![],
1070 tier: vec![],
1071 maturity: vec![],
1072 importance_min: 0.0,
1073 max_count: 200,
1074 snapshot_policy: SnapshotPolicy::default(),
1075 }
1076 }
1077}
1078
1079#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
1081pub struct SnapshotRef {
1082 pub knowledge_commit: String,
1083 pub taken_at: String,
1084 pub filter: PatternFilter,
1085}
1086
1087#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
1089pub struct FederationConfig {
1090 #[serde(default)]
1091 pub filter: PatternFilter,
1092 #[serde(default, skip_serializing_if = "Option::is_none")]
1093 pub snapshot_ref: Option<SnapshotRef>,
1094 #[serde(default)]
1095 pub evidence_flush_interval_minutes: u32,
1096}
1097
1098impl AgentProfile {
1099 #[doc(hidden)]
1105 pub fn default_for_tests() -> Self {
1106 serde_yaml_ng::from_str(include_str!("../tests/fixtures/minimal_profile.yaml"))
1107 .expect("minimal profile fixture")
1108 }
1109}
1110
1111#[cfg(test)]
1112mod tests {
1113 use super::*;
1114
1115 #[test]
1116 fn profile_round_trip_yaml() {
1117 let yaml = r#"
1118schema: 1
1119id: 01JQX4TM8Y9K7VQH6B2N3R5DPE
1120name: agent_a
1121display_name: "Price Hunter"
1122version: "0.1.0"
1123persona:
1124 category: research
1125 description: "Finds prices"
1126 traits: { tone: concise, risk: cautious, verbosity: low }
1127sys_prompt_file: "sys_prompt.md"
1128model: { provider: ollama, name: "llama3.2:3b", params: { temperature: 0.2, max_tokens: 4096 } }
1129mcp_servers: []
1130skills: []
1131transport:
1132 stdio: true
1133 socket: { enabled: true, bind: "unix:///tmp/a.sock" }
1134communication: { accepts_from: ["*"], sends_to: [] }
1135capabilities: ["a2a.message.send", "a2a.tasks"]
1136entitlements:
1137 network:
1138 inbound: { ports: [] }
1139 outbound: { mode: restricted, allow_hosts: [], protocols: ["tcp"], resolve_dns: { mode: system } }
1140 filesystem: { read: [], write: [], deny: [] }
1141 processes: { spawn: { mode: allowlist, allowed: [] } }
1142 syscalls: { mode: default }
1143 limits: { memory_mb: 512, file_descriptors: 1024, processes: 32 }
1144notifications: { on_task_complete: [], on_error: [], on_shutdown: [] }
1145retry:
1146 llm: { max_retries: 3, backoff: exponential, initial_delay_ms: 1000, max_delay_ms: 30000, retry_on: [rate_limit, timeout, connection_error] }
1147 tool: { max_retries: 1, backoff: fixed, initial_delay_ms: 500 }
1148lifecycle: { restart: on_failure, max_restarts: 3, restart_window_secs: 600, stop_timeout_secs: 15, mcp_required: true }
1149created_at: "2026-04-22T10:00:00+08:00"
1150updated_at: "2026-04-22T10:00:00+08:00"
1151"#;
1152 let profile: AgentProfile = serde_yaml_ng::from_str(yaml).expect("parse");
1153 assert_eq!(profile.name, "agent_a");
1154 assert_eq!(profile.persona.category, PersonaCategory::Research);
1155 assert_eq!(
1156 profile.entitlements.network.outbound.mode,
1157 NetworkOutboundMode::Restricted
1158 );
1159 let reserialized = serde_yaml_ng::to_string(&profile).expect("emit");
1160 let round_tripped: AgentProfile = serde_yaml_ng::from_str(&reserialized).expect("re-parse");
1161 assert_eq!(profile.id, round_tripped.id);
1162 }
1163}
1164
1165#[cfg(test)]
1166mod model_ref_tests {
1167 use super::*;
1168
1169 #[test]
1170 fn legacy_profile_without_model_ref_still_parses() {
1171 let yaml = include_str!("../tests/fixtures/profile_p0a_minimal.yaml");
1172 let p: AgentProfile = serde_yaml_ng::from_str(yaml).unwrap();
1173 assert!(
1174 p.model_ref.is_none(),
1175 "legacy profile must not have model_ref"
1176 );
1177 }
1178
1179 #[test]
1180 fn round_trip_with_model_ref_preserves_field() {
1181 let yaml = include_str!("../tests/fixtures/profile_p0a_minimal.yaml");
1182 let mut p: AgentProfile = serde_yaml_ng::from_str(yaml).unwrap();
1183 p.model_ref = Some("anthropic_opus_4_7".into());
1184 let s = serde_yaml_ng::to_string(&p).unwrap();
1185 assert!(s.contains("model_ref: anthropic_opus_4_7"), "yaml: {s}");
1186 let p2: AgentProfile = serde_yaml_ng::from_str(&s).unwrap();
1187 assert_eq!(p2.model_ref.as_deref(), Some("anthropic_opus_4_7"));
1188 }
1189}
1190
1191#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1199#[serde(rename_all = "snake_case")]
1200pub enum ProactiveTier {
1201 Off,
1202 WarmOnly,
1203 WarmAndBehavior,
1204 All,
1205}
1206
1207impl ProactiveTier {
1208 pub fn from_config(c: &CompanionConfig) -> Self {
1209 match (c.enabled, c.rhythm.enabled, c.proactive.enabled) {
1210 (false, _, _) => Self::Off,
1211 (true, false, false) => Self::WarmOnly,
1212 (true, true, false) => Self::WarmAndBehavior,
1213 (true, _, true) => Self::All,
1214 }
1215 }
1216
1217 pub fn apply(&self, c: &mut CompanionConfig) {
1218 match self {
1219 Self::Off => {
1220 c.enabled = false;
1221 c.rhythm.enabled = false;
1222 c.proactive.enabled = false;
1223 }
1224 Self::WarmOnly => {
1225 c.enabled = true;
1226 c.rhythm.enabled = false;
1227 c.proactive.enabled = false;
1228 }
1229 Self::WarmAndBehavior => {
1230 c.enabled = true;
1231 c.rhythm.enabled = true;
1232 c.proactive.enabled = false;
1233 }
1234 Self::All => {
1235 c.enabled = true;
1236 c.rhythm.enabled = true;
1237 c.proactive.enabled = true;
1238 }
1239 }
1240 }
1241}
1242
1243#[cfg(test)]
1244mod mcp_pin_tests {
1245 use super::*;
1246
1247 #[test]
1251 fn pre_m9_entry_roundtrips_without_pin_fields() {
1252 let yaml = r#"
1253name: weather
1254command: /opt/mcp/weather
1255args: ["--port", "0"]
1256"#;
1257 let entry: McpServerEntry = serde_yaml_ng::from_str(yaml).unwrap();
1258 assert_eq!(entry.name, "weather");
1259 assert_eq!(entry.binary_sha256, None);
1260 assert_eq!(entry.description_hash, None);
1261 assert_eq!(entry.publisher, None);
1262 assert_eq!(entry.installed_at, None);
1263
1264 let out = serde_yaml_ng::to_string(&entry).unwrap();
1267 assert!(!out.contains("binary_sha256"), "got {out}");
1268 assert!(!out.contains("description_hash"), "got {out}");
1269 assert!(!out.contains("publisher"), "got {out}");
1270 assert!(!out.contains("installed_at"), "got {out}");
1271 }
1272
1273 #[test]
1275 fn full_m9_entry_roundtrips_all_fields() {
1276 let yaml = r#"
1277name: weather
1278command: /opt/mcp/weather
1279args: []
1280binary_sha256: "3f4abca8b0e6e2c1d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b81c"
1281description_hash: "9a01b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9c7e2"
1282publisher:
1283 name: "@anthropic-mcp/weather"
1284 homepage: "https://github.com/anthropic-mcp/weather"
1285 registry_id: "@anthropic-mcp/weather@1.2.3"
1286installed_at: "2026-05-06T08:00:00Z"
1287"#;
1288 let entry: McpServerEntry = serde_yaml_ng::from_str(yaml).unwrap();
1289 assert!(
1290 entry
1291 .binary_sha256
1292 .as_deref()
1293 .unwrap()
1294 .starts_with("3f4abca8")
1295 );
1296 assert!(
1297 entry
1298 .description_hash
1299 .as_deref()
1300 .unwrap()
1301 .starts_with("9a01b2c3")
1302 );
1303 let pub_info = entry.publisher.clone().unwrap();
1304 assert_eq!(pub_info.name, "@anthropic-mcp/weather");
1305 assert_eq!(
1306 pub_info.homepage.as_deref(),
1307 Some("https://github.com/anthropic-mcp/weather"),
1308 );
1309 assert_eq!(
1310 pub_info.registry_id.as_deref(),
1311 Some("@anthropic-mcp/weather@1.2.3"),
1312 );
1313 let installed = entry.installed_at.unwrap();
1314 assert_eq!(installed.to_rfc3339(), "2026-05-06T08:00:00+00:00");
1315 }
1316
1317 #[test]
1321 fn partial_pin_only_binary_sha_roundtrips() {
1322 let yaml = r#"
1323name: weather
1324command: /opt/mcp/weather
1325args: []
1326binary_sha256: "deadbeef00112233445566778899aabbccddeeff00112233445566778899aabb"
1327"#;
1328 let entry: McpServerEntry = serde_yaml_ng::from_str(yaml).unwrap();
1329 assert_eq!(
1330 entry.binary_sha256.as_deref(),
1331 Some("deadbeef00112233445566778899aabbccddeeff00112233445566778899aabb"),
1332 );
1333 assert_eq!(entry.description_hash, None);
1334 assert_eq!(entry.publisher, None);
1335 }
1336
1337 #[test]
1340 fn publisher_minimal_just_name() {
1341 let yaml = r#"
1342name: weather
1343command: /opt/mcp/weather
1344args: []
1345publisher:
1346 name: "alice"
1347"#;
1348 let entry: McpServerEntry = serde_yaml_ng::from_str(yaml).unwrap();
1349 let p = entry.publisher.as_ref().unwrap();
1350 assert_eq!(p.name, "alice");
1351 assert_eq!(p.homepage, None);
1352 assert_eq!(p.registry_id, None);
1353
1354 let out = serde_yaml_ng::to_string(&entry).unwrap();
1356 assert!(!out.contains("homepage:"), "got {out}");
1357 assert!(!out.contains("registry_id:"), "got {out}");
1358 }
1359}
1360
1361#[cfg(test)]
1362mod voice_tests {
1363 use super::*;
1364 use std::str::FromStr;
1365
1366 #[test]
1367 fn voice_config_round_trips() {
1368 let base = include_str!("../tests/fixtures/profile_p0a_minimal.yaml");
1370 let yaml = format!("{base}voice:\n enabled: true\n voice_id: af_bella\n");
1371
1372 let profile: AgentProfile = serde_yaml_ng::from_str(&yaml).expect("parse with voice");
1373 assert!(profile.voice.enabled);
1374 assert_eq!(profile.voice.voice_id, VoiceId::AfBella);
1375
1376 let legacy: AgentProfile = serde_yaml_ng::from_str(base).expect("parse without voice");
1378 assert!(!legacy.voice.enabled);
1379 assert_eq!(legacy.voice.voice_id, VoiceId::AfHeart);
1380 }
1381
1382 #[test]
1383 fn voice_id_from_str_roundtrips() {
1384 let cases = [
1385 ("af_heart", VoiceId::AfHeart),
1386 ("af_bella", VoiceId::AfBella),
1387 ("af_nicole", VoiceId::AfNicole),
1388 ("am_adam", VoiceId::AmAdam),
1389 ("am_michael", VoiceId::AmMichael),
1390 ];
1391 for (s, expected) in cases {
1392 assert_eq!(VoiceId::from_str(s).unwrap(), expected);
1393 assert_eq!(expected.as_str(), s);
1394 }
1395 }
1396
1397 #[test]
1398 fn voice_id_from_str_rejects_unknown() {
1399 assert!(VoiceId::from_str("bogus").is_err());
1400 }
1401}
1402
1403#[cfg(test)]
1404mod idle_trigger_tests {
1405 use super::*;
1406
1407 #[test]
1408 fn idle_trigger_yaml_round_trip() {
1409 let yaml = r#"
1410restart: on_failure
1411idle_triggers:
1412 - after_secs: 3600
1413 message: "still there?"
1414 sends_to: other_agent
1415 cooldown_secs: 1800
1416 respect_quiet_hours: true
1417"#;
1418 let cfg: LifecycleConfig = serde_yaml_ng::from_str(yaml).unwrap();
1419 assert_eq!(cfg.idle_triggers.len(), 1);
1420 assert_eq!(cfg.idle_triggers[0].after_secs, 3600);
1421 assert_eq!(cfg.idle_triggers[0].message, "still there?");
1422 assert_eq!(
1423 cfg.idle_triggers[0].sends_to.as_deref(),
1424 Some("other_agent")
1425 );
1426 assert_eq!(cfg.idle_triggers[0].cooldown_secs, 1800);
1427 assert!(cfg.idle_triggers[0].respect_quiet_hours);
1428 }
1429
1430 #[test]
1431 fn idle_trigger_defaults_when_omitted() {
1432 let yaml = "restart: on_failure\n";
1433 let cfg: LifecycleConfig = serde_yaml_ng::from_str(yaml).unwrap();
1434 assert!(cfg.idle_triggers.is_empty());
1435 }
1436}
1437
1438#[cfg(test)]
1439mod appearance_tests {
1440 use super::*;
1441
1442 #[test]
1443 fn appearance_default_style_preset_is_default_blob() {
1444 assert_eq!(AgentAppearance::default().style_preset, "default-blob");
1445 }
1446
1447 #[test]
1448 fn appearance_default_behavior_is_normal() {
1449 assert_eq!(
1450 AgentAppearance::default().behavior_preset,
1451 BehaviorPreset::Normal
1452 );
1453 }
1454
1455 #[test]
1456 fn appearance_default_render_status_is_pending() {
1457 assert_eq!(
1458 AgentAppearance::default().render_status,
1459 RenderStatus::Pending
1460 );
1461 }
1462
1463 #[test]
1464 fn render_status_serde_round_trip() {
1465 let cases = [
1466 RenderStatus::Pending,
1467 RenderStatus::Rendering { done: 3, total: 12 },
1468 RenderStatus::Ready,
1469 RenderStatus::Failed {
1470 reason: "out of quota".into(),
1471 },
1472 ];
1473 for status in cases {
1474 let yaml = serde_yaml_ng::to_string(&status).expect("serialize");
1475 let back: RenderStatus = serde_yaml_ng::from_str(&yaml).expect("deserialize");
1476 assert_eq!(status, back);
1477 }
1478 }
1479
1480 #[test]
1481 fn agent_profile_with_appearance_round_trips() {
1482 let base = include_str!("../tests/fixtures/profile_p0a_minimal.yaml");
1483 let yaml = format!(
1484 "{base}appearance:\n style_preset: chiikawa\n render_status:\n status: ready\n"
1485 );
1486 let profile: AgentProfile = serde_yaml_ng::from_str(&yaml).expect("parse with appearance");
1487 assert_eq!(profile.appearance.style_preset, "chiikawa");
1488 assert_eq!(profile.appearance.render_status, RenderStatus::Ready);
1489
1490 let out = serde_yaml_ng::to_string(&profile).expect("serialize");
1491 let back: AgentProfile = serde_yaml_ng::from_str(&out).expect("re-parse");
1492 assert_eq!(profile.appearance, back.appearance);
1493 }
1494
1495 #[test]
1496 fn legacy_profile_without_appearance_uses_default() {
1497 let yaml = include_str!("../tests/fixtures/profile_p0a_minimal.yaml");
1498 let profile: AgentProfile = serde_yaml_ng::from_str(yaml).expect("parse legacy");
1499 assert_eq!(profile.appearance.style_preset, "default-blob");
1500 assert_eq!(profile.appearance.behavior_preset, BehaviorPreset::Normal);
1501 assert_eq!(profile.appearance.render_status, RenderStatus::Pending);
1502 }
1503
1504 #[test]
1505 fn legacy_profile_without_file_actions_or_action_pipeline_loads() {
1506 let yaml = include_str!("../tests/fixtures/profile_p0a_minimal.yaml");
1507 let p: AgentProfile = serde_yaml_ng::from_str(yaml).unwrap();
1508 assert!(p.file_actions.is_empty());
1509 assert_eq!(p.action_pipeline.deletion.cancel_window_minutes, 10);
1510 assert_eq!(p.action_pipeline.queue.max_concurrent, 3);
1511 }
1512}
1513
1514#[cfg(test)]
1515mod federation_tests {
1516 use super::*;
1517
1518 #[test]
1519 fn test_pattern_filter_default() {
1520 let f = PatternFilter::default();
1521 assert_eq!(f.max_count, 200);
1522 assert_eq!(f.importance_min, 0.0);
1523 assert!(f.tier.is_empty());
1524 }
1525
1526 #[test]
1527 fn test_federation_config_roundtrip() {
1528 let cfg = FederationConfig {
1529 filter: PatternFilter {
1530 tier: vec!["core".into()],
1531 max_count: 50,
1532 ..Default::default()
1533 },
1534 snapshot_ref: Some(SnapshotRef {
1535 knowledge_commit: "abc123def456".into(),
1536 taken_at: "2026-05-19T00:00:00Z".into(),
1537 filter: PatternFilter::default(),
1538 }),
1539 evidence_flush_interval_minutes: 15,
1540 };
1541 let yaml = serde_yaml_ng::to_string(&cfg).unwrap();
1542 let back: FederationConfig = serde_yaml_ng::from_str(&yaml).unwrap();
1543 assert_eq!(cfg, back);
1544 }
1545
1546 #[test]
1547 fn test_agent_profile_federation_defaults() {
1548 let cfg = FederationConfig::default();
1552 assert_eq!(cfg.evidence_flush_interval_minutes, 0);
1553 assert!(cfg.snapshot_ref.is_none());
1554 }
1555}
1556
1557#[cfg(test)]
1558mod skill_card_tests {
1559 use super::*;
1560
1561 #[test]
1562 fn installed_skills_default_to_empty_when_absent() {
1563 let yaml = include_str!("../tests/fixtures/profile_p0a_minimal.yaml");
1564 let p: AgentProfile = serde_yaml_ng::from_str(yaml).unwrap();
1565 assert!(p.installed_skills.is_empty());
1566 }
1567
1568 #[test]
1569 fn installed_skills_roundtrip_preserves_entries() {
1570 let base = include_str!("../tests/fixtures/profile_p0a_minimal.yaml");
1571 let yaml = format!(
1572 "{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"
1573 );
1574 let p: AgentProfile = serde_yaml_ng::from_str(&yaml).unwrap();
1575 assert_eq!(p.installed_skills.len(), 1);
1576 assert_eq!(p.installed_skills[0].name, "s1");
1577 assert_eq!(p.installed_skills[0].abstract_text, "does things");
1578 assert_eq!(p.installed_skills[0].transfer_chain, vec!["agent://alice"]);
1579
1580 let out = serde_yaml_ng::to_string(&p).unwrap();
1581 assert!(out.contains("abstract: does things"));
1582 assert!(out.contains("pattern: /find"));
1583
1584 let back: AgentProfile = serde_yaml_ng::from_str(&out).unwrap();
1585 assert_eq!(p.installed_skills, back.installed_skills);
1586 }
1587
1588 #[test]
1589 fn installed_skills_minimal_entry_serializes_compactly() {
1590 let entry = SkillCardEntry {
1592 name: "minimal".into(),
1593 ..Default::default()
1594 };
1595 let yaml = serde_yaml_ng::to_string(&entry).unwrap();
1596 assert!(yaml.contains("name: minimal"));
1597 assert!(
1598 !yaml.contains("version:"),
1599 "empty version must be skipped: {yaml}"
1600 );
1601 assert!(
1602 !yaml.contains("publisher:"),
1603 "empty publisher must be skipped: {yaml}"
1604 );
1605 assert!(
1606 !yaml.contains("abstract:"),
1607 "empty abstract must be skipped: {yaml}"
1608 );
1609 }
1610}