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