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