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 #[serde(default, skip_serializing_if = "Option::is_none")]
57 pub role: Option<String>,
58 pub version: String,
59 pub persona: Persona,
60 pub sys_prompt_file: String,
61 pub model: ModelConfig,
62 #[serde(default, skip_serializing_if = "Option::is_none")]
65 pub model_ref: Option<String>,
66 #[serde(default)]
67 pub mcp_servers: Vec<McpServerEntry>,
68 #[serde(default)]
69 pub skills: Vec<String>,
70 #[serde(default, skip_serializing_if = "Vec::is_empty")]
74 pub installed_skills: Vec<SkillCardEntry>,
75 #[serde(default, skip_serializing_if = "Vec::is_empty")]
80 pub disabled_skills: Vec<String>,
81
82 #[serde(default, skip_serializing_if = "Vec::is_empty")]
86 pub disabled_mcp: Vec<String>,
87 #[serde(default, skip_serializing_if = "Vec::is_empty")]
91 pub addons: Vec<AddonRef>,
92 pub transport: TransportConfig,
93 pub communication: CommunicationConfig,
94 #[serde(default)]
95 pub capabilities: Vec<String>,
96 pub entitlements: Entitlements,
97 #[serde(default)]
98 pub notifications: NotificationsConfig,
99 pub retry: RetryConfig,
100 pub lifecycle: LifecycleConfig,
101 #[serde(default)]
104 pub identity: IdentityConfig,
105 #[serde(default)]
106 pub file_transfer: FileTransferConfig,
107 #[serde(default)]
108 pub deployment: DeploymentConfig,
109 #[serde(default)]
112 pub companion: CompanionConfig,
113 #[serde(default)]
115 pub hitl: HitlConfig,
116 #[serde(default)]
118 pub voice: VoiceConfig,
119 #[serde(default)]
121 pub hooks: crate::HooksConfig,
122 #[serde(default)]
125 pub trusted_peers: Vec<crate::bridge::peer::TrustedPeer>,
126 pub created_at: String,
127 pub updated_at: String,
128 #[serde(default)]
130 pub appearance: AgentAppearance,
131 #[serde(default)]
133 pub federation: FederationConfig,
134
135 #[serde(default)]
139 pub file_actions: Vec<crate::action::FileAction>,
140
141 #[serde(default)]
143 pub action_pipeline: crate::action::ActionPipelineConfig,
144}
145
146fn default_algorithm() -> String {
147 "ed25519".into()
148}
149
150pub const SUPPORTED_ALGORITHMS: &[&str] = &["ed25519"];
152
153#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
154pub struct IdentityConfig {
155 #[serde(default)]
158 pub pubkey: String,
159 #[serde(default, skip_serializing_if = "Option::is_none")]
161 pub owner: Option<String>,
162
163 #[serde(default = "default_algorithm")]
166 pub algorithm: String,
167 #[serde(default)]
169 pub key_version: u32,
170 #[serde(default, skip_serializing_if = "Option::is_none")]
172 pub created_at_key: Option<String>,
173 #[serde(default, skip_serializing_if = "Option::is_none")]
175 pub previous_pubkey: Option<String>,
176 #[serde(default, skip_serializing_if = "Option::is_none")]
178 pub previous_key_version: Option<u32>,
179 #[serde(default, skip_serializing_if = "Option::is_none")]
182 pub grace_expires_at: Option<String>,
183 #[serde(default, skip_serializing_if = "Option::is_none")]
185 pub rotated_at: Option<String>,
186 #[serde(default, skip_serializing_if = "Option::is_none")]
188 pub emergency_rekey_at: Option<String>,
189}
190
191impl Default for IdentityConfig {
192 fn default() -> Self {
193 Self {
194 pubkey: String::new(),
195 owner: None,
196 algorithm: default_algorithm(),
197 key_version: 0,
198 created_at_key: None,
199 previous_pubkey: None,
200 previous_key_version: None,
201 grace_expires_at: None,
202 rotated_at: None,
203 emergency_rekey_at: None,
204 }
205 }
206}
207
208#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
209pub struct Persona {
210 pub category: PersonaCategory,
211 pub description: String,
212 pub traits: PersonaTraits,
213}
214
215#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
216#[serde(rename_all = "lowercase")]
217pub enum PersonaCategory {
218 Research,
219 Automation,
220 Monitor,
221 Notify,
222 Commerce,
223 Custom,
224}
225
226#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
227pub struct PersonaTraits {
228 pub tone: String,
229 pub risk: String,
230 pub verbosity: String,
231}
232
233#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
234pub struct ModelConfig {
235 pub provider: String,
236 pub name: String,
237 #[serde(default)]
238 pub params: BTreeMap<String, serde_yaml_ng::Value>,
239}
240
241#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
242pub struct McpServerEntry {
243 pub name: String,
244 pub command: String,
245 #[serde(default)]
246 pub args: Vec<String>,
247
248 #[serde(default, skip_serializing_if = "Option::is_none")]
254 pub binary_sha256: Option<String>,
255
256 #[serde(default, skip_serializing_if = "Option::is_none")]
262 pub description_hash: Option<String>,
263
264 #[serde(default, skip_serializing_if = "Option::is_none")]
268 pub publisher: Option<McpPublisherInfo>,
269
270 #[serde(default, skip_serializing_if = "Option::is_none")]
274 pub installed_at: Option<chrono::DateTime<chrono::Utc>>,
275
276 #[serde(default, skip_serializing_if = "Option::is_none")]
280 pub timeout_secs: Option<u32>,
281}
282
283#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
292pub struct AddonRef {
293 pub id: String,
295 pub source: String,
297 #[serde(default)]
298 pub enabled: bool,
299 #[serde(default, skip_serializing_if = "Vec::is_empty")]
300 pub skills: Vec<String>,
301 #[serde(default, skip_serializing_if = "Vec::is_empty")]
302 pub mcp: Vec<String>,
303 #[serde(default, skip_serializing_if = "Vec::is_empty")]
304 pub commands: Vec<String>,
305}
306
307#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
313pub struct McpPublisherInfo {
314 pub name: String,
317
318 #[serde(default, skip_serializing_if = "Option::is_none")]
322 pub homepage: Option<String>,
323
324 #[serde(default, skip_serializing_if = "Option::is_none")]
327 pub registry_id: Option<String>,
328}
329
330#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
331pub struct TransportConfig {
332 pub stdio: bool,
333 pub socket: SocketTransportConfig,
334 #[serde(default)]
335 pub tcp: TcpTransportConfig,
336 #[serde(default)]
340 pub webhook: WebhookTransportConfig,
341}
342
343#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
344pub struct TcpTransportConfig {
345 #[serde(default)]
346 pub enabled: bool,
347 #[serde(default)]
348 pub bind: String,
349 #[serde(default)]
350 pub noise: NoiseConfig,
351}
352
353#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
367pub struct WebhookTransportConfig {
368 #[serde(default)]
369 pub enabled: bool,
370 #[serde(default = "default_webhook_bind")]
371 pub bind: String,
372 #[serde(default = "default_webhook_port")]
373 pub port: u16,
374 #[serde(default)]
378 pub hmac_secret_ref: String,
379}
380
381fn default_webhook_bind() -> String {
382 "127.0.0.1".to_string()
383}
384
385fn default_webhook_port() -> u16 {
386 6789
387}
388
389impl Default for WebhookTransportConfig {
390 fn default() -> Self {
391 Self {
392 enabled: false,
393 bind: default_webhook_bind(),
394 port: default_webhook_port(),
395 hmac_secret_ref: String::new(),
396 }
397 }
398}
399
400#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
401pub struct NoiseConfig {
402 pub pattern: String,
403}
404
405impl Default for NoiseConfig {
406 fn default() -> Self {
407 Self {
408 pattern: "Noise_XK_25519_ChaChaPoly_BLAKE2s".into(),
409 }
410 }
411}
412
413#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
414pub struct SocketTransportConfig {
415 pub enabled: bool,
416 pub bind: String, #[serde(default, skip_serializing_if = "Option::is_none")]
418 pub auth: Option<AuthConfig>,
419}
420
421#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
422pub struct AuthConfig {
423 pub scheme: String,
424 pub token_file: String,
425}
426
427#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
428pub struct CommunicationConfig {
429 #[serde(default = "default_accepts_all")]
430 pub accepts_from: Vec<String>,
431 #[serde(default)]
432 pub sends_to: Vec<String>,
433}
434fn default_accepts_all() -> Vec<String> {
435 vec!["*".to_string()]
436}
437
438#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
439pub struct Entitlements {
440 pub network: NetworkEntitlement,
441 pub filesystem: FilesystemEntitlement,
442 pub processes: ProcessesEntitlement,
443 #[serde(default)]
444 pub syscalls: SyscallsEntitlement,
445 #[serde(default)]
446 pub limits: LimitsEntitlement,
447 #[serde(default)]
450 pub llm: crate::bridge::llm_entitlement::LlmEntitlement,
451 #[serde(default, skip_serializing_if = "Vec::is_empty")]
453 pub tools: Vec<ToolRule>,
454 #[serde(default = "default_true")]
459 pub fail_closed_on_sandbox_error: bool,
460}
461
462#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
463pub struct NetworkEntitlement {
464 pub inbound: InboundNetwork,
465 pub outbound: OutboundNetwork,
466}
467
468#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
469pub struct InboundNetwork {
470 #[serde(default)]
471 pub ports: Vec<u16>,
472}
473
474#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
475pub struct OutboundNetwork {
476 pub mode: NetworkOutboundMode,
477 #[serde(default)]
478 pub allow_hosts: Vec<String>,
479 #[serde(default = "default_protocols")]
480 pub protocols: Vec<String>,
481 #[serde(default)]
482 pub resolve_dns: ResolveDnsConfig,
483}
484fn default_protocols() -> Vec<String> {
485 vec!["tcp".to_string()]
486}
487
488#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
489#[serde(rename_all = "lowercase")]
490pub enum NetworkOutboundMode {
491 Unrestricted,
492 Restricted,
493 Off,
494}
495
496#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
497pub struct ResolveDnsConfig {
498 #[serde(default = "default_dns_mode")]
499 pub mode: String,
500 #[serde(default)]
501 pub servers: Vec<String>,
502}
503impl Default for ResolveDnsConfig {
504 fn default() -> Self {
505 Self {
506 mode: default_dns_mode(),
507 servers: vec![],
508 }
509 }
510}
511fn default_dns_mode() -> String {
512 "system".to_string()
513}
514
515#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
516pub struct FilesystemEntitlement {
517 #[serde(default)]
518 pub read: Vec<String>,
519 #[serde(default)]
520 pub write: Vec<String>,
521 #[serde(default)]
522 pub deny: Vec<String>,
523}
524
525#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
526pub struct ProcessesEntitlement {
527 pub spawn: SpawnEntitlement,
528}
529
530#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
531pub struct SpawnEntitlement {
532 pub mode: SpawnMode,
533 #[serde(default)]
534 pub allowed: Vec<String>,
535}
536
537#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
538#[serde(rename_all = "lowercase")]
539pub enum SpawnMode {
540 Allowlist,
541 Any,
542 None,
543}
544
545#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
546pub struct SyscallsEntitlement {
547 #[serde(default = "default_syscalls_mode")]
548 pub mode: String,
549 #[serde(default)]
550 pub extra_deny: Vec<String>,
551}
552fn default_syscalls_mode() -> String {
553 "default".to_string()
554}
555
556#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
557pub struct LimitsEntitlement {
558 #[serde(default)]
559 pub cpu_seconds: Option<u64>,
560 #[serde(default = "default_memory_mb")]
561 pub memory_mb: u64,
562 #[serde(default = "default_fds")]
563 pub file_descriptors: u32,
564 #[serde(default = "default_procs")]
565 pub processes: u32,
566}
567fn default_memory_mb() -> u64 {
568 512
569}
570fn default_fds() -> u32 {
571 1024
572}
573fn default_procs() -> u32 {
574 32
575}
576
577#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
578#[serde(rename_all = "lowercase")]
579pub enum ToolPolicy {
580 Allow,
581 #[default]
582 Ask,
583 Deny,
584}
585
586#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
587pub struct ToolRule {
588 pub pattern: String,
589 pub policy: ToolPolicy,
590 #[serde(default, skip_serializing_if = "Option::is_none")]
593 pub risk: Option<crate::hitl::RiskTier>,
594}
595
596pub fn resolve_tool_policy(rules: &[ToolRule], tool_name: &str) -> ToolPolicy {
600 for rule in rules {
601 if rule.pattern == tool_name {
602 return rule.policy;
603 }
604 }
605 let mut best: Option<(&ToolRule, usize)> = None;
606 for rule in rules {
607 if let Some(prefix) = rule.pattern.strip_suffix('*')
608 && tool_name.starts_with(prefix)
609 {
610 let len = prefix.len();
611 if best.is_none_or(|(_, best_len)| len > best_len) {
612 best = Some((rule, len));
613 }
614 }
615 }
616 if let Some((rule, _)) = best {
617 return rule.policy;
618 }
619 ToolPolicy::default()
620}
621
622#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
623pub struct NotificationsConfig {
624 #[serde(default)]
625 pub on_task_complete: Vec<NotificationTarget>,
626 #[serde(default)]
627 pub on_error: Vec<NotificationTarget>,
628 #[serde(default)]
629 pub on_shutdown: Vec<NotificationTarget>,
630}
631
632#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
633#[serde(tag = "target", rename_all = "lowercase")]
634pub enum NotificationTarget {
635 Agent {
636 name: String,
637 },
638 Commander,
639 Email {
640 address: String,
641 #[serde(default)]
642 smtp_config_file: Option<String>,
643 },
644 Slack {
645 #[serde(default)]
646 channel: Option<String>,
647 #[serde(default)]
648 webhook_url_env: Option<String>,
649 },
650 Webpush {
651 url: String,
652 },
653 Webhook {
654 url: String,
655 #[serde(default = "default_post")]
656 method: String,
657 #[serde(default)]
658 auth: Option<String>,
659 },
660}
661fn default_post() -> String {
662 "POST".to_string()
663}
664
665#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
666pub struct RetryConfig {
667 pub llm: RetryPolicy,
668 pub tool: RetryPolicy,
669}
670
671#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
672pub struct RetryPolicy {
673 pub max_retries: u32,
674 pub backoff: BackoffStrategy,
675 pub initial_delay_ms: u64,
676 #[serde(default)]
677 pub max_delay_ms: Option<u64>,
678 #[serde(default)]
679 pub retry_on: Vec<String>,
680}
681
682#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
683#[serde(rename_all = "lowercase")]
684pub enum BackoffStrategy {
685 Linear,
686 Exponential,
687 Fixed,
688}
689
690#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
691pub struct LifecycleConfig {
692 pub restart: RestartPolicy,
693 #[serde(default = "default_max_restarts")]
694 pub max_restarts: u32,
695 #[serde(default = "default_window")]
696 pub restart_window_secs: u64,
697 #[serde(default = "default_stop_timeout")]
698 pub stop_timeout_secs: u64,
699 #[serde(default = "default_mcp_required")]
700 pub mcp_required: bool,
701 #[serde(default)]
702 pub execution: ExecutionMode,
703 #[serde(default)]
704 pub schedule: Vec<ScheduleEntry>,
705 #[serde(default)]
706 pub idle_triggers: Vec<IdleTrigger>,
707}
708fn default_max_restarts() -> u32 {
709 3
710}
711fn default_window() -> u64 {
712 600
713}
714fn default_stop_timeout() -> u64 {
715 15
716}
717fn default_mcp_required() -> bool {
718 true
719}
720
721#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
722#[serde(rename_all = "snake_case")]
723pub enum RestartPolicy {
724 Never,
725 OnFailure,
726 Always,
727}
728
729#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
730#[serde(rename_all = "snake_case")]
731pub enum ExecutionMode {
732 #[default]
733 Daemon,
734 OnDemand,
735}
736
737#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
738pub struct ScheduleEntry {
739 pub cron: String,
740 pub message: String,
741 #[serde(default, skip_serializing_if = "Option::is_none")]
742 pub sends_to: Option<String>,
743}
744
745#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
746pub struct IdleTrigger {
747 pub after_secs: u64,
749 pub message: String,
751 #[serde(default, skip_serializing_if = "Option::is_none")]
753 pub sends_to: Option<String>,
754 #[serde(default = "default_idle_cooldown")]
757 pub cooldown_secs: u64,
758 #[serde(default = "default_true")]
761 pub respect_quiet_hours: bool,
762}
763
764fn default_idle_cooldown() -> u64 {
765 600
766}
767pub fn name_enabled(denylist: &[String], name: &str) -> bool {
769 !denylist.iter().any(|n| n == name)
770}
771
772pub fn set_denylist(list: &mut Vec<String>, name: &str, enabled: bool) {
775 if enabled {
776 list.retain(|n| n != name);
777 } else if !list.iter().any(|n| n == name) {
778 list.push(name.to_string());
779 }
780}
781
782fn default_true() -> bool {
783 true
784}
785
786#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
787pub struct FileTransferConfig {
788 #[serde(default = "default_accept_max")]
789 pub accept_incoming_file_max_bytes: u64,
790 #[serde(default = "default_accept_total")]
791 pub accept_incoming_total_per_hour: u64,
792 #[serde(default = "default_approval_threshold")]
793 pub require_approval_above_bytes: u64,
794 #[serde(default = "default_reject_paths")]
795 pub reject_paths: Vec<String>,
796 #[serde(default = "default_allowed_mime")]
797 pub allowed_mime_types: Vec<String>,
798}
799
800impl Default for FileTransferConfig {
801 fn default() -> Self {
802 Self {
803 accept_incoming_file_max_bytes: default_accept_max(),
804 accept_incoming_total_per_hour: default_accept_total(),
805 require_approval_above_bytes: default_approval_threshold(),
806 reject_paths: default_reject_paths(),
807 allowed_mime_types: default_allowed_mime(),
808 }
809 }
810}
811
812fn default_accept_max() -> u64 {
813 10_485_760
814}
815fn default_accept_total() -> u64 {
816 104_857_600
817}
818fn default_approval_threshold() -> u64 {
819 10_485_760
820}
821fn default_reject_paths() -> Vec<String> {
822 vec!["~/.ssh".into(), "~/.aws".into(), "~/.gnupg".into()]
823}
824fn default_allowed_mime() -> Vec<String> {
825 vec!["*".into()]
826}
827
828#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
829#[serde(rename_all = "snake_case")]
830pub enum DeploymentType {
831 #[default]
832 Laptop,
833 Vm,
834 Docker,
835 K8s,
836 Lambda,
837}
838
839#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
840pub struct DeploymentConfig {
841 #[serde(rename = "type", default)]
842 pub deployment_type: DeploymentType,
843 #[serde(default, skip_serializing_if = "Option::is_none")]
844 pub region: Option<String>,
845 #[serde(default = "default_env")]
846 pub environment: Option<String>,
847}
848
849impl Default for DeploymentConfig {
850 fn default() -> Self {
851 Self {
852 deployment_type: DeploymentType::default(),
853 region: None,
854 environment: default_env(),
855 }
856 }
857}
858
859fn default_env() -> Option<String> {
860 Some("dev".into())
861}
862
863#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
864pub struct LockFile {
865 pub schema: u32,
866 pub uuid: String,
867 pub name: String,
868 pub pid: u32,
869 pub ppid: u32,
870 pub started_at: String,
871 pub binary_version: String,
872 pub transports: LockTransports,
873 pub card_digest: String,
874 pub capabilities: Vec<String>,
875 #[serde(default)]
878 pub build_sha: String,
879 #[serde(default)]
882 pub proto_version: u32,
883}
884
885#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
886pub struct LockTransports {
887 pub stdio: bool,
888 #[serde(default)]
889 pub unix_socket: Option<String>,
890 #[serde(default)]
891 pub tcp: Option<String>,
892 #[serde(default)]
897 pub webhook: Option<String>,
898}
899
900#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
907#[serde(rename_all = "snake_case")]
908pub enum VoiceId {
909 #[default]
911 AfHeart,
912 AfBella,
913 AfNicole,
914 AmAdam,
915 AmMichael,
916}
917
918impl VoiceId {
919 pub fn style_index(&self) -> usize {
921 match self {
922 VoiceId::AfHeart => 0,
923 VoiceId::AfBella => 1,
924 VoiceId::AfNicole => 2,
925 VoiceId::AmAdam => 3,
926 VoiceId::AmMichael => 4,
927 }
928 }
929
930 pub fn as_str(&self) -> &'static str {
932 match self {
933 VoiceId::AfHeart => "af_heart",
934 VoiceId::AfBella => "af_bella",
935 VoiceId::AfNicole => "af_nicole",
936 VoiceId::AmAdam => "am_adam",
937 VoiceId::AmMichael => "am_michael",
938 }
939 }
940}
941
942impl std::str::FromStr for VoiceId {
943 type Err = anyhow::Error;
944
945 fn from_str(s: &str) -> anyhow::Result<Self> {
946 match s {
947 "af_heart" => Ok(VoiceId::AfHeart),
948 "af_bella" => Ok(VoiceId::AfBella),
949 "af_nicole" => Ok(VoiceId::AfNicole),
950 "am_adam" => Ok(VoiceId::AmAdam),
951 "am_michael" => Ok(VoiceId::AmMichael),
952 other => anyhow::bail!(
953 "unknown voice ID '{other}' \
954 (valid: af_heart, af_bella, af_nicole, am_adam, am_michael)"
955 ),
956 }
957 }
958}
959
960#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
963pub struct VoiceConfig {
964 #[serde(default)]
966 pub enabled: bool,
967 #[serde(default)]
969 pub voice_id: VoiceId,
970 #[serde(default, skip_serializing_if = "Option::is_none")]
973 pub input_device: Option<String>,
974}
975
976#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
981pub struct HitlConfig {
982 #[serde(default = "default_hitl_timeout_secs")]
983 pub timeout_secs: u32,
984 #[serde(default)]
988 pub max_iterations: Option<u32>,
989 #[serde(default)]
994 pub max_tokens: Option<u64>,
995}
996
997fn default_hitl_timeout_secs() -> u32 {
998 300
999}
1000
1001impl Default for HitlConfig {
1002 fn default() -> Self {
1003 Self {
1004 timeout_secs: default_hitl_timeout_secs(),
1005 max_iterations: None,
1006 max_tokens: None,
1007 }
1008 }
1009}
1010
1011#[cfg(test)]
1012mod hitl_tests {
1013 use super::*;
1014
1015 #[test]
1016 fn hitl_config_default_max_iterations_is_none() {
1017 let cfg = HitlConfig::default();
1018 assert!(cfg.max_iterations.is_none());
1019 }
1020
1021 #[test]
1022 fn hitl_config_max_iterations_explicit() {
1023 let cfg: HitlConfig = serde_yaml::from_str("timeout_secs: 60\nmax_iterations: 5").unwrap();
1024 assert_eq!(cfg.max_iterations, Some(5));
1025 }
1026
1027 #[test]
1028 fn hitl_config_default_max_tokens_is_none() {
1029 let cfg = HitlConfig::default();
1030 assert!(cfg.max_tokens.is_none());
1031 }
1032
1033 #[test]
1034 fn hitl_config_max_tokens_explicit() {
1035 let cfg: HitlConfig = serde_yaml::from_str("timeout_secs: 60\nmax_tokens: 250000").unwrap();
1036 assert_eq!(cfg.max_tokens, Some(250_000));
1037 }
1038}
1039
1040#[derive(Debug, Default, Clone, PartialEq, Serialize, Deserialize)]
1046pub struct CompanionConfig {
1047 #[serde(default)]
1048 pub enabled: bool,
1049 #[serde(default = "default_locale")]
1050 pub locale: String,
1051 #[serde(default)]
1052 pub relationship: Relationship,
1053 #[serde(default)]
1054 pub voice_overrides: VoiceOverrides,
1055 #[serde(default)]
1056 pub onboarding: OnboardingState,
1057 #[serde(default)]
1058 pub rhythm: RhythmConfig,
1059 #[serde(default)]
1060 pub proactive: ProactiveConfig,
1061}
1062
1063pub fn default_locale() -> String {
1066 std::env::var("LANG")
1067 .ok()
1068 .and_then(|v| v.split('.').next().map(|s| s.replace('_', "-")))
1069 .unwrap_or_else(|| "en-US".into())
1070}
1071
1072#[derive(Debug, Default, Clone, PartialEq, Serialize, Deserialize)]
1073pub struct VoiceOverrides {
1074 #[serde(default, skip_serializing_if = "Option::is_none")]
1075 pub name_for_user: Option<String>,
1076 #[serde(default, skip_serializing_if = "Option::is_none")]
1077 pub formality: Option<Formality>,
1078 #[serde(default, skip_serializing_if = "Option::is_none")]
1079 pub extra_instructions: Option<String>,
1080}
1081
1082#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1083pub struct FirstMemory {
1084 pub text: String,
1085 pub established_at: chrono::DateTime<chrono::Utc>,
1086}
1087
1088#[derive(Debug, Default, Clone, PartialEq, Serialize, Deserialize)]
1089pub struct OnboardingState {
1090 #[serde(default, skip_serializing_if = "Option::is_none")]
1091 pub completed_at: Option<chrono::DateTime<chrono::Utc>>,
1092 #[serde(default)]
1093 pub version: u32,
1094 #[serde(default, skip_serializing_if = "Option::is_none")]
1095 pub agent_display_name: Option<String>,
1096 #[serde(default, skip_serializing_if = "Option::is_none")]
1097 pub first_memory: Option<FirstMemory>,
1098}
1099
1100#[derive(Debug, Default, Clone, PartialEq, Serialize, Deserialize)]
1103pub struct RhythmConfig {
1104 #[serde(default)]
1105 pub enabled: bool,
1106}
1107
1108#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1109pub struct ProactiveConfig {
1110 #[serde(default)]
1111 pub enabled: bool,
1112 #[serde(default, skip_serializing_if = "Option::is_none")]
1114 pub learning_until: Option<chrono::DateTime<chrono::Utc>>,
1115 #[serde(default, skip_serializing_if = "Option::is_none")]
1116 pub quiet_hours: Option<QuietHours>,
1117 #[serde(default, skip_serializing_if = "Option::is_none")]
1118 pub active_hours: Option<ActiveHours>,
1119 #[serde(default = "default_daily_cap")]
1120 pub daily_cap: u8,
1121 #[serde(default = "default_channels")]
1122 pub channels: Vec<String>,
1123 #[serde(default, skip_serializing_if = "Option::is_none")]
1124 pub paused_until: Option<chrono::DateTime<chrono::Utc>>,
1125}
1126
1127impl Default for ProactiveConfig {
1128 fn default() -> Self {
1129 Self {
1130 enabled: false,
1131 learning_until: None,
1132 quiet_hours: None,
1133 active_hours: None,
1134 daily_cap: default_daily_cap(),
1135 channels: default_channels(),
1136 paused_until: None,
1137 }
1138 }
1139}
1140
1141fn default_daily_cap() -> u8 {
1142 3
1143}
1144fn default_channels() -> Vec<String> {
1145 vec!["stdout".into()]
1146}
1147
1148#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1149pub struct QuietHours {
1150 pub start: String,
1151 pub end: String,
1152}
1153
1154#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1155pub struct ActiveHours {
1156 pub start: String,
1157 pub end: String,
1158}
1159
1160#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
1165pub struct AgentAppearance {
1166 #[serde(default = "default_style_preset")]
1168 pub style_preset: String,
1169 #[serde(default)]
1170 pub behavior_preset: BehaviorPreset,
1171 #[serde(default, skip_serializing_if = "Option::is_none")]
1173 pub source_image_path: Option<std::path::PathBuf>,
1174 #[serde(default = "default_expressions_dir")]
1176 pub expressions_dir: std::path::PathBuf,
1177 #[serde(default, skip_serializing_if = "Option::is_none")]
1178 pub last_rendered_at: Option<chrono::DateTime<chrono::Utc>>,
1179 #[serde(default)]
1180 pub render_status: RenderStatus,
1181}
1182
1183fn default_style_preset() -> String {
1184 "default-blob".into()
1185}
1186
1187fn default_expressions_dir() -> std::path::PathBuf {
1188 std::path::PathBuf::from("expressions")
1189}
1190
1191impl Default for AgentAppearance {
1192 fn default() -> Self {
1193 Self {
1194 style_preset: default_style_preset(),
1195 behavior_preset: BehaviorPreset::Normal,
1196 source_image_path: None,
1197 expressions_dir: default_expressions_dir(),
1198 last_rendered_at: None,
1199 render_status: RenderStatus::Pending,
1200 }
1201 }
1202}
1203
1204#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
1205#[serde(rename_all = "snake_case")]
1206pub enum BehaviorPreset {
1207 Quiet,
1208 #[default]
1209 Normal,
1210 Lively,
1211}
1212
1213#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
1214#[serde(tag = "status", rename_all = "snake_case")]
1215pub enum RenderStatus {
1216 #[default]
1217 Pending,
1218 Rendering {
1219 done: u8,
1220 total: u8,
1221 },
1222 Ready,
1223 Failed {
1224 reason: String,
1225 },
1226}
1227
1228#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
1234#[serde(rename_all = "kebab-case")]
1235pub enum SnapshotPolicy {
1236 #[default]
1237 PullOnStart,
1238 PullPeriodic,
1239 Manual,
1240}
1241
1242#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
1244pub struct PatternFilter {
1245 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1246 pub applies_in: Vec<String>,
1247 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1248 pub tier: Vec<String>,
1249 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1250 pub maturity: Vec<String>,
1251 #[serde(default)]
1252 pub importance_min: f64,
1253 #[serde(default = "default_max_snapshot_count")]
1254 pub max_count: usize,
1255 #[serde(default)]
1256 pub snapshot_policy: SnapshotPolicy,
1257}
1258
1259fn default_max_snapshot_count() -> usize {
1260 200
1261}
1262
1263impl Default for PatternFilter {
1264 fn default() -> Self {
1265 Self {
1266 applies_in: vec![],
1267 tier: vec![],
1268 maturity: vec![],
1269 importance_min: 0.0,
1270 max_count: 200,
1271 snapshot_policy: SnapshotPolicy::default(),
1272 }
1273 }
1274}
1275
1276#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
1278pub struct SnapshotRef {
1279 pub knowledge_commit: String,
1280 pub taken_at: String,
1281 pub filter: PatternFilter,
1282}
1283
1284#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
1286pub struct FederationConfig {
1287 #[serde(default)]
1288 pub filter: PatternFilter,
1289 #[serde(default, skip_serializing_if = "Option::is_none")]
1290 pub snapshot_ref: Option<SnapshotRef>,
1291 #[serde(default)]
1292 pub evidence_flush_interval_minutes: u32,
1293}
1294
1295impl AgentProfile {
1296 #[doc(hidden)]
1302 pub fn default_for_tests() -> Self {
1303 serde_yaml_ng::from_str(include_str!("../tests/fixtures/minimal_profile.yaml"))
1304 .expect("minimal profile fixture")
1305 }
1306
1307 pub fn group_of(&self, name: &str) -> Option<&AddonRef> {
1309 self.addons.iter().find(|g| {
1310 g.skills.iter().any(|n| n == name)
1311 || g.mcp.iter().any(|n| n == name)
1312 || g.commands.iter().any(|n| n == name)
1313 })
1314 }
1315
1316 pub fn skill_enabled(&self, skill_name: &str) -> bool {
1319 name_enabled(&self.disabled_skills, skill_name)
1320 && self.group_of(skill_name).is_none_or(|g| g.enabled)
1321 }
1322
1323 pub fn mcp_enabled(&self, server_id: &str) -> bool {
1325 name_enabled(&self.disabled_mcp, server_id)
1326 && self.group_of(server_id).is_none_or(|g| g.enabled)
1327 }
1328
1329 pub fn set_skill_enabled(&mut self, skill_name: &str, enabled: bool) {
1331 set_denylist(&mut self.disabled_skills, skill_name, enabled);
1332 }
1333
1334 pub fn set_mcp_enabled(&mut self, server_id: &str, enabled: bool) {
1336 set_denylist(&mut self.disabled_mcp, server_id, enabled);
1337 }
1338
1339 pub fn set_addon_enabled(&mut self, addon_id: &str, enabled: bool) -> bool {
1342 match self.addons.iter_mut().find(|g| g.id == addon_id) {
1343 Some(g) => {
1344 g.enabled = enabled;
1345 true
1346 }
1347 None => false,
1348 }
1349 }
1350
1351 pub fn disable_all_addons(&mut self) {
1357 for g in &mut self.addons {
1358 g.enabled = false;
1359 }
1360 }
1361
1362 pub fn enabled_mcp_servers(&self) -> Vec<McpServerEntry> {
1364 self.mcp_servers
1365 .iter()
1366 .filter(|m| self.mcp_enabled(&m.name))
1367 .cloned()
1368 .collect()
1369 }
1370}
1371
1372#[cfg(test)]
1373mod tests {
1374 use super::*;
1375
1376 #[test]
1377 fn profile_round_trip_yaml() {
1378 let yaml = r#"
1379schema: 1
1380id: 01JQX4TM8Y9K7VQH6B2N3R5DPE
1381name: agent_a
1382display_name: "Price Hunter"
1383version: "0.1.0"
1384persona:
1385 category: research
1386 description: "Finds prices"
1387 traits: { tone: concise, risk: cautious, verbosity: low }
1388sys_prompt_file: "sys_prompt.md"
1389model: { provider: ollama, name: "llama3.2:3b", params: { temperature: 0.2, max_tokens: 4096 } }
1390mcp_servers: []
1391skills: []
1392transport:
1393 stdio: true
1394 socket: { enabled: true, bind: "unix:///tmp/a.sock" }
1395communication: { accepts_from: ["*"], sends_to: [] }
1396capabilities: ["a2a.message.send", "a2a.tasks"]
1397entitlements:
1398 network:
1399 inbound: { ports: [] }
1400 outbound: { mode: restricted, allow_hosts: [], protocols: ["tcp"], resolve_dns: { mode: system } }
1401 filesystem: { read: [], write: [], deny: [] }
1402 processes: { spawn: { mode: allowlist, allowed: [] } }
1403 syscalls: { mode: default }
1404 limits: { memory_mb: 512, file_descriptors: 1024, processes: 32 }
1405notifications: { on_task_complete: [], on_error: [], on_shutdown: [] }
1406retry:
1407 llm: { max_retries: 3, backoff: exponential, initial_delay_ms: 1000, max_delay_ms: 30000, retry_on: [rate_limit, timeout, connection_error] }
1408 tool: { max_retries: 1, backoff: fixed, initial_delay_ms: 500 }
1409lifecycle: { restart: on_failure, max_restarts: 3, restart_window_secs: 600, stop_timeout_secs: 15, mcp_required: true }
1410created_at: "2026-04-22T10:00:00+08:00"
1411updated_at: "2026-04-22T10:00:00+08:00"
1412"#;
1413 let profile: AgentProfile = serde_yaml_ng::from_str(yaml).expect("parse");
1414 assert_eq!(profile.name, "agent_a");
1415 assert_eq!(profile.persona.category, PersonaCategory::Research);
1416 assert_eq!(
1417 profile.entitlements.network.outbound.mode,
1418 NetworkOutboundMode::Restricted
1419 );
1420 let reserialized = serde_yaml_ng::to_string(&profile).expect("emit");
1421 let round_tripped: AgentProfile = serde_yaml_ng::from_str(&reserialized).expect("re-parse");
1422 assert_eq!(profile.id, round_tripped.id);
1423 }
1424}
1425
1426#[cfg(test)]
1427mod model_ref_tests {
1428 use super::*;
1429
1430 #[test]
1431 fn legacy_profile_without_model_ref_still_parses() {
1432 let yaml = include_str!("../tests/fixtures/profile_p0a_minimal.yaml");
1433 let p: AgentProfile = serde_yaml_ng::from_str(yaml).unwrap();
1434 assert!(
1435 p.model_ref.is_none(),
1436 "legacy profile must not have model_ref"
1437 );
1438 }
1439
1440 #[test]
1441 fn round_trip_with_model_ref_preserves_field() {
1442 let yaml = include_str!("../tests/fixtures/profile_p0a_minimal.yaml");
1443 let mut p: AgentProfile = serde_yaml_ng::from_str(yaml).unwrap();
1444 p.model_ref = Some("anthropic_opus_4_7".into());
1445 let s = serde_yaml_ng::to_string(&p).unwrap();
1446 assert!(s.contains("model_ref: anthropic_opus_4_7"), "yaml: {s}");
1447 let p2: AgentProfile = serde_yaml_ng::from_str(&s).unwrap();
1448 assert_eq!(p2.model_ref.as_deref(), Some("anthropic_opus_4_7"));
1449 }
1450}
1451
1452#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1460#[serde(rename_all = "snake_case")]
1461pub enum ProactiveTier {
1462 Off,
1463 WarmOnly,
1464 WarmAndBehavior,
1465 All,
1466}
1467
1468impl ProactiveTier {
1469 pub fn from_config(c: &CompanionConfig) -> Self {
1470 match (c.enabled, c.rhythm.enabled, c.proactive.enabled) {
1471 (false, _, _) => Self::Off,
1472 (true, false, false) => Self::WarmOnly,
1473 (true, true, false) => Self::WarmAndBehavior,
1474 (true, _, true) => Self::All,
1475 }
1476 }
1477
1478 pub fn apply(&self, c: &mut CompanionConfig) {
1479 match self {
1480 Self::Off => {
1481 c.enabled = false;
1482 c.rhythm.enabled = false;
1483 c.proactive.enabled = false;
1484 }
1485 Self::WarmOnly => {
1486 c.enabled = true;
1487 c.rhythm.enabled = false;
1488 c.proactive.enabled = false;
1489 }
1490 Self::WarmAndBehavior => {
1491 c.enabled = true;
1492 c.rhythm.enabled = true;
1493 c.proactive.enabled = false;
1494 }
1495 Self::All => {
1496 c.enabled = true;
1497 c.rhythm.enabled = true;
1498 c.proactive.enabled = true;
1499 }
1500 }
1501 }
1502}
1503
1504#[cfg(test)]
1505mod mcp_pin_tests {
1506 use super::*;
1507
1508 #[test]
1512 fn pre_m9_entry_roundtrips_without_pin_fields() {
1513 let yaml = r#"
1514name: weather
1515command: /opt/mcp/weather
1516args: ["--port", "0"]
1517"#;
1518 let entry: McpServerEntry = serde_yaml_ng::from_str(yaml).unwrap();
1519 assert_eq!(entry.name, "weather");
1520 assert_eq!(entry.binary_sha256, None);
1521 assert_eq!(entry.description_hash, None);
1522 assert_eq!(entry.publisher, None);
1523 assert_eq!(entry.installed_at, None);
1524
1525 let out = serde_yaml_ng::to_string(&entry).unwrap();
1528 assert!(!out.contains("binary_sha256"), "got {out}");
1529 assert!(!out.contains("description_hash"), "got {out}");
1530 assert!(!out.contains("publisher"), "got {out}");
1531 assert!(!out.contains("installed_at"), "got {out}");
1532 }
1533
1534 #[test]
1536 fn full_m9_entry_roundtrips_all_fields() {
1537 let yaml = r#"
1538name: weather
1539command: /opt/mcp/weather
1540args: []
1541binary_sha256: "3f4abca8b0e6e2c1d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b81c"
1542description_hash: "9a01b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9c7e2"
1543publisher:
1544 name: "@anthropic-mcp/weather"
1545 homepage: "https://github.com/anthropic-mcp/weather"
1546 registry_id: "@anthropic-mcp/weather@1.2.3"
1547installed_at: "2026-05-06T08:00:00Z"
1548"#;
1549 let entry: McpServerEntry = serde_yaml_ng::from_str(yaml).unwrap();
1550 assert!(
1551 entry
1552 .binary_sha256
1553 .as_deref()
1554 .unwrap()
1555 .starts_with("3f4abca8")
1556 );
1557 assert!(
1558 entry
1559 .description_hash
1560 .as_deref()
1561 .unwrap()
1562 .starts_with("9a01b2c3")
1563 );
1564 let pub_info = entry.publisher.clone().unwrap();
1565 assert_eq!(pub_info.name, "@anthropic-mcp/weather");
1566 assert_eq!(
1567 pub_info.homepage.as_deref(),
1568 Some("https://github.com/anthropic-mcp/weather"),
1569 );
1570 assert_eq!(
1571 pub_info.registry_id.as_deref(),
1572 Some("@anthropic-mcp/weather@1.2.3"),
1573 );
1574 let installed = entry.installed_at.unwrap();
1575 assert_eq!(installed.to_rfc3339(), "2026-05-06T08:00:00+00:00");
1576 }
1577
1578 #[test]
1582 fn partial_pin_only_binary_sha_roundtrips() {
1583 let yaml = r#"
1584name: weather
1585command: /opt/mcp/weather
1586args: []
1587binary_sha256: "deadbeef00112233445566778899aabbccddeeff00112233445566778899aabb"
1588"#;
1589 let entry: McpServerEntry = serde_yaml_ng::from_str(yaml).unwrap();
1590 assert_eq!(
1591 entry.binary_sha256.as_deref(),
1592 Some("deadbeef00112233445566778899aabbccddeeff00112233445566778899aabb"),
1593 );
1594 assert_eq!(entry.description_hash, None);
1595 assert_eq!(entry.publisher, None);
1596 }
1597
1598 #[test]
1601 fn publisher_minimal_just_name() {
1602 let yaml = r#"
1603name: weather
1604command: /opt/mcp/weather
1605args: []
1606publisher:
1607 name: "alice"
1608"#;
1609 let entry: McpServerEntry = serde_yaml_ng::from_str(yaml).unwrap();
1610 let p = entry.publisher.as_ref().unwrap();
1611 assert_eq!(p.name, "alice");
1612 assert_eq!(p.homepage, None);
1613 assert_eq!(p.registry_id, None);
1614
1615 let out = serde_yaml_ng::to_string(&entry).unwrap();
1617 assert!(!out.contains("homepage:"), "got {out}");
1618 assert!(!out.contains("registry_id:"), "got {out}");
1619 }
1620}
1621
1622#[cfg(test)]
1623mod voice_tests {
1624 use super::*;
1625 use std::str::FromStr;
1626
1627 #[test]
1628 fn voice_config_round_trips() {
1629 let base = include_str!("../tests/fixtures/profile_p0a_minimal.yaml");
1631 let yaml = format!("{base}voice:\n enabled: true\n voice_id: af_bella\n");
1632
1633 let profile: AgentProfile = serde_yaml_ng::from_str(&yaml).expect("parse with voice");
1634 assert!(profile.voice.enabled);
1635 assert_eq!(profile.voice.voice_id, VoiceId::AfBella);
1636
1637 let legacy: AgentProfile = serde_yaml_ng::from_str(base).expect("parse without voice");
1639 assert!(!legacy.voice.enabled);
1640 assert_eq!(legacy.voice.voice_id, VoiceId::AfHeart);
1641 }
1642
1643 #[test]
1644 fn voice_id_from_str_roundtrips() {
1645 let cases = [
1646 ("af_heart", VoiceId::AfHeart),
1647 ("af_bella", VoiceId::AfBella),
1648 ("af_nicole", VoiceId::AfNicole),
1649 ("am_adam", VoiceId::AmAdam),
1650 ("am_michael", VoiceId::AmMichael),
1651 ];
1652 for (s, expected) in cases {
1653 assert_eq!(VoiceId::from_str(s).unwrap(), expected);
1654 assert_eq!(expected.as_str(), s);
1655 }
1656 }
1657
1658 #[test]
1659 fn voice_id_from_str_rejects_unknown() {
1660 assert!(VoiceId::from_str("bogus").is_err());
1661 }
1662}
1663
1664#[cfg(test)]
1665mod idle_trigger_tests {
1666 use super::*;
1667
1668 #[test]
1669 fn idle_trigger_yaml_round_trip() {
1670 let yaml = r#"
1671restart: on_failure
1672idle_triggers:
1673 - after_secs: 3600
1674 message: "still there?"
1675 sends_to: other_agent
1676 cooldown_secs: 1800
1677 respect_quiet_hours: true
1678"#;
1679 let cfg: LifecycleConfig = serde_yaml_ng::from_str(yaml).unwrap();
1680 assert_eq!(cfg.idle_triggers.len(), 1);
1681 assert_eq!(cfg.idle_triggers[0].after_secs, 3600);
1682 assert_eq!(cfg.idle_triggers[0].message, "still there?");
1683 assert_eq!(
1684 cfg.idle_triggers[0].sends_to.as_deref(),
1685 Some("other_agent")
1686 );
1687 assert_eq!(cfg.idle_triggers[0].cooldown_secs, 1800);
1688 assert!(cfg.idle_triggers[0].respect_quiet_hours);
1689 }
1690
1691 #[test]
1692 fn idle_trigger_defaults_when_omitted() {
1693 let yaml = "restart: on_failure\n";
1694 let cfg: LifecycleConfig = serde_yaml_ng::from_str(yaml).unwrap();
1695 assert!(cfg.idle_triggers.is_empty());
1696 }
1697}
1698
1699#[cfg(test)]
1700mod appearance_tests {
1701 use super::*;
1702
1703 #[test]
1704 fn appearance_default_style_preset_is_default_blob() {
1705 assert_eq!(AgentAppearance::default().style_preset, "default-blob");
1706 }
1707
1708 #[test]
1709 fn appearance_default_behavior_is_normal() {
1710 assert_eq!(
1711 AgentAppearance::default().behavior_preset,
1712 BehaviorPreset::Normal
1713 );
1714 }
1715
1716 #[test]
1717 fn appearance_default_render_status_is_pending() {
1718 assert_eq!(
1719 AgentAppearance::default().render_status,
1720 RenderStatus::Pending
1721 );
1722 }
1723
1724 #[test]
1725 fn render_status_serde_round_trip() {
1726 let cases = [
1727 RenderStatus::Pending,
1728 RenderStatus::Rendering { done: 3, total: 12 },
1729 RenderStatus::Ready,
1730 RenderStatus::Failed {
1731 reason: "out of quota".into(),
1732 },
1733 ];
1734 for status in cases {
1735 let yaml = serde_yaml_ng::to_string(&status).expect("serialize");
1736 let back: RenderStatus = serde_yaml_ng::from_str(&yaml).expect("deserialize");
1737 assert_eq!(status, back);
1738 }
1739 }
1740
1741 #[test]
1742 fn agent_profile_with_appearance_round_trips() {
1743 let base = include_str!("../tests/fixtures/profile_p0a_minimal.yaml");
1744 let yaml = format!(
1745 "{base}appearance:\n style_preset: chiikawa\n render_status:\n status: ready\n"
1746 );
1747 let profile: AgentProfile = serde_yaml_ng::from_str(&yaml).expect("parse with appearance");
1748 assert_eq!(profile.appearance.style_preset, "chiikawa");
1749 assert_eq!(profile.appearance.render_status, RenderStatus::Ready);
1750
1751 let out = serde_yaml_ng::to_string(&profile).expect("serialize");
1752 let back: AgentProfile = serde_yaml_ng::from_str(&out).expect("re-parse");
1753 assert_eq!(profile.appearance, back.appearance);
1754 }
1755
1756 #[test]
1757 fn legacy_profile_without_appearance_uses_default() {
1758 let yaml = include_str!("../tests/fixtures/profile_p0a_minimal.yaml");
1759 let profile: AgentProfile = serde_yaml_ng::from_str(yaml).expect("parse legacy");
1760 assert_eq!(profile.appearance.style_preset, "default-blob");
1761 assert_eq!(profile.appearance.behavior_preset, BehaviorPreset::Normal);
1762 assert_eq!(profile.appearance.render_status, RenderStatus::Pending);
1763 }
1764
1765 #[test]
1766 fn legacy_profile_without_file_actions_or_action_pipeline_loads() {
1767 let yaml = include_str!("../tests/fixtures/profile_p0a_minimal.yaml");
1768 let p: AgentProfile = serde_yaml_ng::from_str(yaml).unwrap();
1769 assert!(p.file_actions.is_empty());
1770 assert_eq!(p.action_pipeline.deletion.cancel_window_minutes, 10);
1771 assert_eq!(p.action_pipeline.queue.max_concurrent, 3);
1772 }
1773}
1774
1775#[cfg(test)]
1776mod federation_tests {
1777 use super::*;
1778
1779 #[test]
1780 fn test_pattern_filter_default() {
1781 let f = PatternFilter::default();
1782 assert_eq!(f.max_count, 200);
1783 assert_eq!(f.importance_min, 0.0);
1784 assert!(f.tier.is_empty());
1785 }
1786
1787 #[test]
1788 fn test_federation_config_roundtrip() {
1789 let cfg = FederationConfig {
1790 filter: PatternFilter {
1791 tier: vec!["core".into()],
1792 max_count: 50,
1793 ..Default::default()
1794 },
1795 snapshot_ref: Some(SnapshotRef {
1796 knowledge_commit: "abc123def456".into(),
1797 taken_at: "2026-05-19T00:00:00Z".into(),
1798 filter: PatternFilter::default(),
1799 }),
1800 evidence_flush_interval_minutes: 15,
1801 };
1802 let yaml = serde_yaml_ng::to_string(&cfg).unwrap();
1803 let back: FederationConfig = serde_yaml_ng::from_str(&yaml).unwrap();
1804 assert_eq!(cfg, back);
1805 }
1806
1807 #[test]
1808 fn test_agent_profile_federation_defaults() {
1809 let cfg = FederationConfig::default();
1813 assert_eq!(cfg.evidence_flush_interval_minutes, 0);
1814 assert!(cfg.snapshot_ref.is_none());
1815 }
1816}
1817
1818#[cfg(test)]
1819mod skill_card_tests {
1820 use super::*;
1821
1822 #[test]
1823 fn installed_skills_default_to_empty_when_absent() {
1824 let yaml = include_str!("../tests/fixtures/profile_p0a_minimal.yaml");
1825 let p: AgentProfile = serde_yaml_ng::from_str(yaml).unwrap();
1826 assert!(p.installed_skills.is_empty());
1827 }
1828
1829 #[test]
1830 fn installed_skills_roundtrip_preserves_entries() {
1831 let base = include_str!("../tests/fixtures/profile_p0a_minimal.yaml");
1832 let yaml = format!(
1833 "{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"
1834 );
1835 let p: AgentProfile = serde_yaml_ng::from_str(&yaml).unwrap();
1836 assert_eq!(p.installed_skills.len(), 1);
1837 assert_eq!(p.installed_skills[0].name, "s1");
1838 assert_eq!(p.installed_skills[0].abstract_text, "does things");
1839 assert_eq!(p.installed_skills[0].transfer_chain, vec!["agent://alice"]);
1840
1841 let out = serde_yaml_ng::to_string(&p).unwrap();
1842 assert!(out.contains("abstract: does things"));
1843 assert!(out.contains("pattern: /find"));
1844
1845 let back: AgentProfile = serde_yaml_ng::from_str(&out).unwrap();
1846 assert_eq!(p.installed_skills, back.installed_skills);
1847 }
1848
1849 #[test]
1850 fn installed_skills_minimal_entry_serializes_compactly() {
1851 let entry = SkillCardEntry {
1853 name: "minimal".into(),
1854 ..Default::default()
1855 };
1856 let yaml = serde_yaml_ng::to_string(&entry).unwrap();
1857 assert!(yaml.contains("name: minimal"));
1858 assert!(
1859 !yaml.contains("version:"),
1860 "empty version must be skipped: {yaml}"
1861 );
1862 assert!(
1863 !yaml.contains("publisher:"),
1864 "empty publisher must be skipped: {yaml}"
1865 );
1866 assert!(
1867 !yaml.contains("abstract:"),
1868 "empty abstract must be skipped: {yaml}"
1869 );
1870 }
1871}
1872
1873#[cfg(test)]
1874mod tool_policy_tests {
1875 use super::*;
1876
1877 fn rules() -> Vec<ToolRule> {
1878 vec![
1879 ToolRule {
1880 pattern: "mcp__github__merge_pr".into(),
1881 policy: ToolPolicy::Ask,
1882 risk: None,
1883 },
1884 ToolRule {
1885 pattern: "mcp__github__*".into(),
1886 policy: ToolPolicy::Allow,
1887 risk: None,
1888 },
1889 ToolRule {
1890 pattern: "mcp__*".into(),
1891 policy: ToolPolicy::Deny,
1892 risk: None,
1893 },
1894 ToolRule {
1895 pattern: "bash".into(),
1896 policy: ToolPolicy::Allow,
1897 risk: None,
1898 },
1899 ]
1900 }
1901
1902 #[test]
1903 fn exact_beats_glob() {
1904 assert_eq!(
1905 resolve_tool_policy(&rules(), "mcp__github__merge_pr"),
1906 ToolPolicy::Ask
1907 );
1908 }
1909
1910 #[test]
1911 fn longer_glob_wins() {
1912 assert_eq!(
1913 resolve_tool_policy(&rules(), "mcp__github__create_issue"),
1914 ToolPolicy::Allow
1915 );
1916 }
1917
1918 #[test]
1919 fn shorter_glob_fallback() {
1920 assert_eq!(
1921 resolve_tool_policy(&rules(), "mcp__slack__send"),
1922 ToolPolicy::Deny
1923 );
1924 }
1925
1926 #[test]
1927 fn exact_bash() {
1928 assert_eq!(resolve_tool_policy(&rules(), "bash"), ToolPolicy::Allow);
1929 }
1930
1931 #[test]
1932 fn unknown_tool_defaults_ask() {
1933 assert_eq!(
1934 resolve_tool_policy(&rules(), "unknown_tool"),
1935 ToolPolicy::Ask
1936 );
1937 }
1938
1939 #[test]
1940 fn empty_rules_defaults_ask() {
1941 assert_eq!(resolve_tool_policy(&[], "bash"), ToolPolicy::Ask);
1942 }
1943
1944 fn minimal_entitlements_yaml() -> &'static str {
1945 "network:\n inbound: {}\n outbound:\n mode: off\nfilesystem: {}\nprocesses:\n spawn:\n mode: none\n"
1946 }
1947
1948 #[test]
1949 fn entitlements_tools_defaults_empty() {
1950 let e: Entitlements = serde_yaml_ng::from_str(minimal_entitlements_yaml()).unwrap();
1951 assert!(e.tools.is_empty());
1952 }
1953
1954 #[test]
1955 fn entitlements_tools_roundtrip() {
1956 let base = minimal_entitlements_yaml();
1957 let yaml = format!("{base}tools:\n - pattern: \"mcp__github__*\"\n policy: allow\n");
1958 let e: Entitlements = serde_yaml_ng::from_str(&yaml).unwrap();
1959 assert_eq!(e.tools.len(), 1);
1960 assert_eq!(e.tools[0].policy, ToolPolicy::Allow);
1961 let y = serde_yaml_ng::to_string(&e).unwrap();
1962 let back: Entitlements = serde_yaml_ng::from_str(&y).unwrap();
1963 assert_eq!(back.tools.len(), 1);
1964 assert_eq!(back.tools[0].policy, ToolPolicy::Allow);
1965 }
1966 #[test]
1967 fn denylist_membership_and_mutation() {
1968 let mut list: Vec<String> = vec![];
1969 assert!(name_enabled(&list, "a"), "empty denylist => enabled");
1970
1971 set_denylist(&mut list, "a", false); assert!(!name_enabled(&list, "a"));
1973 assert_eq!(list, ["a"]);
1974
1975 set_denylist(&mut list, "a", false); assert_eq!(list, ["a"], "no duplicate entries");
1977
1978 set_denylist(&mut list, "a", true); assert!(name_enabled(&list, "a"));
1980 assert!(list.is_empty());
1981
1982 set_denylist(&mut list, "b", true); assert!(list.is_empty());
1984 }
1985
1986 #[test]
1987 fn addon_group_rule_truth_table() {
1988 let mut p = AgentProfile::default_for_tests();
1989 p.addons.push(AddonRef {
1990 id: "grp".into(),
1991 source: "claude-local:grp@1.0.0".into(),
1992 enabled: false,
1993 skills: vec!["g_skill".into()],
1994 mcp: vec!["g_mcp".into()],
1995 commands: vec!["g_cmd".into()],
1996 });
1997
1998 assert!(p.skill_enabled("standalone"));
2000 assert!(p.mcp_enabled("standalone_mcp"));
2001
2002 assert!(!p.skill_enabled("g_skill"));
2004 assert!(!p.mcp_enabled("g_mcp"));
2005
2006 assert!(p.set_addon_enabled("grp", true));
2008 assert!(p.skill_enabled("g_skill"));
2009 assert!(p.mcp_enabled("g_mcp"));
2010
2011 p.set_skill_enabled("g_skill", false);
2013 assert!(!p.skill_enabled("g_skill"));
2014
2015 assert!(!p.set_addon_enabled("nope", true));
2017
2018 p.disable_all_addons();
2020 assert!(p.addons.iter().all(|g| !g.enabled));
2021 assert!(!p.skill_enabled("g_skill"));
2022 assert!(!p.skill_enabled("g_cmd"));
2023 assert!(!p.mcp_enabled("g_mcp")); assert!(p.set_addon_enabled("grp", true));
2029 assert!(!p.skill_enabled("g_skill")); assert!(p.skill_enabled("g_cmd")); assert!(p.mcp_enabled("g_mcp")); p.set_skill_enabled("g_skill", true);
2035 assert!(p.skill_enabled("g_skill"));
2036 }
2037}
2038
2039#[cfg(test)]
2040mod lockfile_compat_tests {
2041 use super::*;
2042
2043 #[test]
2044 fn lockfile_new_fields_default_for_old_locks() {
2045 let old = r#"{"schema":1,"uuid":"u","name":"a","pid":1,"ppid":1,
2048 "started_at":"t","binary_version":"mur-agent-runtime 2.26.9",
2049 "transports":{"stdio":true},"card_digest":"d","capabilities":[]}"#;
2050 let lock: LockFile = serde_json::from_str(old).unwrap();
2051 assert_eq!(lock.build_sha, "");
2052 assert_eq!(lock.proto_version, 0);
2053 }
2054}