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