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 #[serde(default, skip_serializing_if = "Option::is_none")]
546 pub risk: Option<crate::hitl::RiskTier>,
547}
548
549pub fn resolve_tool_policy(rules: &[ToolRule], tool_name: &str) -> ToolPolicy {
553 for rule in rules {
554 if rule.pattern == tool_name {
555 return rule.policy;
556 }
557 }
558 let mut best: Option<(&ToolRule, usize)> = None;
559 for rule in rules {
560 if let Some(prefix) = rule.pattern.strip_suffix('*')
561 && tool_name.starts_with(prefix)
562 {
563 let len = prefix.len();
564 if best.is_none_or(|(_, best_len)| len > best_len) {
565 best = Some((rule, len));
566 }
567 }
568 }
569 if let Some((rule, _)) = best {
570 return rule.policy;
571 }
572 ToolPolicy::default()
573}
574
575#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
576pub struct NotificationsConfig {
577 #[serde(default)]
578 pub on_task_complete: Vec<NotificationTarget>,
579 #[serde(default)]
580 pub on_error: Vec<NotificationTarget>,
581 #[serde(default)]
582 pub on_shutdown: Vec<NotificationTarget>,
583}
584
585#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
586#[serde(tag = "target", rename_all = "lowercase")]
587pub enum NotificationTarget {
588 Agent {
589 name: String,
590 },
591 Commander,
592 Email {
593 address: String,
594 #[serde(default)]
595 smtp_config_file: Option<String>,
596 },
597 Slack {
598 #[serde(default)]
599 channel: Option<String>,
600 #[serde(default)]
601 webhook_url_env: Option<String>,
602 },
603 Webpush {
604 url: String,
605 },
606 Webhook {
607 url: String,
608 #[serde(default = "default_post")]
609 method: String,
610 #[serde(default)]
611 auth: Option<String>,
612 },
613}
614fn default_post() -> String {
615 "POST".to_string()
616}
617
618#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
619pub struct RetryConfig {
620 pub llm: RetryPolicy,
621 pub tool: RetryPolicy,
622}
623
624#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
625pub struct RetryPolicy {
626 pub max_retries: u32,
627 pub backoff: BackoffStrategy,
628 pub initial_delay_ms: u64,
629 #[serde(default)]
630 pub max_delay_ms: Option<u64>,
631 #[serde(default)]
632 pub retry_on: Vec<String>,
633}
634
635#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
636#[serde(rename_all = "lowercase")]
637pub enum BackoffStrategy {
638 Linear,
639 Exponential,
640 Fixed,
641}
642
643#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
644pub struct LifecycleConfig {
645 pub restart: RestartPolicy,
646 #[serde(default = "default_max_restarts")]
647 pub max_restarts: u32,
648 #[serde(default = "default_window")]
649 pub restart_window_secs: u64,
650 #[serde(default = "default_stop_timeout")]
651 pub stop_timeout_secs: u64,
652 #[serde(default = "default_mcp_required")]
653 pub mcp_required: bool,
654 #[serde(default)]
655 pub execution: ExecutionMode,
656 #[serde(default)]
657 pub schedule: Vec<ScheduleEntry>,
658 #[serde(default)]
659 pub idle_triggers: Vec<IdleTrigger>,
660}
661fn default_max_restarts() -> u32 {
662 3
663}
664fn default_window() -> u64 {
665 600
666}
667fn default_stop_timeout() -> u64 {
668 15
669}
670fn default_mcp_required() -> bool {
671 true
672}
673
674#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
675#[serde(rename_all = "snake_case")]
676pub enum RestartPolicy {
677 Never,
678 OnFailure,
679 Always,
680}
681
682#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
683#[serde(rename_all = "snake_case")]
684pub enum ExecutionMode {
685 #[default]
686 Daemon,
687 OnDemand,
688}
689
690#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
691pub struct ScheduleEntry {
692 pub cron: String,
693 pub message: String,
694 #[serde(default, skip_serializing_if = "Option::is_none")]
695 pub sends_to: Option<String>,
696}
697
698#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
699pub struct IdleTrigger {
700 pub after_secs: u64,
702 pub message: String,
704 #[serde(default, skip_serializing_if = "Option::is_none")]
706 pub sends_to: Option<String>,
707 #[serde(default = "default_idle_cooldown")]
710 pub cooldown_secs: u64,
711 #[serde(default = "default_true")]
714 pub respect_quiet_hours: bool,
715}
716
717fn default_idle_cooldown() -> u64 {
718 600
719}
720fn default_true() -> bool {
721 true
722}
723
724#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
725pub struct FileTransferConfig {
726 #[serde(default = "default_accept_max")]
727 pub accept_incoming_file_max_bytes: u64,
728 #[serde(default = "default_accept_total")]
729 pub accept_incoming_total_per_hour: u64,
730 #[serde(default = "default_approval_threshold")]
731 pub require_approval_above_bytes: u64,
732 #[serde(default = "default_reject_paths")]
733 pub reject_paths: Vec<String>,
734 #[serde(default = "default_allowed_mime")]
735 pub allowed_mime_types: Vec<String>,
736}
737
738impl Default for FileTransferConfig {
739 fn default() -> Self {
740 Self {
741 accept_incoming_file_max_bytes: default_accept_max(),
742 accept_incoming_total_per_hour: default_accept_total(),
743 require_approval_above_bytes: default_approval_threshold(),
744 reject_paths: default_reject_paths(),
745 allowed_mime_types: default_allowed_mime(),
746 }
747 }
748}
749
750fn default_accept_max() -> u64 {
751 10_485_760
752}
753fn default_accept_total() -> u64 {
754 104_857_600
755}
756fn default_approval_threshold() -> u64 {
757 10_485_760
758}
759fn default_reject_paths() -> Vec<String> {
760 vec!["~/.ssh".into(), "~/.aws".into(), "~/.gnupg".into()]
761}
762fn default_allowed_mime() -> Vec<String> {
763 vec!["*".into()]
764}
765
766#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
767#[serde(rename_all = "snake_case")]
768pub enum DeploymentType {
769 #[default]
770 Laptop,
771 Vm,
772 Docker,
773 K8s,
774 Lambda,
775}
776
777#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
778pub struct DeploymentConfig {
779 #[serde(rename = "type", default)]
780 pub deployment_type: DeploymentType,
781 #[serde(default, skip_serializing_if = "Option::is_none")]
782 pub region: Option<String>,
783 #[serde(default = "default_env")]
784 pub environment: Option<String>,
785}
786
787impl Default for DeploymentConfig {
788 fn default() -> Self {
789 Self {
790 deployment_type: DeploymentType::default(),
791 region: None,
792 environment: default_env(),
793 }
794 }
795}
796
797fn default_env() -> Option<String> {
798 Some("dev".into())
799}
800
801#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
802pub struct LockFile {
803 pub schema: u32,
804 pub uuid: String,
805 pub name: String,
806 pub pid: u32,
807 pub ppid: u32,
808 pub started_at: String,
809 pub binary_version: String,
810 pub transports: LockTransports,
811 pub card_digest: String,
812 pub capabilities: Vec<String>,
813}
814
815#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
816pub struct LockTransports {
817 pub stdio: bool,
818 #[serde(default)]
819 pub unix_socket: Option<String>,
820 #[serde(default)]
821 pub tcp: Option<String>,
822 #[serde(default)]
827 pub webhook: Option<String>,
828}
829
830#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
837#[serde(rename_all = "snake_case")]
838pub enum VoiceId {
839 #[default]
841 AfHeart,
842 AfBella,
843 AfNicole,
844 AmAdam,
845 AmMichael,
846}
847
848impl VoiceId {
849 pub fn style_index(&self) -> usize {
851 match self {
852 VoiceId::AfHeart => 0,
853 VoiceId::AfBella => 1,
854 VoiceId::AfNicole => 2,
855 VoiceId::AmAdam => 3,
856 VoiceId::AmMichael => 4,
857 }
858 }
859
860 pub fn as_str(&self) -> &'static str {
862 match self {
863 VoiceId::AfHeart => "af_heart",
864 VoiceId::AfBella => "af_bella",
865 VoiceId::AfNicole => "af_nicole",
866 VoiceId::AmAdam => "am_adam",
867 VoiceId::AmMichael => "am_michael",
868 }
869 }
870}
871
872impl std::str::FromStr for VoiceId {
873 type Err = anyhow::Error;
874
875 fn from_str(s: &str) -> anyhow::Result<Self> {
876 match s {
877 "af_heart" => Ok(VoiceId::AfHeart),
878 "af_bella" => Ok(VoiceId::AfBella),
879 "af_nicole" => Ok(VoiceId::AfNicole),
880 "am_adam" => Ok(VoiceId::AmAdam),
881 "am_michael" => Ok(VoiceId::AmMichael),
882 other => anyhow::bail!(
883 "unknown voice ID '{other}' \
884 (valid: af_heart, af_bella, af_nicole, am_adam, am_michael)"
885 ),
886 }
887 }
888}
889
890#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
893pub struct VoiceConfig {
894 #[serde(default)]
896 pub enabled: bool,
897 #[serde(default)]
899 pub voice_id: VoiceId,
900 #[serde(default, skip_serializing_if = "Option::is_none")]
903 pub input_device: Option<String>,
904}
905
906#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
911pub struct HitlConfig {
912 #[serde(default = "default_hitl_timeout_secs")]
913 pub timeout_secs: u32,
914 #[serde(default)]
918 pub max_iterations: Option<u32>,
919 #[serde(default)]
924 pub max_tokens: Option<u64>,
925}
926
927fn default_hitl_timeout_secs() -> u32 {
928 300
929}
930
931impl Default for HitlConfig {
932 fn default() -> Self {
933 Self {
934 timeout_secs: default_hitl_timeout_secs(),
935 max_iterations: None,
936 max_tokens: None,
937 }
938 }
939}
940
941#[cfg(test)]
942mod hitl_tests {
943 use super::*;
944
945 #[test]
946 fn hitl_config_default_max_iterations_is_none() {
947 let cfg = HitlConfig::default();
948 assert!(cfg.max_iterations.is_none());
949 }
950
951 #[test]
952 fn hitl_config_max_iterations_explicit() {
953 let cfg: HitlConfig = serde_yaml::from_str("timeout_secs: 60\nmax_iterations: 5").unwrap();
954 assert_eq!(cfg.max_iterations, Some(5));
955 }
956
957 #[test]
958 fn hitl_config_default_max_tokens_is_none() {
959 let cfg = HitlConfig::default();
960 assert!(cfg.max_tokens.is_none());
961 }
962
963 #[test]
964 fn hitl_config_max_tokens_explicit() {
965 let cfg: HitlConfig = serde_yaml::from_str("timeout_secs: 60\nmax_tokens: 250000").unwrap();
966 assert_eq!(cfg.max_tokens, Some(250_000));
967 }
968}
969
970#[derive(Debug, Default, Clone, PartialEq, Serialize, Deserialize)]
976pub struct CompanionConfig {
977 #[serde(default)]
978 pub enabled: bool,
979 #[serde(default = "default_locale")]
980 pub locale: String,
981 #[serde(default)]
982 pub relationship: Relationship,
983 #[serde(default)]
984 pub voice_overrides: VoiceOverrides,
985 #[serde(default)]
986 pub onboarding: OnboardingState,
987 #[serde(default)]
988 pub rhythm: RhythmConfig,
989 #[serde(default)]
990 pub proactive: ProactiveConfig,
991}
992
993pub fn default_locale() -> String {
996 std::env::var("LANG")
997 .ok()
998 .and_then(|v| v.split('.').next().map(|s| s.replace('_', "-")))
999 .unwrap_or_else(|| "en-US".into())
1000}
1001
1002#[derive(Debug, Default, Clone, PartialEq, Serialize, Deserialize)]
1003pub struct VoiceOverrides {
1004 #[serde(default, skip_serializing_if = "Option::is_none")]
1005 pub name_for_user: Option<String>,
1006 #[serde(default, skip_serializing_if = "Option::is_none")]
1007 pub formality: Option<Formality>,
1008 #[serde(default, skip_serializing_if = "Option::is_none")]
1009 pub extra_instructions: Option<String>,
1010}
1011
1012#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1013pub struct FirstMemory {
1014 pub text: String,
1015 pub established_at: chrono::DateTime<chrono::Utc>,
1016}
1017
1018#[derive(Debug, Default, Clone, PartialEq, Serialize, Deserialize)]
1019pub struct OnboardingState {
1020 #[serde(default, skip_serializing_if = "Option::is_none")]
1021 pub completed_at: Option<chrono::DateTime<chrono::Utc>>,
1022 #[serde(default)]
1023 pub version: u32,
1024 #[serde(default, skip_serializing_if = "Option::is_none")]
1025 pub agent_display_name: Option<String>,
1026 #[serde(default, skip_serializing_if = "Option::is_none")]
1027 pub first_memory: Option<FirstMemory>,
1028}
1029
1030#[derive(Debug, Default, Clone, PartialEq, Serialize, Deserialize)]
1033pub struct RhythmConfig {
1034 #[serde(default)]
1035 pub enabled: bool,
1036}
1037
1038#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1039pub struct ProactiveConfig {
1040 #[serde(default)]
1041 pub enabled: bool,
1042 #[serde(default, skip_serializing_if = "Option::is_none")]
1044 pub learning_until: Option<chrono::DateTime<chrono::Utc>>,
1045 #[serde(default, skip_serializing_if = "Option::is_none")]
1046 pub quiet_hours: Option<QuietHours>,
1047 #[serde(default, skip_serializing_if = "Option::is_none")]
1048 pub active_hours: Option<ActiveHours>,
1049 #[serde(default = "default_daily_cap")]
1050 pub daily_cap: u8,
1051 #[serde(default = "default_channels")]
1052 pub channels: Vec<String>,
1053 #[serde(default, skip_serializing_if = "Option::is_none")]
1054 pub paused_until: Option<chrono::DateTime<chrono::Utc>>,
1055}
1056
1057impl Default for ProactiveConfig {
1058 fn default() -> Self {
1059 Self {
1060 enabled: false,
1061 learning_until: None,
1062 quiet_hours: None,
1063 active_hours: None,
1064 daily_cap: default_daily_cap(),
1065 channels: default_channels(),
1066 paused_until: None,
1067 }
1068 }
1069}
1070
1071fn default_daily_cap() -> u8 {
1072 3
1073}
1074fn default_channels() -> Vec<String> {
1075 vec!["stdout".into()]
1076}
1077
1078#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1079pub struct QuietHours {
1080 pub start: String,
1081 pub end: String,
1082}
1083
1084#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1085pub struct ActiveHours {
1086 pub start: String,
1087 pub end: String,
1088}
1089
1090#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
1095pub struct AgentAppearance {
1096 #[serde(default = "default_style_preset")]
1098 pub style_preset: String,
1099 #[serde(default)]
1100 pub behavior_preset: BehaviorPreset,
1101 #[serde(default, skip_serializing_if = "Option::is_none")]
1103 pub source_image_path: Option<std::path::PathBuf>,
1104 #[serde(default = "default_expressions_dir")]
1106 pub expressions_dir: std::path::PathBuf,
1107 #[serde(default, skip_serializing_if = "Option::is_none")]
1108 pub last_rendered_at: Option<chrono::DateTime<chrono::Utc>>,
1109 #[serde(default)]
1110 pub render_status: RenderStatus,
1111}
1112
1113fn default_style_preset() -> String {
1114 "default-blob".into()
1115}
1116
1117fn default_expressions_dir() -> std::path::PathBuf {
1118 std::path::PathBuf::from("expressions")
1119}
1120
1121impl Default for AgentAppearance {
1122 fn default() -> Self {
1123 Self {
1124 style_preset: default_style_preset(),
1125 behavior_preset: BehaviorPreset::Normal,
1126 source_image_path: None,
1127 expressions_dir: default_expressions_dir(),
1128 last_rendered_at: None,
1129 render_status: RenderStatus::Pending,
1130 }
1131 }
1132}
1133
1134#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
1135#[serde(rename_all = "snake_case")]
1136pub enum BehaviorPreset {
1137 Quiet,
1138 #[default]
1139 Normal,
1140 Lively,
1141}
1142
1143#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
1144#[serde(tag = "status", rename_all = "snake_case")]
1145pub enum RenderStatus {
1146 #[default]
1147 Pending,
1148 Rendering {
1149 done: u8,
1150 total: u8,
1151 },
1152 Ready,
1153 Failed {
1154 reason: String,
1155 },
1156}
1157
1158#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
1164#[serde(rename_all = "kebab-case")]
1165pub enum SnapshotPolicy {
1166 #[default]
1167 PullOnStart,
1168 PullPeriodic,
1169 Manual,
1170}
1171
1172#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
1174pub struct PatternFilter {
1175 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1176 pub applies_in: Vec<String>,
1177 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1178 pub tier: Vec<String>,
1179 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1180 pub maturity: Vec<String>,
1181 #[serde(default)]
1182 pub importance_min: f64,
1183 #[serde(default = "default_max_snapshot_count")]
1184 pub max_count: usize,
1185 #[serde(default)]
1186 pub snapshot_policy: SnapshotPolicy,
1187}
1188
1189fn default_max_snapshot_count() -> usize {
1190 200
1191}
1192
1193impl Default for PatternFilter {
1194 fn default() -> Self {
1195 Self {
1196 applies_in: vec![],
1197 tier: vec![],
1198 maturity: vec![],
1199 importance_min: 0.0,
1200 max_count: 200,
1201 snapshot_policy: SnapshotPolicy::default(),
1202 }
1203 }
1204}
1205
1206#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
1208pub struct SnapshotRef {
1209 pub knowledge_commit: String,
1210 pub taken_at: String,
1211 pub filter: PatternFilter,
1212}
1213
1214#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
1216pub struct FederationConfig {
1217 #[serde(default)]
1218 pub filter: PatternFilter,
1219 #[serde(default, skip_serializing_if = "Option::is_none")]
1220 pub snapshot_ref: Option<SnapshotRef>,
1221 #[serde(default)]
1222 pub evidence_flush_interval_minutes: u32,
1223}
1224
1225impl AgentProfile {
1226 #[doc(hidden)]
1232 pub fn default_for_tests() -> Self {
1233 serde_yaml_ng::from_str(include_str!("../tests/fixtures/minimal_profile.yaml"))
1234 .expect("minimal profile fixture")
1235 }
1236}
1237
1238#[cfg(test)]
1239mod tests {
1240 use super::*;
1241
1242 #[test]
1243 fn profile_round_trip_yaml() {
1244 let yaml = r#"
1245schema: 1
1246id: 01JQX4TM8Y9K7VQH6B2N3R5DPE
1247name: agent_a
1248display_name: "Price Hunter"
1249version: "0.1.0"
1250persona:
1251 category: research
1252 description: "Finds prices"
1253 traits: { tone: concise, risk: cautious, verbosity: low }
1254sys_prompt_file: "sys_prompt.md"
1255model: { provider: ollama, name: "llama3.2:3b", params: { temperature: 0.2, max_tokens: 4096 } }
1256mcp_servers: []
1257skills: []
1258transport:
1259 stdio: true
1260 socket: { enabled: true, bind: "unix:///tmp/a.sock" }
1261communication: { accepts_from: ["*"], sends_to: [] }
1262capabilities: ["a2a.message.send", "a2a.tasks"]
1263entitlements:
1264 network:
1265 inbound: { ports: [] }
1266 outbound: { mode: restricted, allow_hosts: [], protocols: ["tcp"], resolve_dns: { mode: system } }
1267 filesystem: { read: [], write: [], deny: [] }
1268 processes: { spawn: { mode: allowlist, allowed: [] } }
1269 syscalls: { mode: default }
1270 limits: { memory_mb: 512, file_descriptors: 1024, processes: 32 }
1271notifications: { on_task_complete: [], on_error: [], on_shutdown: [] }
1272retry:
1273 llm: { max_retries: 3, backoff: exponential, initial_delay_ms: 1000, max_delay_ms: 30000, retry_on: [rate_limit, timeout, connection_error] }
1274 tool: { max_retries: 1, backoff: fixed, initial_delay_ms: 500 }
1275lifecycle: { restart: on_failure, max_restarts: 3, restart_window_secs: 600, stop_timeout_secs: 15, mcp_required: true }
1276created_at: "2026-04-22T10:00:00+08:00"
1277updated_at: "2026-04-22T10:00:00+08:00"
1278"#;
1279 let profile: AgentProfile = serde_yaml_ng::from_str(yaml).expect("parse");
1280 assert_eq!(profile.name, "agent_a");
1281 assert_eq!(profile.persona.category, PersonaCategory::Research);
1282 assert_eq!(
1283 profile.entitlements.network.outbound.mode,
1284 NetworkOutboundMode::Restricted
1285 );
1286 let reserialized = serde_yaml_ng::to_string(&profile).expect("emit");
1287 let round_tripped: AgentProfile = serde_yaml_ng::from_str(&reserialized).expect("re-parse");
1288 assert_eq!(profile.id, round_tripped.id);
1289 }
1290}
1291
1292#[cfg(test)]
1293mod model_ref_tests {
1294 use super::*;
1295
1296 #[test]
1297 fn legacy_profile_without_model_ref_still_parses() {
1298 let yaml = include_str!("../tests/fixtures/profile_p0a_minimal.yaml");
1299 let p: AgentProfile = serde_yaml_ng::from_str(yaml).unwrap();
1300 assert!(
1301 p.model_ref.is_none(),
1302 "legacy profile must not have model_ref"
1303 );
1304 }
1305
1306 #[test]
1307 fn round_trip_with_model_ref_preserves_field() {
1308 let yaml = include_str!("../tests/fixtures/profile_p0a_minimal.yaml");
1309 let mut p: AgentProfile = serde_yaml_ng::from_str(yaml).unwrap();
1310 p.model_ref = Some("anthropic_opus_4_7".into());
1311 let s = serde_yaml_ng::to_string(&p).unwrap();
1312 assert!(s.contains("model_ref: anthropic_opus_4_7"), "yaml: {s}");
1313 let p2: AgentProfile = serde_yaml_ng::from_str(&s).unwrap();
1314 assert_eq!(p2.model_ref.as_deref(), Some("anthropic_opus_4_7"));
1315 }
1316}
1317
1318#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1326#[serde(rename_all = "snake_case")]
1327pub enum ProactiveTier {
1328 Off,
1329 WarmOnly,
1330 WarmAndBehavior,
1331 All,
1332}
1333
1334impl ProactiveTier {
1335 pub fn from_config(c: &CompanionConfig) -> Self {
1336 match (c.enabled, c.rhythm.enabled, c.proactive.enabled) {
1337 (false, _, _) => Self::Off,
1338 (true, false, false) => Self::WarmOnly,
1339 (true, true, false) => Self::WarmAndBehavior,
1340 (true, _, true) => Self::All,
1341 }
1342 }
1343
1344 pub fn apply(&self, c: &mut CompanionConfig) {
1345 match self {
1346 Self::Off => {
1347 c.enabled = false;
1348 c.rhythm.enabled = false;
1349 c.proactive.enabled = false;
1350 }
1351 Self::WarmOnly => {
1352 c.enabled = true;
1353 c.rhythm.enabled = false;
1354 c.proactive.enabled = false;
1355 }
1356 Self::WarmAndBehavior => {
1357 c.enabled = true;
1358 c.rhythm.enabled = true;
1359 c.proactive.enabled = false;
1360 }
1361 Self::All => {
1362 c.enabled = true;
1363 c.rhythm.enabled = true;
1364 c.proactive.enabled = true;
1365 }
1366 }
1367 }
1368}
1369
1370#[cfg(test)]
1371mod mcp_pin_tests {
1372 use super::*;
1373
1374 #[test]
1378 fn pre_m9_entry_roundtrips_without_pin_fields() {
1379 let yaml = r#"
1380name: weather
1381command: /opt/mcp/weather
1382args: ["--port", "0"]
1383"#;
1384 let entry: McpServerEntry = serde_yaml_ng::from_str(yaml).unwrap();
1385 assert_eq!(entry.name, "weather");
1386 assert_eq!(entry.binary_sha256, None);
1387 assert_eq!(entry.description_hash, None);
1388 assert_eq!(entry.publisher, None);
1389 assert_eq!(entry.installed_at, None);
1390
1391 let out = serde_yaml_ng::to_string(&entry).unwrap();
1394 assert!(!out.contains("binary_sha256"), "got {out}");
1395 assert!(!out.contains("description_hash"), "got {out}");
1396 assert!(!out.contains("publisher"), "got {out}");
1397 assert!(!out.contains("installed_at"), "got {out}");
1398 }
1399
1400 #[test]
1402 fn full_m9_entry_roundtrips_all_fields() {
1403 let yaml = r#"
1404name: weather
1405command: /opt/mcp/weather
1406args: []
1407binary_sha256: "3f4abca8b0e6e2c1d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b81c"
1408description_hash: "9a01b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9c7e2"
1409publisher:
1410 name: "@anthropic-mcp/weather"
1411 homepage: "https://github.com/anthropic-mcp/weather"
1412 registry_id: "@anthropic-mcp/weather@1.2.3"
1413installed_at: "2026-05-06T08:00:00Z"
1414"#;
1415 let entry: McpServerEntry = serde_yaml_ng::from_str(yaml).unwrap();
1416 assert!(
1417 entry
1418 .binary_sha256
1419 .as_deref()
1420 .unwrap()
1421 .starts_with("3f4abca8")
1422 );
1423 assert!(
1424 entry
1425 .description_hash
1426 .as_deref()
1427 .unwrap()
1428 .starts_with("9a01b2c3")
1429 );
1430 let pub_info = entry.publisher.clone().unwrap();
1431 assert_eq!(pub_info.name, "@anthropic-mcp/weather");
1432 assert_eq!(
1433 pub_info.homepage.as_deref(),
1434 Some("https://github.com/anthropic-mcp/weather"),
1435 );
1436 assert_eq!(
1437 pub_info.registry_id.as_deref(),
1438 Some("@anthropic-mcp/weather@1.2.3"),
1439 );
1440 let installed = entry.installed_at.unwrap();
1441 assert_eq!(installed.to_rfc3339(), "2026-05-06T08:00:00+00:00");
1442 }
1443
1444 #[test]
1448 fn partial_pin_only_binary_sha_roundtrips() {
1449 let yaml = r#"
1450name: weather
1451command: /opt/mcp/weather
1452args: []
1453binary_sha256: "deadbeef00112233445566778899aabbccddeeff00112233445566778899aabb"
1454"#;
1455 let entry: McpServerEntry = serde_yaml_ng::from_str(yaml).unwrap();
1456 assert_eq!(
1457 entry.binary_sha256.as_deref(),
1458 Some("deadbeef00112233445566778899aabbccddeeff00112233445566778899aabb"),
1459 );
1460 assert_eq!(entry.description_hash, None);
1461 assert_eq!(entry.publisher, None);
1462 }
1463
1464 #[test]
1467 fn publisher_minimal_just_name() {
1468 let yaml = r#"
1469name: weather
1470command: /opt/mcp/weather
1471args: []
1472publisher:
1473 name: "alice"
1474"#;
1475 let entry: McpServerEntry = serde_yaml_ng::from_str(yaml).unwrap();
1476 let p = entry.publisher.as_ref().unwrap();
1477 assert_eq!(p.name, "alice");
1478 assert_eq!(p.homepage, None);
1479 assert_eq!(p.registry_id, None);
1480
1481 let out = serde_yaml_ng::to_string(&entry).unwrap();
1483 assert!(!out.contains("homepage:"), "got {out}");
1484 assert!(!out.contains("registry_id:"), "got {out}");
1485 }
1486}
1487
1488#[cfg(test)]
1489mod voice_tests {
1490 use super::*;
1491 use std::str::FromStr;
1492
1493 #[test]
1494 fn voice_config_round_trips() {
1495 let base = include_str!("../tests/fixtures/profile_p0a_minimal.yaml");
1497 let yaml = format!("{base}voice:\n enabled: true\n voice_id: af_bella\n");
1498
1499 let profile: AgentProfile = serde_yaml_ng::from_str(&yaml).expect("parse with voice");
1500 assert!(profile.voice.enabled);
1501 assert_eq!(profile.voice.voice_id, VoiceId::AfBella);
1502
1503 let legacy: AgentProfile = serde_yaml_ng::from_str(base).expect("parse without voice");
1505 assert!(!legacy.voice.enabled);
1506 assert_eq!(legacy.voice.voice_id, VoiceId::AfHeart);
1507 }
1508
1509 #[test]
1510 fn voice_id_from_str_roundtrips() {
1511 let cases = [
1512 ("af_heart", VoiceId::AfHeart),
1513 ("af_bella", VoiceId::AfBella),
1514 ("af_nicole", VoiceId::AfNicole),
1515 ("am_adam", VoiceId::AmAdam),
1516 ("am_michael", VoiceId::AmMichael),
1517 ];
1518 for (s, expected) in cases {
1519 assert_eq!(VoiceId::from_str(s).unwrap(), expected);
1520 assert_eq!(expected.as_str(), s);
1521 }
1522 }
1523
1524 #[test]
1525 fn voice_id_from_str_rejects_unknown() {
1526 assert!(VoiceId::from_str("bogus").is_err());
1527 }
1528}
1529
1530#[cfg(test)]
1531mod idle_trigger_tests {
1532 use super::*;
1533
1534 #[test]
1535 fn idle_trigger_yaml_round_trip() {
1536 let yaml = r#"
1537restart: on_failure
1538idle_triggers:
1539 - after_secs: 3600
1540 message: "still there?"
1541 sends_to: other_agent
1542 cooldown_secs: 1800
1543 respect_quiet_hours: true
1544"#;
1545 let cfg: LifecycleConfig = serde_yaml_ng::from_str(yaml).unwrap();
1546 assert_eq!(cfg.idle_triggers.len(), 1);
1547 assert_eq!(cfg.idle_triggers[0].after_secs, 3600);
1548 assert_eq!(cfg.idle_triggers[0].message, "still there?");
1549 assert_eq!(
1550 cfg.idle_triggers[0].sends_to.as_deref(),
1551 Some("other_agent")
1552 );
1553 assert_eq!(cfg.idle_triggers[0].cooldown_secs, 1800);
1554 assert!(cfg.idle_triggers[0].respect_quiet_hours);
1555 }
1556
1557 #[test]
1558 fn idle_trigger_defaults_when_omitted() {
1559 let yaml = "restart: on_failure\n";
1560 let cfg: LifecycleConfig = serde_yaml_ng::from_str(yaml).unwrap();
1561 assert!(cfg.idle_triggers.is_empty());
1562 }
1563}
1564
1565#[cfg(test)]
1566mod appearance_tests {
1567 use super::*;
1568
1569 #[test]
1570 fn appearance_default_style_preset_is_default_blob() {
1571 assert_eq!(AgentAppearance::default().style_preset, "default-blob");
1572 }
1573
1574 #[test]
1575 fn appearance_default_behavior_is_normal() {
1576 assert_eq!(
1577 AgentAppearance::default().behavior_preset,
1578 BehaviorPreset::Normal
1579 );
1580 }
1581
1582 #[test]
1583 fn appearance_default_render_status_is_pending() {
1584 assert_eq!(
1585 AgentAppearance::default().render_status,
1586 RenderStatus::Pending
1587 );
1588 }
1589
1590 #[test]
1591 fn render_status_serde_round_trip() {
1592 let cases = [
1593 RenderStatus::Pending,
1594 RenderStatus::Rendering { done: 3, total: 12 },
1595 RenderStatus::Ready,
1596 RenderStatus::Failed {
1597 reason: "out of quota".into(),
1598 },
1599 ];
1600 for status in cases {
1601 let yaml = serde_yaml_ng::to_string(&status).expect("serialize");
1602 let back: RenderStatus = serde_yaml_ng::from_str(&yaml).expect("deserialize");
1603 assert_eq!(status, back);
1604 }
1605 }
1606
1607 #[test]
1608 fn agent_profile_with_appearance_round_trips() {
1609 let base = include_str!("../tests/fixtures/profile_p0a_minimal.yaml");
1610 let yaml = format!(
1611 "{base}appearance:\n style_preset: chiikawa\n render_status:\n status: ready\n"
1612 );
1613 let profile: AgentProfile = serde_yaml_ng::from_str(&yaml).expect("parse with appearance");
1614 assert_eq!(profile.appearance.style_preset, "chiikawa");
1615 assert_eq!(profile.appearance.render_status, RenderStatus::Ready);
1616
1617 let out = serde_yaml_ng::to_string(&profile).expect("serialize");
1618 let back: AgentProfile = serde_yaml_ng::from_str(&out).expect("re-parse");
1619 assert_eq!(profile.appearance, back.appearance);
1620 }
1621
1622 #[test]
1623 fn legacy_profile_without_appearance_uses_default() {
1624 let yaml = include_str!("../tests/fixtures/profile_p0a_minimal.yaml");
1625 let profile: AgentProfile = serde_yaml_ng::from_str(yaml).expect("parse legacy");
1626 assert_eq!(profile.appearance.style_preset, "default-blob");
1627 assert_eq!(profile.appearance.behavior_preset, BehaviorPreset::Normal);
1628 assert_eq!(profile.appearance.render_status, RenderStatus::Pending);
1629 }
1630
1631 #[test]
1632 fn legacy_profile_without_file_actions_or_action_pipeline_loads() {
1633 let yaml = include_str!("../tests/fixtures/profile_p0a_minimal.yaml");
1634 let p: AgentProfile = serde_yaml_ng::from_str(yaml).unwrap();
1635 assert!(p.file_actions.is_empty());
1636 assert_eq!(p.action_pipeline.deletion.cancel_window_minutes, 10);
1637 assert_eq!(p.action_pipeline.queue.max_concurrent, 3);
1638 }
1639}
1640
1641#[cfg(test)]
1642mod federation_tests {
1643 use super::*;
1644
1645 #[test]
1646 fn test_pattern_filter_default() {
1647 let f = PatternFilter::default();
1648 assert_eq!(f.max_count, 200);
1649 assert_eq!(f.importance_min, 0.0);
1650 assert!(f.tier.is_empty());
1651 }
1652
1653 #[test]
1654 fn test_federation_config_roundtrip() {
1655 let cfg = FederationConfig {
1656 filter: PatternFilter {
1657 tier: vec!["core".into()],
1658 max_count: 50,
1659 ..Default::default()
1660 },
1661 snapshot_ref: Some(SnapshotRef {
1662 knowledge_commit: "abc123def456".into(),
1663 taken_at: "2026-05-19T00:00:00Z".into(),
1664 filter: PatternFilter::default(),
1665 }),
1666 evidence_flush_interval_minutes: 15,
1667 };
1668 let yaml = serde_yaml_ng::to_string(&cfg).unwrap();
1669 let back: FederationConfig = serde_yaml_ng::from_str(&yaml).unwrap();
1670 assert_eq!(cfg, back);
1671 }
1672
1673 #[test]
1674 fn test_agent_profile_federation_defaults() {
1675 let cfg = FederationConfig::default();
1679 assert_eq!(cfg.evidence_flush_interval_minutes, 0);
1680 assert!(cfg.snapshot_ref.is_none());
1681 }
1682}
1683
1684#[cfg(test)]
1685mod skill_card_tests {
1686 use super::*;
1687
1688 #[test]
1689 fn installed_skills_default_to_empty_when_absent() {
1690 let yaml = include_str!("../tests/fixtures/profile_p0a_minimal.yaml");
1691 let p: AgentProfile = serde_yaml_ng::from_str(yaml).unwrap();
1692 assert!(p.installed_skills.is_empty());
1693 }
1694
1695 #[test]
1696 fn installed_skills_roundtrip_preserves_entries() {
1697 let base = include_str!("../tests/fixtures/profile_p0a_minimal.yaml");
1698 let yaml = format!(
1699 "{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"
1700 );
1701 let p: AgentProfile = serde_yaml_ng::from_str(&yaml).unwrap();
1702 assert_eq!(p.installed_skills.len(), 1);
1703 assert_eq!(p.installed_skills[0].name, "s1");
1704 assert_eq!(p.installed_skills[0].abstract_text, "does things");
1705 assert_eq!(p.installed_skills[0].transfer_chain, vec!["agent://alice"]);
1706
1707 let out = serde_yaml_ng::to_string(&p).unwrap();
1708 assert!(out.contains("abstract: does things"));
1709 assert!(out.contains("pattern: /find"));
1710
1711 let back: AgentProfile = serde_yaml_ng::from_str(&out).unwrap();
1712 assert_eq!(p.installed_skills, back.installed_skills);
1713 }
1714
1715 #[test]
1716 fn installed_skills_minimal_entry_serializes_compactly() {
1717 let entry = SkillCardEntry {
1719 name: "minimal".into(),
1720 ..Default::default()
1721 };
1722 let yaml = serde_yaml_ng::to_string(&entry).unwrap();
1723 assert!(yaml.contains("name: minimal"));
1724 assert!(
1725 !yaml.contains("version:"),
1726 "empty version must be skipped: {yaml}"
1727 );
1728 assert!(
1729 !yaml.contains("publisher:"),
1730 "empty publisher must be skipped: {yaml}"
1731 );
1732 assert!(
1733 !yaml.contains("abstract:"),
1734 "empty abstract must be skipped: {yaml}"
1735 );
1736 }
1737}
1738
1739#[cfg(test)]
1740mod tool_policy_tests {
1741 use super::*;
1742
1743 fn rules() -> Vec<ToolRule> {
1744 vec![
1745 ToolRule {
1746 pattern: "mcp__github__merge_pr".into(),
1747 policy: ToolPolicy::Ask,
1748 risk: None,
1749 },
1750 ToolRule {
1751 pattern: "mcp__github__*".into(),
1752 policy: ToolPolicy::Allow,
1753 risk: None,
1754 },
1755 ToolRule {
1756 pattern: "mcp__*".into(),
1757 policy: ToolPolicy::Deny,
1758 risk: None,
1759 },
1760 ToolRule {
1761 pattern: "bash".into(),
1762 policy: ToolPolicy::Allow,
1763 risk: None,
1764 },
1765 ]
1766 }
1767
1768 #[test]
1769 fn exact_beats_glob() {
1770 assert_eq!(
1771 resolve_tool_policy(&rules(), "mcp__github__merge_pr"),
1772 ToolPolicy::Ask
1773 );
1774 }
1775
1776 #[test]
1777 fn longer_glob_wins() {
1778 assert_eq!(
1779 resolve_tool_policy(&rules(), "mcp__github__create_issue"),
1780 ToolPolicy::Allow
1781 );
1782 }
1783
1784 #[test]
1785 fn shorter_glob_fallback() {
1786 assert_eq!(
1787 resolve_tool_policy(&rules(), "mcp__slack__send"),
1788 ToolPolicy::Deny
1789 );
1790 }
1791
1792 #[test]
1793 fn exact_bash() {
1794 assert_eq!(resolve_tool_policy(&rules(), "bash"), ToolPolicy::Allow);
1795 }
1796
1797 #[test]
1798 fn unknown_tool_defaults_ask() {
1799 assert_eq!(
1800 resolve_tool_policy(&rules(), "unknown_tool"),
1801 ToolPolicy::Ask
1802 );
1803 }
1804
1805 #[test]
1806 fn empty_rules_defaults_ask() {
1807 assert_eq!(resolve_tool_policy(&[], "bash"), ToolPolicy::Ask);
1808 }
1809
1810 fn minimal_entitlements_yaml() -> &'static str {
1811 "network:\n inbound: {}\n outbound:\n mode: off\nfilesystem: {}\nprocesses:\n spawn:\n mode: none\n"
1812 }
1813
1814 #[test]
1815 fn entitlements_tools_defaults_empty() {
1816 let e: Entitlements = serde_yaml_ng::from_str(minimal_entitlements_yaml()).unwrap();
1817 assert!(e.tools.is_empty());
1818 }
1819
1820 #[test]
1821 fn entitlements_tools_roundtrip() {
1822 let base = minimal_entitlements_yaml();
1823 let yaml = format!("{base}tools:\n - pattern: \"mcp__github__*\"\n policy: allow\n");
1824 let e: Entitlements = serde_yaml_ng::from_str(&yaml).unwrap();
1825 assert_eq!(e.tools.len(), 1);
1826 assert_eq!(e.tools[0].policy, ToolPolicy::Allow);
1827 let y = serde_yaml_ng::to_string(&e).unwrap();
1828 let back: Entitlements = serde_yaml_ng::from_str(&y).unwrap();
1829 assert_eq!(back.tools.len(), 1);
1830 assert_eq!(back.tools[0].policy, ToolPolicy::Allow);
1831 }
1832}