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 hitl: HitlConfig,
93 #[serde(default)]
95 pub voice: VoiceConfig,
96 #[serde(default)]
98 pub hooks: crate::HooksConfig,
99 #[serde(default)]
102 pub trusted_peers: Vec<crate::bridge::peer::TrustedPeer>,
103 pub created_at: String,
104 pub updated_at: String,
105 #[serde(default)]
107 pub appearance: AgentAppearance,
108 #[serde(default)]
110 pub federation: FederationConfig,
111
112 #[serde(default)]
116 pub file_actions: Vec<crate::action::FileAction>,
117
118 #[serde(default)]
120 pub action_pipeline: crate::action::ActionPipelineConfig,
121}
122
123fn default_algorithm() -> String {
124 "ed25519".into()
125}
126
127pub const SUPPORTED_ALGORITHMS: &[&str] = &["ed25519"];
129
130#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
131pub struct IdentityConfig {
132 #[serde(default)]
135 pub pubkey: String,
136 #[serde(default, skip_serializing_if = "Option::is_none")]
138 pub owner: Option<String>,
139
140 #[serde(default = "default_algorithm")]
143 pub algorithm: String,
144 #[serde(default)]
146 pub key_version: u32,
147 #[serde(default, skip_serializing_if = "Option::is_none")]
149 pub created_at_key: Option<String>,
150 #[serde(default, skip_serializing_if = "Option::is_none")]
152 pub previous_pubkey: Option<String>,
153 #[serde(default, skip_serializing_if = "Option::is_none")]
155 pub previous_key_version: Option<u32>,
156 #[serde(default, skip_serializing_if = "Option::is_none")]
159 pub grace_expires_at: Option<String>,
160 #[serde(default, skip_serializing_if = "Option::is_none")]
162 pub rotated_at: Option<String>,
163 #[serde(default, skip_serializing_if = "Option::is_none")]
165 pub emergency_rekey_at: Option<String>,
166}
167
168impl Default for IdentityConfig {
169 fn default() -> Self {
170 Self {
171 pubkey: String::new(),
172 owner: None,
173 algorithm: default_algorithm(),
174 key_version: 0,
175 created_at_key: None,
176 previous_pubkey: None,
177 previous_key_version: None,
178 grace_expires_at: None,
179 rotated_at: None,
180 emergency_rekey_at: None,
181 }
182 }
183}
184
185#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
186pub struct Persona {
187 pub category: PersonaCategory,
188 pub description: String,
189 pub traits: PersonaTraits,
190}
191
192#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
193#[serde(rename_all = "lowercase")]
194pub enum PersonaCategory {
195 Research,
196 Automation,
197 Monitor,
198 Notify,
199 Commerce,
200 Custom,
201}
202
203#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
204pub struct PersonaTraits {
205 pub tone: String,
206 pub risk: String,
207 pub verbosity: String,
208}
209
210#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
211pub struct ModelConfig {
212 pub provider: String,
213 pub name: String,
214 #[serde(default)]
215 pub params: BTreeMap<String, serde_yaml_ng::Value>,
216}
217
218#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
219pub struct McpServerEntry {
220 pub name: String,
221 pub command: String,
222 #[serde(default)]
223 pub args: Vec<String>,
224
225 #[serde(default, skip_serializing_if = "Option::is_none")]
231 pub binary_sha256: Option<String>,
232
233 #[serde(default, skip_serializing_if = "Option::is_none")]
239 pub description_hash: Option<String>,
240
241 #[serde(default, skip_serializing_if = "Option::is_none")]
245 pub publisher: Option<McpPublisherInfo>,
246
247 #[serde(default, skip_serializing_if = "Option::is_none")]
251 pub installed_at: Option<chrono::DateTime<chrono::Utc>>,
252}
253
254#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
260pub struct McpPublisherInfo {
261 pub name: String,
264
265 #[serde(default, skip_serializing_if = "Option::is_none")]
269 pub homepage: Option<String>,
270
271 #[serde(default, skip_serializing_if = "Option::is_none")]
274 pub registry_id: Option<String>,
275}
276
277#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
278pub struct TransportConfig {
279 pub stdio: bool,
280 pub socket: SocketTransportConfig,
281 #[serde(default)]
282 pub tcp: TcpTransportConfig,
283 #[serde(default)]
287 pub webhook: WebhookTransportConfig,
288}
289
290#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
291pub struct TcpTransportConfig {
292 #[serde(default)]
293 pub enabled: bool,
294 #[serde(default)]
295 pub bind: String,
296 #[serde(default)]
297 pub noise: NoiseConfig,
298}
299
300#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
314pub struct WebhookTransportConfig {
315 #[serde(default)]
316 pub enabled: bool,
317 #[serde(default = "default_webhook_bind")]
318 pub bind: String,
319 #[serde(default = "default_webhook_port")]
320 pub port: u16,
321 #[serde(default)]
325 pub hmac_secret_ref: String,
326}
327
328fn default_webhook_bind() -> String {
329 "127.0.0.1".to_string()
330}
331
332fn default_webhook_port() -> u16 {
333 6789
334}
335
336impl Default for WebhookTransportConfig {
337 fn default() -> Self {
338 Self {
339 enabled: false,
340 bind: default_webhook_bind(),
341 port: default_webhook_port(),
342 hmac_secret_ref: String::new(),
343 }
344 }
345}
346
347#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
348pub struct NoiseConfig {
349 pub pattern: String,
350}
351
352impl Default for NoiseConfig {
353 fn default() -> Self {
354 Self {
355 pattern: "Noise_XK_25519_ChaChaPoly_BLAKE2s".into(),
356 }
357 }
358}
359
360#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
361pub struct SocketTransportConfig {
362 pub enabled: bool,
363 pub bind: String, #[serde(default, skip_serializing_if = "Option::is_none")]
365 pub auth: Option<AuthConfig>,
366}
367
368#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
369pub struct AuthConfig {
370 pub scheme: String,
371 pub token_file: String,
372}
373
374#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
375pub struct CommunicationConfig {
376 #[serde(default = "default_accepts_all")]
377 pub accepts_from: Vec<String>,
378 #[serde(default)]
379 pub sends_to: Vec<String>,
380}
381fn default_accepts_all() -> Vec<String> {
382 vec!["*".to_string()]
383}
384
385#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
386pub struct Entitlements {
387 pub network: NetworkEntitlement,
388 pub filesystem: FilesystemEntitlement,
389 pub processes: ProcessesEntitlement,
390 #[serde(default)]
391 pub syscalls: SyscallsEntitlement,
392 #[serde(default)]
393 pub limits: LimitsEntitlement,
394 #[serde(default)]
397 pub llm: crate::bridge::llm_entitlement::LlmEntitlement,
398 #[serde(default, skip_serializing_if = "Vec::is_empty")]
400 pub tools: Vec<ToolRule>,
401 #[serde(default = "default_true")]
406 pub fail_closed_on_sandbox_error: bool,
407}
408
409#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
410pub struct NetworkEntitlement {
411 pub inbound: InboundNetwork,
412 pub outbound: OutboundNetwork,
413}
414
415#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
416pub struct InboundNetwork {
417 #[serde(default)]
418 pub ports: Vec<u16>,
419}
420
421#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
422pub struct OutboundNetwork {
423 pub mode: NetworkOutboundMode,
424 #[serde(default)]
425 pub allow_hosts: Vec<String>,
426 #[serde(default = "default_protocols")]
427 pub protocols: Vec<String>,
428 #[serde(default)]
429 pub resolve_dns: ResolveDnsConfig,
430}
431fn default_protocols() -> Vec<String> {
432 vec!["tcp".to_string()]
433}
434
435#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
436#[serde(rename_all = "lowercase")]
437pub enum NetworkOutboundMode {
438 Unrestricted,
439 Restricted,
440 Off,
441}
442
443#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
444pub struct ResolveDnsConfig {
445 #[serde(default = "default_dns_mode")]
446 pub mode: String,
447 #[serde(default)]
448 pub servers: Vec<String>,
449}
450impl Default for ResolveDnsConfig {
451 fn default() -> Self {
452 Self {
453 mode: default_dns_mode(),
454 servers: vec![],
455 }
456 }
457}
458fn default_dns_mode() -> String {
459 "system".to_string()
460}
461
462#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
463pub struct FilesystemEntitlement {
464 #[serde(default)]
465 pub read: Vec<String>,
466 #[serde(default)]
467 pub write: Vec<String>,
468 #[serde(default)]
469 pub deny: Vec<String>,
470}
471
472#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
473pub struct ProcessesEntitlement {
474 pub spawn: SpawnEntitlement,
475}
476
477#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
478pub struct SpawnEntitlement {
479 pub mode: SpawnMode,
480 #[serde(default)]
481 pub allowed: Vec<String>,
482}
483
484#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
485#[serde(rename_all = "lowercase")]
486pub enum SpawnMode {
487 Allowlist,
488 Any,
489 None,
490}
491
492#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
493pub struct SyscallsEntitlement {
494 #[serde(default = "default_syscalls_mode")]
495 pub mode: String,
496 #[serde(default)]
497 pub extra_deny: Vec<String>,
498}
499fn default_syscalls_mode() -> String {
500 "default".to_string()
501}
502
503#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
504pub struct LimitsEntitlement {
505 #[serde(default)]
506 pub cpu_seconds: Option<u64>,
507 #[serde(default = "default_memory_mb")]
508 pub memory_mb: u64,
509 #[serde(default = "default_fds")]
510 pub file_descriptors: u32,
511 #[serde(default = "default_procs")]
512 pub processes: u32,
513}
514fn default_memory_mb() -> u64 {
515 512
516}
517fn default_fds() -> u32 {
518 1024
519}
520fn default_procs() -> u32 {
521 32
522}
523
524#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
525#[serde(rename_all = "lowercase")]
526pub enum ToolPolicy {
527 Allow,
528 #[default]
529 Ask,
530 Deny,
531}
532
533#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
534pub struct ToolRule {
535 pub pattern: String,
536 pub policy: ToolPolicy,
537}
538
539pub fn resolve_tool_policy(rules: &[ToolRule], tool_name: &str) -> ToolPolicy {
543 for rule in rules {
544 if rule.pattern == tool_name {
545 return rule.policy;
546 }
547 }
548 let mut best: Option<(&ToolRule, usize)> = None;
549 for rule in rules {
550 if let Some(prefix) = rule.pattern.strip_suffix('*')
551 && tool_name.starts_with(prefix)
552 {
553 let len = prefix.len();
554 if best.is_none_or(|(_, best_len)| len > best_len) {
555 best = Some((rule, len));
556 }
557 }
558 }
559 if let Some((rule, _)) = best {
560 return rule.policy;
561 }
562 ToolPolicy::default()
563}
564
565#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
566pub struct NotificationsConfig {
567 #[serde(default)]
568 pub on_task_complete: Vec<NotificationTarget>,
569 #[serde(default)]
570 pub on_error: Vec<NotificationTarget>,
571 #[serde(default)]
572 pub on_shutdown: Vec<NotificationTarget>,
573}
574
575#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
576#[serde(tag = "target", rename_all = "lowercase")]
577pub enum NotificationTarget {
578 Agent {
579 name: String,
580 },
581 Commander,
582 Email {
583 address: String,
584 #[serde(default)]
585 smtp_config_file: Option<String>,
586 },
587 Slack {
588 #[serde(default)]
589 channel: Option<String>,
590 #[serde(default)]
591 webhook_url_env: Option<String>,
592 },
593 Webpush {
594 url: String,
595 },
596 Webhook {
597 url: String,
598 #[serde(default = "default_post")]
599 method: String,
600 #[serde(default)]
601 auth: Option<String>,
602 },
603}
604fn default_post() -> String {
605 "POST".to_string()
606}
607
608#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
609pub struct RetryConfig {
610 pub llm: RetryPolicy,
611 pub tool: RetryPolicy,
612}
613
614#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
615pub struct RetryPolicy {
616 pub max_retries: u32,
617 pub backoff: BackoffStrategy,
618 pub initial_delay_ms: u64,
619 #[serde(default)]
620 pub max_delay_ms: Option<u64>,
621 #[serde(default)]
622 pub retry_on: Vec<String>,
623}
624
625#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
626#[serde(rename_all = "lowercase")]
627pub enum BackoffStrategy {
628 Linear,
629 Exponential,
630 Fixed,
631}
632
633#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
634pub struct LifecycleConfig {
635 pub restart: RestartPolicy,
636 #[serde(default = "default_max_restarts")]
637 pub max_restarts: u32,
638 #[serde(default = "default_window")]
639 pub restart_window_secs: u64,
640 #[serde(default = "default_stop_timeout")]
641 pub stop_timeout_secs: u64,
642 #[serde(default = "default_mcp_required")]
643 pub mcp_required: bool,
644 #[serde(default)]
645 pub execution: ExecutionMode,
646 #[serde(default)]
647 pub schedule: Vec<ScheduleEntry>,
648 #[serde(default)]
649 pub idle_triggers: Vec<IdleTrigger>,
650}
651fn default_max_restarts() -> u32 {
652 3
653}
654fn default_window() -> u64 {
655 600
656}
657fn default_stop_timeout() -> u64 {
658 15
659}
660fn default_mcp_required() -> bool {
661 true
662}
663
664#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
665#[serde(rename_all = "snake_case")]
666pub enum RestartPolicy {
667 Never,
668 OnFailure,
669 Always,
670}
671
672#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
673#[serde(rename_all = "snake_case")]
674pub enum ExecutionMode {
675 #[default]
676 Daemon,
677 OnDemand,
678}
679
680#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
681pub struct ScheduleEntry {
682 pub cron: String,
683 pub message: String,
684 #[serde(default, skip_serializing_if = "Option::is_none")]
685 pub sends_to: Option<String>,
686}
687
688#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
689pub struct IdleTrigger {
690 pub after_secs: u64,
692 pub message: String,
694 #[serde(default, skip_serializing_if = "Option::is_none")]
696 pub sends_to: Option<String>,
697 #[serde(default = "default_idle_cooldown")]
700 pub cooldown_secs: u64,
701 #[serde(default = "default_true")]
704 pub respect_quiet_hours: bool,
705}
706
707fn default_idle_cooldown() -> u64 {
708 600
709}
710fn default_true() -> bool {
711 true
712}
713
714#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
715pub struct FileTransferConfig {
716 #[serde(default = "default_accept_max")]
717 pub accept_incoming_file_max_bytes: u64,
718 #[serde(default = "default_accept_total")]
719 pub accept_incoming_total_per_hour: u64,
720 #[serde(default = "default_approval_threshold")]
721 pub require_approval_above_bytes: u64,
722 #[serde(default = "default_reject_paths")]
723 pub reject_paths: Vec<String>,
724 #[serde(default = "default_allowed_mime")]
725 pub allowed_mime_types: Vec<String>,
726}
727
728impl Default for FileTransferConfig {
729 fn default() -> Self {
730 Self {
731 accept_incoming_file_max_bytes: default_accept_max(),
732 accept_incoming_total_per_hour: default_accept_total(),
733 require_approval_above_bytes: default_approval_threshold(),
734 reject_paths: default_reject_paths(),
735 allowed_mime_types: default_allowed_mime(),
736 }
737 }
738}
739
740fn default_accept_max() -> u64 {
741 10_485_760
742}
743fn default_accept_total() -> u64 {
744 104_857_600
745}
746fn default_approval_threshold() -> u64 {
747 10_485_760
748}
749fn default_reject_paths() -> Vec<String> {
750 vec!["~/.ssh".into(), "~/.aws".into(), "~/.gnupg".into()]
751}
752fn default_allowed_mime() -> Vec<String> {
753 vec!["*".into()]
754}
755
756#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
757#[serde(rename_all = "snake_case")]
758pub enum DeploymentType {
759 #[default]
760 Laptop,
761 Vm,
762 Docker,
763 K8s,
764 Lambda,
765}
766
767#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
768pub struct DeploymentConfig {
769 #[serde(rename = "type", default)]
770 pub deployment_type: DeploymentType,
771 #[serde(default, skip_serializing_if = "Option::is_none")]
772 pub region: Option<String>,
773 #[serde(default = "default_env")]
774 pub environment: Option<String>,
775}
776
777impl Default for DeploymentConfig {
778 fn default() -> Self {
779 Self {
780 deployment_type: DeploymentType::default(),
781 region: None,
782 environment: default_env(),
783 }
784 }
785}
786
787fn default_env() -> Option<String> {
788 Some("dev".into())
789}
790
791#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
792pub struct LockFile {
793 pub schema: u32,
794 pub uuid: String,
795 pub name: String,
796 pub pid: u32,
797 pub ppid: u32,
798 pub started_at: String,
799 pub binary_version: String,
800 pub transports: LockTransports,
801 pub card_digest: String,
802 pub capabilities: Vec<String>,
803}
804
805#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
806pub struct LockTransports {
807 pub stdio: bool,
808 #[serde(default)]
809 pub unix_socket: Option<String>,
810 #[serde(default)]
811 pub tcp: Option<String>,
812 #[serde(default)]
817 pub webhook: Option<String>,
818}
819
820#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
827#[serde(rename_all = "snake_case")]
828pub enum VoiceId {
829 #[default]
831 AfHeart,
832 AfBella,
833 AfNicole,
834 AmAdam,
835 AmMichael,
836}
837
838impl VoiceId {
839 pub fn style_index(&self) -> usize {
841 match self {
842 VoiceId::AfHeart => 0,
843 VoiceId::AfBella => 1,
844 VoiceId::AfNicole => 2,
845 VoiceId::AmAdam => 3,
846 VoiceId::AmMichael => 4,
847 }
848 }
849
850 pub fn as_str(&self) -> &'static str {
852 match self {
853 VoiceId::AfHeart => "af_heart",
854 VoiceId::AfBella => "af_bella",
855 VoiceId::AfNicole => "af_nicole",
856 VoiceId::AmAdam => "am_adam",
857 VoiceId::AmMichael => "am_michael",
858 }
859 }
860}
861
862impl std::str::FromStr for VoiceId {
863 type Err = anyhow::Error;
864
865 fn from_str(s: &str) -> anyhow::Result<Self> {
866 match s {
867 "af_heart" => Ok(VoiceId::AfHeart),
868 "af_bella" => Ok(VoiceId::AfBella),
869 "af_nicole" => Ok(VoiceId::AfNicole),
870 "am_adam" => Ok(VoiceId::AmAdam),
871 "am_michael" => Ok(VoiceId::AmMichael),
872 other => anyhow::bail!(
873 "unknown voice ID '{other}' \
874 (valid: af_heart, af_bella, af_nicole, am_adam, am_michael)"
875 ),
876 }
877 }
878}
879
880#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
883pub struct VoiceConfig {
884 #[serde(default)]
886 pub enabled: bool,
887 #[serde(default)]
889 pub voice_id: VoiceId,
890 #[serde(default, skip_serializing_if = "Option::is_none")]
893 pub input_device: Option<String>,
894}
895
896#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
901pub struct HitlConfig {
902 #[serde(default = "default_hitl_timeout_secs")]
903 pub timeout_secs: u32,
904 #[serde(default)]
905 pub max_iterations: Option<u32>,
906}
907
908fn default_hitl_timeout_secs() -> u32 {
909 300
910}
911
912impl Default for HitlConfig {
913 fn default() -> Self {
914 Self {
915 timeout_secs: default_hitl_timeout_secs(),
916 max_iterations: None,
917 }
918 }
919}
920
921#[cfg(test)]
922mod hitl_tests {
923 use super::*;
924
925 #[test]
926 fn hitl_config_default_max_iterations_is_none() {
927 let cfg = HitlConfig::default();
928 assert!(cfg.max_iterations.is_none());
929 }
930
931 #[test]
932 fn hitl_config_max_iterations_explicit() {
933 let cfg: HitlConfig = serde_yaml::from_str("timeout_secs: 60\nmax_iterations: 5").unwrap();
934 assert_eq!(cfg.max_iterations, Some(5));
935 }
936}
937
938#[derive(Debug, Default, Clone, PartialEq, Serialize, Deserialize)]
944pub struct CompanionConfig {
945 #[serde(default)]
946 pub enabled: bool,
947 #[serde(default = "default_locale")]
948 pub locale: String,
949 #[serde(default)]
950 pub relationship: Relationship,
951 #[serde(default)]
952 pub voice_overrides: VoiceOverrides,
953 #[serde(default)]
954 pub onboarding: OnboardingState,
955 #[serde(default)]
956 pub rhythm: RhythmConfig,
957 #[serde(default)]
958 pub proactive: ProactiveConfig,
959}
960
961pub fn default_locale() -> String {
964 std::env::var("LANG")
965 .ok()
966 .and_then(|v| v.split('.').next().map(|s| s.replace('_', "-")))
967 .unwrap_or_else(|| "en-US".into())
968}
969
970#[derive(Debug, Default, Clone, PartialEq, Serialize, Deserialize)]
971pub struct VoiceOverrides {
972 #[serde(default, skip_serializing_if = "Option::is_none")]
973 pub name_for_user: Option<String>,
974 #[serde(default, skip_serializing_if = "Option::is_none")]
975 pub formality: Option<Formality>,
976 #[serde(default, skip_serializing_if = "Option::is_none")]
977 pub extra_instructions: Option<String>,
978}
979
980#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
981pub struct FirstMemory {
982 pub text: String,
983 pub established_at: chrono::DateTime<chrono::Utc>,
984}
985
986#[derive(Debug, Default, Clone, PartialEq, Serialize, Deserialize)]
987pub struct OnboardingState {
988 #[serde(default, skip_serializing_if = "Option::is_none")]
989 pub completed_at: Option<chrono::DateTime<chrono::Utc>>,
990 #[serde(default)]
991 pub version: u32,
992 #[serde(default, skip_serializing_if = "Option::is_none")]
993 pub agent_display_name: Option<String>,
994 #[serde(default, skip_serializing_if = "Option::is_none")]
995 pub first_memory: Option<FirstMemory>,
996}
997
998#[derive(Debug, Default, Clone, PartialEq, Serialize, Deserialize)]
1001pub struct RhythmConfig {
1002 #[serde(default)]
1003 pub enabled: bool,
1004}
1005
1006#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1007pub struct ProactiveConfig {
1008 #[serde(default)]
1009 pub enabled: bool,
1010 #[serde(default, skip_serializing_if = "Option::is_none")]
1012 pub learning_until: Option<chrono::DateTime<chrono::Utc>>,
1013 #[serde(default, skip_serializing_if = "Option::is_none")]
1014 pub quiet_hours: Option<QuietHours>,
1015 #[serde(default, skip_serializing_if = "Option::is_none")]
1016 pub active_hours: Option<ActiveHours>,
1017 #[serde(default = "default_daily_cap")]
1018 pub daily_cap: u8,
1019 #[serde(default = "default_channels")]
1020 pub channels: Vec<String>,
1021 #[serde(default, skip_serializing_if = "Option::is_none")]
1022 pub paused_until: Option<chrono::DateTime<chrono::Utc>>,
1023}
1024
1025impl Default for ProactiveConfig {
1026 fn default() -> Self {
1027 Self {
1028 enabled: false,
1029 learning_until: None,
1030 quiet_hours: None,
1031 active_hours: None,
1032 daily_cap: default_daily_cap(),
1033 channels: default_channels(),
1034 paused_until: None,
1035 }
1036 }
1037}
1038
1039fn default_daily_cap() -> u8 {
1040 3
1041}
1042fn default_channels() -> Vec<String> {
1043 vec!["stdout".into()]
1044}
1045
1046#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1047pub struct QuietHours {
1048 pub start: String,
1049 pub end: String,
1050}
1051
1052#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1053pub struct ActiveHours {
1054 pub start: String,
1055 pub end: String,
1056}
1057
1058#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
1063pub struct AgentAppearance {
1064 #[serde(default = "default_style_preset")]
1066 pub style_preset: String,
1067 #[serde(default)]
1068 pub behavior_preset: BehaviorPreset,
1069 #[serde(default, skip_serializing_if = "Option::is_none")]
1071 pub source_image_path: Option<std::path::PathBuf>,
1072 #[serde(default = "default_expressions_dir")]
1074 pub expressions_dir: std::path::PathBuf,
1075 #[serde(default, skip_serializing_if = "Option::is_none")]
1076 pub last_rendered_at: Option<chrono::DateTime<chrono::Utc>>,
1077 #[serde(default)]
1078 pub render_status: RenderStatus,
1079}
1080
1081fn default_style_preset() -> String {
1082 "default-blob".into()
1083}
1084
1085fn default_expressions_dir() -> std::path::PathBuf {
1086 std::path::PathBuf::from("expressions")
1087}
1088
1089impl Default for AgentAppearance {
1090 fn default() -> Self {
1091 Self {
1092 style_preset: default_style_preset(),
1093 behavior_preset: BehaviorPreset::Normal,
1094 source_image_path: None,
1095 expressions_dir: default_expressions_dir(),
1096 last_rendered_at: None,
1097 render_status: RenderStatus::Pending,
1098 }
1099 }
1100}
1101
1102#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
1103#[serde(rename_all = "snake_case")]
1104pub enum BehaviorPreset {
1105 Quiet,
1106 #[default]
1107 Normal,
1108 Lively,
1109}
1110
1111#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
1112#[serde(tag = "status", rename_all = "snake_case")]
1113pub enum RenderStatus {
1114 #[default]
1115 Pending,
1116 Rendering {
1117 done: u8,
1118 total: u8,
1119 },
1120 Ready,
1121 Failed {
1122 reason: String,
1123 },
1124}
1125
1126#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
1132#[serde(rename_all = "kebab-case")]
1133pub enum SnapshotPolicy {
1134 #[default]
1135 PullOnStart,
1136 PullPeriodic,
1137 Manual,
1138}
1139
1140#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
1142pub struct PatternFilter {
1143 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1144 pub applies_in: Vec<String>,
1145 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1146 pub tier: Vec<String>,
1147 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1148 pub maturity: Vec<String>,
1149 #[serde(default)]
1150 pub importance_min: f64,
1151 #[serde(default = "default_max_snapshot_count")]
1152 pub max_count: usize,
1153 #[serde(default)]
1154 pub snapshot_policy: SnapshotPolicy,
1155}
1156
1157fn default_max_snapshot_count() -> usize {
1158 200
1159}
1160
1161impl Default for PatternFilter {
1162 fn default() -> Self {
1163 Self {
1164 applies_in: vec![],
1165 tier: vec![],
1166 maturity: vec![],
1167 importance_min: 0.0,
1168 max_count: 200,
1169 snapshot_policy: SnapshotPolicy::default(),
1170 }
1171 }
1172}
1173
1174#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
1176pub struct SnapshotRef {
1177 pub knowledge_commit: String,
1178 pub taken_at: String,
1179 pub filter: PatternFilter,
1180}
1181
1182#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
1184pub struct FederationConfig {
1185 #[serde(default)]
1186 pub filter: PatternFilter,
1187 #[serde(default, skip_serializing_if = "Option::is_none")]
1188 pub snapshot_ref: Option<SnapshotRef>,
1189 #[serde(default)]
1190 pub evidence_flush_interval_minutes: u32,
1191}
1192
1193impl AgentProfile {
1194 #[doc(hidden)]
1200 pub fn default_for_tests() -> Self {
1201 serde_yaml_ng::from_str(include_str!("../tests/fixtures/minimal_profile.yaml"))
1202 .expect("minimal profile fixture")
1203 }
1204}
1205
1206#[cfg(test)]
1207mod tests {
1208 use super::*;
1209
1210 #[test]
1211 fn profile_round_trip_yaml() {
1212 let yaml = r#"
1213schema: 1
1214id: 01JQX4TM8Y9K7VQH6B2N3R5DPE
1215name: agent_a
1216display_name: "Price Hunter"
1217version: "0.1.0"
1218persona:
1219 category: research
1220 description: "Finds prices"
1221 traits: { tone: concise, risk: cautious, verbosity: low }
1222sys_prompt_file: "sys_prompt.md"
1223model: { provider: ollama, name: "llama3.2:3b", params: { temperature: 0.2, max_tokens: 4096 } }
1224mcp_servers: []
1225skills: []
1226transport:
1227 stdio: true
1228 socket: { enabled: true, bind: "unix:///tmp/a.sock" }
1229communication: { accepts_from: ["*"], sends_to: [] }
1230capabilities: ["a2a.message.send", "a2a.tasks"]
1231entitlements:
1232 network:
1233 inbound: { ports: [] }
1234 outbound: { mode: restricted, allow_hosts: [], protocols: ["tcp"], resolve_dns: { mode: system } }
1235 filesystem: { read: [], write: [], deny: [] }
1236 processes: { spawn: { mode: allowlist, allowed: [] } }
1237 syscalls: { mode: default }
1238 limits: { memory_mb: 512, file_descriptors: 1024, processes: 32 }
1239notifications: { on_task_complete: [], on_error: [], on_shutdown: [] }
1240retry:
1241 llm: { max_retries: 3, backoff: exponential, initial_delay_ms: 1000, max_delay_ms: 30000, retry_on: [rate_limit, timeout, connection_error] }
1242 tool: { max_retries: 1, backoff: fixed, initial_delay_ms: 500 }
1243lifecycle: { restart: on_failure, max_restarts: 3, restart_window_secs: 600, stop_timeout_secs: 15, mcp_required: true }
1244created_at: "2026-04-22T10:00:00+08:00"
1245updated_at: "2026-04-22T10:00:00+08:00"
1246"#;
1247 let profile: AgentProfile = serde_yaml_ng::from_str(yaml).expect("parse");
1248 assert_eq!(profile.name, "agent_a");
1249 assert_eq!(profile.persona.category, PersonaCategory::Research);
1250 assert_eq!(
1251 profile.entitlements.network.outbound.mode,
1252 NetworkOutboundMode::Restricted
1253 );
1254 let reserialized = serde_yaml_ng::to_string(&profile).expect("emit");
1255 let round_tripped: AgentProfile = serde_yaml_ng::from_str(&reserialized).expect("re-parse");
1256 assert_eq!(profile.id, round_tripped.id);
1257 }
1258}
1259
1260#[cfg(test)]
1261mod model_ref_tests {
1262 use super::*;
1263
1264 #[test]
1265 fn legacy_profile_without_model_ref_still_parses() {
1266 let yaml = include_str!("../tests/fixtures/profile_p0a_minimal.yaml");
1267 let p: AgentProfile = serde_yaml_ng::from_str(yaml).unwrap();
1268 assert!(
1269 p.model_ref.is_none(),
1270 "legacy profile must not have model_ref"
1271 );
1272 }
1273
1274 #[test]
1275 fn round_trip_with_model_ref_preserves_field() {
1276 let yaml = include_str!("../tests/fixtures/profile_p0a_minimal.yaml");
1277 let mut p: AgentProfile = serde_yaml_ng::from_str(yaml).unwrap();
1278 p.model_ref = Some("anthropic_opus_4_7".into());
1279 let s = serde_yaml_ng::to_string(&p).unwrap();
1280 assert!(s.contains("model_ref: anthropic_opus_4_7"), "yaml: {s}");
1281 let p2: AgentProfile = serde_yaml_ng::from_str(&s).unwrap();
1282 assert_eq!(p2.model_ref.as_deref(), Some("anthropic_opus_4_7"));
1283 }
1284}
1285
1286#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1294#[serde(rename_all = "snake_case")]
1295pub enum ProactiveTier {
1296 Off,
1297 WarmOnly,
1298 WarmAndBehavior,
1299 All,
1300}
1301
1302impl ProactiveTier {
1303 pub fn from_config(c: &CompanionConfig) -> Self {
1304 match (c.enabled, c.rhythm.enabled, c.proactive.enabled) {
1305 (false, _, _) => Self::Off,
1306 (true, false, false) => Self::WarmOnly,
1307 (true, true, false) => Self::WarmAndBehavior,
1308 (true, _, true) => Self::All,
1309 }
1310 }
1311
1312 pub fn apply(&self, c: &mut CompanionConfig) {
1313 match self {
1314 Self::Off => {
1315 c.enabled = false;
1316 c.rhythm.enabled = false;
1317 c.proactive.enabled = false;
1318 }
1319 Self::WarmOnly => {
1320 c.enabled = true;
1321 c.rhythm.enabled = false;
1322 c.proactive.enabled = false;
1323 }
1324 Self::WarmAndBehavior => {
1325 c.enabled = true;
1326 c.rhythm.enabled = true;
1327 c.proactive.enabled = false;
1328 }
1329 Self::All => {
1330 c.enabled = true;
1331 c.rhythm.enabled = true;
1332 c.proactive.enabled = true;
1333 }
1334 }
1335 }
1336}
1337
1338#[cfg(test)]
1339mod mcp_pin_tests {
1340 use super::*;
1341
1342 #[test]
1346 fn pre_m9_entry_roundtrips_without_pin_fields() {
1347 let yaml = r#"
1348name: weather
1349command: /opt/mcp/weather
1350args: ["--port", "0"]
1351"#;
1352 let entry: McpServerEntry = serde_yaml_ng::from_str(yaml).unwrap();
1353 assert_eq!(entry.name, "weather");
1354 assert_eq!(entry.binary_sha256, None);
1355 assert_eq!(entry.description_hash, None);
1356 assert_eq!(entry.publisher, None);
1357 assert_eq!(entry.installed_at, None);
1358
1359 let out = serde_yaml_ng::to_string(&entry).unwrap();
1362 assert!(!out.contains("binary_sha256"), "got {out}");
1363 assert!(!out.contains("description_hash"), "got {out}");
1364 assert!(!out.contains("publisher"), "got {out}");
1365 assert!(!out.contains("installed_at"), "got {out}");
1366 }
1367
1368 #[test]
1370 fn full_m9_entry_roundtrips_all_fields() {
1371 let yaml = r#"
1372name: weather
1373command: /opt/mcp/weather
1374args: []
1375binary_sha256: "3f4abca8b0e6e2c1d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b81c"
1376description_hash: "9a01b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9c7e2"
1377publisher:
1378 name: "@anthropic-mcp/weather"
1379 homepage: "https://github.com/anthropic-mcp/weather"
1380 registry_id: "@anthropic-mcp/weather@1.2.3"
1381installed_at: "2026-05-06T08:00:00Z"
1382"#;
1383 let entry: McpServerEntry = serde_yaml_ng::from_str(yaml).unwrap();
1384 assert!(
1385 entry
1386 .binary_sha256
1387 .as_deref()
1388 .unwrap()
1389 .starts_with("3f4abca8")
1390 );
1391 assert!(
1392 entry
1393 .description_hash
1394 .as_deref()
1395 .unwrap()
1396 .starts_with("9a01b2c3")
1397 );
1398 let pub_info = entry.publisher.clone().unwrap();
1399 assert_eq!(pub_info.name, "@anthropic-mcp/weather");
1400 assert_eq!(
1401 pub_info.homepage.as_deref(),
1402 Some("https://github.com/anthropic-mcp/weather"),
1403 );
1404 assert_eq!(
1405 pub_info.registry_id.as_deref(),
1406 Some("@anthropic-mcp/weather@1.2.3"),
1407 );
1408 let installed = entry.installed_at.unwrap();
1409 assert_eq!(installed.to_rfc3339(), "2026-05-06T08:00:00+00:00");
1410 }
1411
1412 #[test]
1416 fn partial_pin_only_binary_sha_roundtrips() {
1417 let yaml = r#"
1418name: weather
1419command: /opt/mcp/weather
1420args: []
1421binary_sha256: "deadbeef00112233445566778899aabbccddeeff00112233445566778899aabb"
1422"#;
1423 let entry: McpServerEntry = serde_yaml_ng::from_str(yaml).unwrap();
1424 assert_eq!(
1425 entry.binary_sha256.as_deref(),
1426 Some("deadbeef00112233445566778899aabbccddeeff00112233445566778899aabb"),
1427 );
1428 assert_eq!(entry.description_hash, None);
1429 assert_eq!(entry.publisher, None);
1430 }
1431
1432 #[test]
1435 fn publisher_minimal_just_name() {
1436 let yaml = r#"
1437name: weather
1438command: /opt/mcp/weather
1439args: []
1440publisher:
1441 name: "alice"
1442"#;
1443 let entry: McpServerEntry = serde_yaml_ng::from_str(yaml).unwrap();
1444 let p = entry.publisher.as_ref().unwrap();
1445 assert_eq!(p.name, "alice");
1446 assert_eq!(p.homepage, None);
1447 assert_eq!(p.registry_id, None);
1448
1449 let out = serde_yaml_ng::to_string(&entry).unwrap();
1451 assert!(!out.contains("homepage:"), "got {out}");
1452 assert!(!out.contains("registry_id:"), "got {out}");
1453 }
1454}
1455
1456#[cfg(test)]
1457mod voice_tests {
1458 use super::*;
1459 use std::str::FromStr;
1460
1461 #[test]
1462 fn voice_config_round_trips() {
1463 let base = include_str!("../tests/fixtures/profile_p0a_minimal.yaml");
1465 let yaml = format!("{base}voice:\n enabled: true\n voice_id: af_bella\n");
1466
1467 let profile: AgentProfile = serde_yaml_ng::from_str(&yaml).expect("parse with voice");
1468 assert!(profile.voice.enabled);
1469 assert_eq!(profile.voice.voice_id, VoiceId::AfBella);
1470
1471 let legacy: AgentProfile = serde_yaml_ng::from_str(base).expect("parse without voice");
1473 assert!(!legacy.voice.enabled);
1474 assert_eq!(legacy.voice.voice_id, VoiceId::AfHeart);
1475 }
1476
1477 #[test]
1478 fn voice_id_from_str_roundtrips() {
1479 let cases = [
1480 ("af_heart", VoiceId::AfHeart),
1481 ("af_bella", VoiceId::AfBella),
1482 ("af_nicole", VoiceId::AfNicole),
1483 ("am_adam", VoiceId::AmAdam),
1484 ("am_michael", VoiceId::AmMichael),
1485 ];
1486 for (s, expected) in cases {
1487 assert_eq!(VoiceId::from_str(s).unwrap(), expected);
1488 assert_eq!(expected.as_str(), s);
1489 }
1490 }
1491
1492 #[test]
1493 fn voice_id_from_str_rejects_unknown() {
1494 assert!(VoiceId::from_str("bogus").is_err());
1495 }
1496}
1497
1498#[cfg(test)]
1499mod idle_trigger_tests {
1500 use super::*;
1501
1502 #[test]
1503 fn idle_trigger_yaml_round_trip() {
1504 let yaml = r#"
1505restart: on_failure
1506idle_triggers:
1507 - after_secs: 3600
1508 message: "still there?"
1509 sends_to: other_agent
1510 cooldown_secs: 1800
1511 respect_quiet_hours: true
1512"#;
1513 let cfg: LifecycleConfig = serde_yaml_ng::from_str(yaml).unwrap();
1514 assert_eq!(cfg.idle_triggers.len(), 1);
1515 assert_eq!(cfg.idle_triggers[0].after_secs, 3600);
1516 assert_eq!(cfg.idle_triggers[0].message, "still there?");
1517 assert_eq!(
1518 cfg.idle_triggers[0].sends_to.as_deref(),
1519 Some("other_agent")
1520 );
1521 assert_eq!(cfg.idle_triggers[0].cooldown_secs, 1800);
1522 assert!(cfg.idle_triggers[0].respect_quiet_hours);
1523 }
1524
1525 #[test]
1526 fn idle_trigger_defaults_when_omitted() {
1527 let yaml = "restart: on_failure\n";
1528 let cfg: LifecycleConfig = serde_yaml_ng::from_str(yaml).unwrap();
1529 assert!(cfg.idle_triggers.is_empty());
1530 }
1531}
1532
1533#[cfg(test)]
1534mod appearance_tests {
1535 use super::*;
1536
1537 #[test]
1538 fn appearance_default_style_preset_is_default_blob() {
1539 assert_eq!(AgentAppearance::default().style_preset, "default-blob");
1540 }
1541
1542 #[test]
1543 fn appearance_default_behavior_is_normal() {
1544 assert_eq!(
1545 AgentAppearance::default().behavior_preset,
1546 BehaviorPreset::Normal
1547 );
1548 }
1549
1550 #[test]
1551 fn appearance_default_render_status_is_pending() {
1552 assert_eq!(
1553 AgentAppearance::default().render_status,
1554 RenderStatus::Pending
1555 );
1556 }
1557
1558 #[test]
1559 fn render_status_serde_round_trip() {
1560 let cases = [
1561 RenderStatus::Pending,
1562 RenderStatus::Rendering { done: 3, total: 12 },
1563 RenderStatus::Ready,
1564 RenderStatus::Failed {
1565 reason: "out of quota".into(),
1566 },
1567 ];
1568 for status in cases {
1569 let yaml = serde_yaml_ng::to_string(&status).expect("serialize");
1570 let back: RenderStatus = serde_yaml_ng::from_str(&yaml).expect("deserialize");
1571 assert_eq!(status, back);
1572 }
1573 }
1574
1575 #[test]
1576 fn agent_profile_with_appearance_round_trips() {
1577 let base = include_str!("../tests/fixtures/profile_p0a_minimal.yaml");
1578 let yaml = format!(
1579 "{base}appearance:\n style_preset: chiikawa\n render_status:\n status: ready\n"
1580 );
1581 let profile: AgentProfile = serde_yaml_ng::from_str(&yaml).expect("parse with appearance");
1582 assert_eq!(profile.appearance.style_preset, "chiikawa");
1583 assert_eq!(profile.appearance.render_status, RenderStatus::Ready);
1584
1585 let out = serde_yaml_ng::to_string(&profile).expect("serialize");
1586 let back: AgentProfile = serde_yaml_ng::from_str(&out).expect("re-parse");
1587 assert_eq!(profile.appearance, back.appearance);
1588 }
1589
1590 #[test]
1591 fn legacy_profile_without_appearance_uses_default() {
1592 let yaml = include_str!("../tests/fixtures/profile_p0a_minimal.yaml");
1593 let profile: AgentProfile = serde_yaml_ng::from_str(yaml).expect("parse legacy");
1594 assert_eq!(profile.appearance.style_preset, "default-blob");
1595 assert_eq!(profile.appearance.behavior_preset, BehaviorPreset::Normal);
1596 assert_eq!(profile.appearance.render_status, RenderStatus::Pending);
1597 }
1598
1599 #[test]
1600 fn legacy_profile_without_file_actions_or_action_pipeline_loads() {
1601 let yaml = include_str!("../tests/fixtures/profile_p0a_minimal.yaml");
1602 let p: AgentProfile = serde_yaml_ng::from_str(yaml).unwrap();
1603 assert!(p.file_actions.is_empty());
1604 assert_eq!(p.action_pipeline.deletion.cancel_window_minutes, 10);
1605 assert_eq!(p.action_pipeline.queue.max_concurrent, 3);
1606 }
1607}
1608
1609#[cfg(test)]
1610mod federation_tests {
1611 use super::*;
1612
1613 #[test]
1614 fn test_pattern_filter_default() {
1615 let f = PatternFilter::default();
1616 assert_eq!(f.max_count, 200);
1617 assert_eq!(f.importance_min, 0.0);
1618 assert!(f.tier.is_empty());
1619 }
1620
1621 #[test]
1622 fn test_federation_config_roundtrip() {
1623 let cfg = FederationConfig {
1624 filter: PatternFilter {
1625 tier: vec!["core".into()],
1626 max_count: 50,
1627 ..Default::default()
1628 },
1629 snapshot_ref: Some(SnapshotRef {
1630 knowledge_commit: "abc123def456".into(),
1631 taken_at: "2026-05-19T00:00:00Z".into(),
1632 filter: PatternFilter::default(),
1633 }),
1634 evidence_flush_interval_minutes: 15,
1635 };
1636 let yaml = serde_yaml_ng::to_string(&cfg).unwrap();
1637 let back: FederationConfig = serde_yaml_ng::from_str(&yaml).unwrap();
1638 assert_eq!(cfg, back);
1639 }
1640
1641 #[test]
1642 fn test_agent_profile_federation_defaults() {
1643 let cfg = FederationConfig::default();
1647 assert_eq!(cfg.evidence_flush_interval_minutes, 0);
1648 assert!(cfg.snapshot_ref.is_none());
1649 }
1650}
1651
1652#[cfg(test)]
1653mod skill_card_tests {
1654 use super::*;
1655
1656 #[test]
1657 fn installed_skills_default_to_empty_when_absent() {
1658 let yaml = include_str!("../tests/fixtures/profile_p0a_minimal.yaml");
1659 let p: AgentProfile = serde_yaml_ng::from_str(yaml).unwrap();
1660 assert!(p.installed_skills.is_empty());
1661 }
1662
1663 #[test]
1664 fn installed_skills_roundtrip_preserves_entries() {
1665 let base = include_str!("../tests/fixtures/profile_p0a_minimal.yaml");
1666 let yaml = format!(
1667 "{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"
1668 );
1669 let p: AgentProfile = serde_yaml_ng::from_str(&yaml).unwrap();
1670 assert_eq!(p.installed_skills.len(), 1);
1671 assert_eq!(p.installed_skills[0].name, "s1");
1672 assert_eq!(p.installed_skills[0].abstract_text, "does things");
1673 assert_eq!(p.installed_skills[0].transfer_chain, vec!["agent://alice"]);
1674
1675 let out = serde_yaml_ng::to_string(&p).unwrap();
1676 assert!(out.contains("abstract: does things"));
1677 assert!(out.contains("pattern: /find"));
1678
1679 let back: AgentProfile = serde_yaml_ng::from_str(&out).unwrap();
1680 assert_eq!(p.installed_skills, back.installed_skills);
1681 }
1682
1683 #[test]
1684 fn installed_skills_minimal_entry_serializes_compactly() {
1685 let entry = SkillCardEntry {
1687 name: "minimal".into(),
1688 ..Default::default()
1689 };
1690 let yaml = serde_yaml_ng::to_string(&entry).unwrap();
1691 assert!(yaml.contains("name: minimal"));
1692 assert!(
1693 !yaml.contains("version:"),
1694 "empty version must be skipped: {yaml}"
1695 );
1696 assert!(
1697 !yaml.contains("publisher:"),
1698 "empty publisher must be skipped: {yaml}"
1699 );
1700 assert!(
1701 !yaml.contains("abstract:"),
1702 "empty abstract must be skipped: {yaml}"
1703 );
1704 }
1705}
1706
1707#[cfg(test)]
1708mod tool_policy_tests {
1709 use super::*;
1710
1711 fn rules() -> Vec<ToolRule> {
1712 vec![
1713 ToolRule {
1714 pattern: "mcp__github__merge_pr".into(),
1715 policy: ToolPolicy::Ask,
1716 },
1717 ToolRule {
1718 pattern: "mcp__github__*".into(),
1719 policy: ToolPolicy::Allow,
1720 },
1721 ToolRule {
1722 pattern: "mcp__*".into(),
1723 policy: ToolPolicy::Deny,
1724 },
1725 ToolRule {
1726 pattern: "bash".into(),
1727 policy: ToolPolicy::Allow,
1728 },
1729 ]
1730 }
1731
1732 #[test]
1733 fn exact_beats_glob() {
1734 assert_eq!(
1735 resolve_tool_policy(&rules(), "mcp__github__merge_pr"),
1736 ToolPolicy::Ask
1737 );
1738 }
1739
1740 #[test]
1741 fn longer_glob_wins() {
1742 assert_eq!(
1743 resolve_tool_policy(&rules(), "mcp__github__create_issue"),
1744 ToolPolicy::Allow
1745 );
1746 }
1747
1748 #[test]
1749 fn shorter_glob_fallback() {
1750 assert_eq!(
1751 resolve_tool_policy(&rules(), "mcp__slack__send"),
1752 ToolPolicy::Deny
1753 );
1754 }
1755
1756 #[test]
1757 fn exact_bash() {
1758 assert_eq!(resolve_tool_policy(&rules(), "bash"), ToolPolicy::Allow);
1759 }
1760
1761 #[test]
1762 fn unknown_tool_defaults_ask() {
1763 assert_eq!(
1764 resolve_tool_policy(&rules(), "unknown_tool"),
1765 ToolPolicy::Ask
1766 );
1767 }
1768
1769 #[test]
1770 fn empty_rules_defaults_ask() {
1771 assert_eq!(resolve_tool_policy(&[], "bash"), ToolPolicy::Ask);
1772 }
1773
1774 fn minimal_entitlements_yaml() -> &'static str {
1775 "network:\n inbound: {}\n outbound:\n mode: off\nfilesystem: {}\nprocesses:\n spawn:\n mode: none\n"
1776 }
1777
1778 #[test]
1779 fn entitlements_tools_defaults_empty() {
1780 let e: Entitlements = serde_yaml_ng::from_str(minimal_entitlements_yaml()).unwrap();
1781 assert!(e.tools.is_empty());
1782 }
1783
1784 #[test]
1785 fn entitlements_tools_roundtrip() {
1786 let base = minimal_entitlements_yaml();
1787 let yaml = format!("{base}tools:\n - pattern: \"mcp__github__*\"\n policy: allow\n");
1788 let e: Entitlements = serde_yaml_ng::from_str(&yaml).unwrap();
1789 assert_eq!(e.tools.len(), 1);
1790 assert_eq!(e.tools[0].policy, ToolPolicy::Allow);
1791 let y = serde_yaml_ng::to_string(&e).unwrap();
1792 let back: Entitlements = serde_yaml_ng::from_str(&y).unwrap();
1793 assert_eq!(back.tools.len(), 1);
1794 assert_eq!(back.tools[0].policy, ToolPolicy::Allow);
1795 }
1796}