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