1use crate::companion::{Formality, Relationship};
5use serde::{Deserialize, Serialize};
6use std::collections::BTreeMap;
7
8#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
9pub struct AgentProfile {
10 pub schema: u32,
11 pub id: String, pub name: String,
13 pub display_name: String,
14 pub version: String,
15 pub persona: Persona,
16 pub sys_prompt_file: String,
17 pub model: ModelConfig,
18 #[serde(default, skip_serializing_if = "Option::is_none")]
21 pub model_ref: Option<String>,
22 #[serde(default)]
23 pub mcp_servers: Vec<McpServerEntry>,
24 #[serde(default)]
25 pub skills: Vec<String>,
26 pub transport: TransportConfig,
27 pub communication: CommunicationConfig,
28 #[serde(default)]
29 pub capabilities: Vec<String>,
30 pub entitlements: Entitlements,
31 #[serde(default)]
32 pub notifications: NotificationsConfig,
33 pub retry: RetryConfig,
34 pub lifecycle: LifecycleConfig,
35 #[serde(default)]
38 pub identity: IdentityConfig,
39 #[serde(default)]
40 pub file_transfer: FileTransferConfig,
41 #[serde(default)]
42 pub deployment: DeploymentConfig,
43 #[serde(default)]
46 pub companion: CompanionConfig,
47 #[serde(default)]
49 pub voice: VoiceConfig,
50 #[serde(default)]
52 pub hooks: crate::HooksConfig,
53 #[serde(default)]
56 pub trusted_peers: Vec<crate::bridge::peer::TrustedPeer>,
57 pub created_at: String,
58 pub updated_at: String,
59 #[serde(default)]
61 pub appearance: AgentAppearance,
62 #[serde(default)]
64 pub federation: FederationConfig,
65}
66
67fn default_algorithm() -> String {
68 "ed25519".into()
69}
70
71pub const SUPPORTED_ALGORITHMS: &[&str] = &["ed25519"];
73
74#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
75pub struct IdentityConfig {
76 #[serde(default)]
79 pub pubkey: String,
80 #[serde(default, skip_serializing_if = "Option::is_none")]
82 pub owner: Option<String>,
83
84 #[serde(default = "default_algorithm")]
87 pub algorithm: String,
88 #[serde(default)]
90 pub key_version: u32,
91 #[serde(default, skip_serializing_if = "Option::is_none")]
93 pub created_at_key: Option<String>,
94 #[serde(default, skip_serializing_if = "Option::is_none")]
96 pub previous_pubkey: Option<String>,
97 #[serde(default, skip_serializing_if = "Option::is_none")]
99 pub previous_key_version: Option<u32>,
100 #[serde(default, skip_serializing_if = "Option::is_none")]
103 pub grace_expires_at: Option<String>,
104 #[serde(default, skip_serializing_if = "Option::is_none")]
106 pub rotated_at: Option<String>,
107 #[serde(default, skip_serializing_if = "Option::is_none")]
109 pub emergency_rekey_at: Option<String>,
110}
111
112impl Default for IdentityConfig {
113 fn default() -> Self {
114 Self {
115 pubkey: String::new(),
116 owner: None,
117 algorithm: default_algorithm(),
118 key_version: 0,
119 created_at_key: None,
120 previous_pubkey: None,
121 previous_key_version: None,
122 grace_expires_at: None,
123 rotated_at: None,
124 emergency_rekey_at: None,
125 }
126 }
127}
128
129#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
130pub struct Persona {
131 pub category: PersonaCategory,
132 pub description: String,
133 pub traits: PersonaTraits,
134}
135
136#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
137#[serde(rename_all = "lowercase")]
138pub enum PersonaCategory {
139 Research,
140 Automation,
141 Monitor,
142 Notify,
143 Commerce,
144 Custom,
145}
146
147#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
148pub struct PersonaTraits {
149 pub tone: String,
150 pub risk: String,
151 pub verbosity: String,
152}
153
154#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
155pub struct ModelConfig {
156 pub provider: String,
157 pub name: String,
158 #[serde(default)]
159 pub params: BTreeMap<String, serde_yaml_ng::Value>,
160}
161
162#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
163pub struct McpServerEntry {
164 pub name: String,
165 pub command: String,
166 #[serde(default)]
167 pub args: Vec<String>,
168
169 #[serde(default, skip_serializing_if = "Option::is_none")]
175 pub binary_sha256: Option<String>,
176
177 #[serde(default, skip_serializing_if = "Option::is_none")]
183 pub description_hash: Option<String>,
184
185 #[serde(default, skip_serializing_if = "Option::is_none")]
189 pub publisher: Option<McpPublisherInfo>,
190
191 #[serde(default, skip_serializing_if = "Option::is_none")]
195 pub installed_at: Option<chrono::DateTime<chrono::Utc>>,
196}
197
198#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
204pub struct McpPublisherInfo {
205 pub name: String,
208
209 #[serde(default, skip_serializing_if = "Option::is_none")]
213 pub homepage: Option<String>,
214
215 #[serde(default, skip_serializing_if = "Option::is_none")]
218 pub registry_id: Option<String>,
219}
220
221#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
222pub struct TransportConfig {
223 pub stdio: bool,
224 pub socket: SocketTransportConfig,
225 #[serde(default)]
226 pub tcp: TcpTransportConfig,
227 #[serde(default)]
231 pub webhook: WebhookTransportConfig,
232}
233
234#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
235pub struct TcpTransportConfig {
236 #[serde(default)]
237 pub enabled: bool,
238 #[serde(default)]
239 pub bind: String,
240 #[serde(default)]
241 pub noise: NoiseConfig,
242}
243
244#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
258pub struct WebhookTransportConfig {
259 #[serde(default)]
260 pub enabled: bool,
261 #[serde(default = "default_webhook_bind")]
262 pub bind: String,
263 #[serde(default = "default_webhook_port")]
264 pub port: u16,
265 #[serde(default)]
269 pub hmac_secret_ref: String,
270}
271
272fn default_webhook_bind() -> String {
273 "127.0.0.1".to_string()
274}
275
276fn default_webhook_port() -> u16 {
277 6789
278}
279
280impl Default for WebhookTransportConfig {
281 fn default() -> Self {
282 Self {
283 enabled: false,
284 bind: default_webhook_bind(),
285 port: default_webhook_port(),
286 hmac_secret_ref: String::new(),
287 }
288 }
289}
290
291#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
292pub struct NoiseConfig {
293 pub pattern: String,
294}
295
296impl Default for NoiseConfig {
297 fn default() -> Self {
298 Self {
299 pattern: "Noise_XK_25519_ChaChaPoly_BLAKE2s".into(),
300 }
301 }
302}
303
304#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
305pub struct SocketTransportConfig {
306 pub enabled: bool,
307 pub bind: String, #[serde(default, skip_serializing_if = "Option::is_none")]
309 pub auth: Option<AuthConfig>,
310}
311
312#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
313pub struct AuthConfig {
314 pub scheme: String,
315 pub token_file: String,
316}
317
318#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
319pub struct CommunicationConfig {
320 #[serde(default = "default_accepts_all")]
321 pub accepts_from: Vec<String>,
322 #[serde(default)]
323 pub sends_to: Vec<String>,
324}
325fn default_accepts_all() -> Vec<String> {
326 vec!["*".to_string()]
327}
328
329#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
330pub struct Entitlements {
331 pub network: NetworkEntitlement,
332 pub filesystem: FilesystemEntitlement,
333 pub processes: ProcessesEntitlement,
334 #[serde(default)]
335 pub syscalls: SyscallsEntitlement,
336 #[serde(default)]
337 pub limits: LimitsEntitlement,
338 #[serde(default)]
341 pub llm: crate::bridge::llm_entitlement::LlmEntitlement,
342}
343
344#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
345pub struct NetworkEntitlement {
346 pub inbound: InboundNetwork,
347 pub outbound: OutboundNetwork,
348}
349
350#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
351pub struct InboundNetwork {
352 #[serde(default)]
353 pub ports: Vec<u16>,
354}
355
356#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
357pub struct OutboundNetwork {
358 pub mode: NetworkOutboundMode,
359 #[serde(default)]
360 pub allow_hosts: Vec<String>,
361 #[serde(default = "default_protocols")]
362 pub protocols: Vec<String>,
363 #[serde(default)]
364 pub resolve_dns: ResolveDnsConfig,
365}
366fn default_protocols() -> Vec<String> {
367 vec!["tcp".to_string()]
368}
369
370#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
371#[serde(rename_all = "lowercase")]
372pub enum NetworkOutboundMode {
373 Unrestricted,
374 Restricted,
375 Off,
376}
377
378#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
379pub struct ResolveDnsConfig {
380 #[serde(default = "default_dns_mode")]
381 pub mode: String,
382 #[serde(default)]
383 pub servers: Vec<String>,
384}
385impl Default for ResolveDnsConfig {
386 fn default() -> Self {
387 Self {
388 mode: default_dns_mode(),
389 servers: vec![],
390 }
391 }
392}
393fn default_dns_mode() -> String {
394 "system".to_string()
395}
396
397#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
398pub struct FilesystemEntitlement {
399 #[serde(default)]
400 pub read: Vec<String>,
401 #[serde(default)]
402 pub write: Vec<String>,
403 #[serde(default)]
404 pub deny: Vec<String>,
405}
406
407#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
408pub struct ProcessesEntitlement {
409 pub spawn: SpawnEntitlement,
410}
411
412#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
413pub struct SpawnEntitlement {
414 pub mode: SpawnMode,
415 #[serde(default)]
416 pub allowed: Vec<String>,
417}
418
419#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
420#[serde(rename_all = "lowercase")]
421pub enum SpawnMode {
422 Allowlist,
423 Any,
424 None,
425}
426
427#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
428pub struct SyscallsEntitlement {
429 #[serde(default = "default_syscalls_mode")]
430 pub mode: String,
431 #[serde(default)]
432 pub extra_deny: Vec<String>,
433}
434fn default_syscalls_mode() -> String {
435 "default".to_string()
436}
437
438#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
439pub struct LimitsEntitlement {
440 #[serde(default)]
441 pub cpu_seconds: Option<u64>,
442 #[serde(default = "default_memory_mb")]
443 pub memory_mb: u64,
444 #[serde(default = "default_fds")]
445 pub file_descriptors: u32,
446 #[serde(default = "default_procs")]
447 pub processes: u32,
448}
449fn default_memory_mb() -> u64 {
450 512
451}
452fn default_fds() -> u32 {
453 1024
454}
455fn default_procs() -> u32 {
456 32
457}
458
459#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
460pub struct NotificationsConfig {
461 #[serde(default)]
462 pub on_task_complete: Vec<NotificationTarget>,
463 #[serde(default)]
464 pub on_error: Vec<NotificationTarget>,
465 #[serde(default)]
466 pub on_shutdown: Vec<NotificationTarget>,
467}
468
469#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
470#[serde(tag = "target", rename_all = "lowercase")]
471pub enum NotificationTarget {
472 Agent {
473 name: String,
474 },
475 Commander,
476 Email {
477 address: String,
478 #[serde(default)]
479 smtp_config_file: Option<String>,
480 },
481 Slack {
482 #[serde(default)]
483 channel: Option<String>,
484 #[serde(default)]
485 webhook_url_env: Option<String>,
486 },
487 Webpush {
488 url: String,
489 },
490 Webhook {
491 url: String,
492 #[serde(default = "default_post")]
493 method: String,
494 #[serde(default)]
495 auth: Option<String>,
496 },
497}
498fn default_post() -> String {
499 "POST".to_string()
500}
501
502#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
503pub struct RetryConfig {
504 pub llm: RetryPolicy,
505 pub tool: RetryPolicy,
506}
507
508#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
509pub struct RetryPolicy {
510 pub max_retries: u32,
511 pub backoff: BackoffStrategy,
512 pub initial_delay_ms: u64,
513 #[serde(default)]
514 pub max_delay_ms: Option<u64>,
515 #[serde(default)]
516 pub retry_on: Vec<String>,
517}
518
519#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
520#[serde(rename_all = "lowercase")]
521pub enum BackoffStrategy {
522 Linear,
523 Exponential,
524 Fixed,
525}
526
527#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
528pub struct LifecycleConfig {
529 pub restart: RestartPolicy,
530 #[serde(default = "default_max_restarts")]
531 pub max_restarts: u32,
532 #[serde(default = "default_window")]
533 pub restart_window_secs: u64,
534 #[serde(default = "default_stop_timeout")]
535 pub stop_timeout_secs: u64,
536 #[serde(default = "default_mcp_required")]
537 pub mcp_required: bool,
538 #[serde(default)]
539 pub execution: ExecutionMode,
540 #[serde(default)]
541 pub schedule: Vec<ScheduleEntry>,
542 #[serde(default)]
543 pub idle_triggers: Vec<IdleTrigger>,
544}
545fn default_max_restarts() -> u32 {
546 3
547}
548fn default_window() -> u64 {
549 600
550}
551fn default_stop_timeout() -> u64 {
552 15
553}
554fn default_mcp_required() -> bool {
555 true
556}
557
558#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
559#[serde(rename_all = "snake_case")]
560pub enum RestartPolicy {
561 Never,
562 OnFailure,
563 Always,
564}
565
566#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
567#[serde(rename_all = "snake_case")]
568pub enum ExecutionMode {
569 #[default]
570 Daemon,
571 OnDemand,
572}
573
574#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
575pub struct ScheduleEntry {
576 pub cron: String,
577 pub message: String,
578 #[serde(default, skip_serializing_if = "Option::is_none")]
579 pub sends_to: Option<String>,
580}
581
582#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
583pub struct IdleTrigger {
584 pub after_secs: u64,
586 pub message: String,
588 #[serde(default, skip_serializing_if = "Option::is_none")]
590 pub sends_to: Option<String>,
591 #[serde(default = "default_idle_cooldown")]
594 pub cooldown_secs: u64,
595 #[serde(default = "default_true")]
598 pub respect_quiet_hours: bool,
599}
600
601fn default_idle_cooldown() -> u64 {
602 600
603}
604fn default_true() -> bool {
605 true
606}
607
608#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
609pub struct FileTransferConfig {
610 #[serde(default = "default_accept_max")]
611 pub accept_incoming_file_max_bytes: u64,
612 #[serde(default = "default_accept_total")]
613 pub accept_incoming_total_per_hour: u64,
614 #[serde(default = "default_approval_threshold")]
615 pub require_approval_above_bytes: u64,
616 #[serde(default = "default_reject_paths")]
617 pub reject_paths: Vec<String>,
618 #[serde(default = "default_allowed_mime")]
619 pub allowed_mime_types: Vec<String>,
620}
621
622impl Default for FileTransferConfig {
623 fn default() -> Self {
624 Self {
625 accept_incoming_file_max_bytes: default_accept_max(),
626 accept_incoming_total_per_hour: default_accept_total(),
627 require_approval_above_bytes: default_approval_threshold(),
628 reject_paths: default_reject_paths(),
629 allowed_mime_types: default_allowed_mime(),
630 }
631 }
632}
633
634fn default_accept_max() -> u64 {
635 10_485_760
636}
637fn default_accept_total() -> u64 {
638 104_857_600
639}
640fn default_approval_threshold() -> u64 {
641 10_485_760
642}
643fn default_reject_paths() -> Vec<String> {
644 vec!["~/.ssh".into(), "~/.aws".into(), "~/.gnupg".into()]
645}
646fn default_allowed_mime() -> Vec<String> {
647 vec!["*".into()]
648}
649
650#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
651#[serde(rename_all = "snake_case")]
652pub enum DeploymentType {
653 #[default]
654 Laptop,
655 Vm,
656 Docker,
657 K8s,
658 Lambda,
659}
660
661#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
662pub struct DeploymentConfig {
663 #[serde(rename = "type", default)]
664 pub deployment_type: DeploymentType,
665 #[serde(default, skip_serializing_if = "Option::is_none")]
666 pub region: Option<String>,
667 #[serde(default = "default_env")]
668 pub environment: Option<String>,
669}
670
671impl Default for DeploymentConfig {
672 fn default() -> Self {
673 Self {
674 deployment_type: DeploymentType::default(),
675 region: None,
676 environment: default_env(),
677 }
678 }
679}
680
681fn default_env() -> Option<String> {
682 Some("dev".into())
683}
684
685#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
686pub struct LockFile {
687 pub schema: u32,
688 pub uuid: String,
689 pub name: String,
690 pub pid: u32,
691 pub ppid: u32,
692 pub started_at: String,
693 pub binary_version: String,
694 pub transports: LockTransports,
695 pub card_digest: String,
696 pub capabilities: Vec<String>,
697}
698
699#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
700pub struct LockTransports {
701 pub stdio: bool,
702 #[serde(default)]
703 pub unix_socket: Option<String>,
704 #[serde(default)]
705 pub tcp: Option<String>,
706 #[serde(default)]
711 pub webhook: Option<String>,
712}
713
714#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
721#[serde(rename_all = "snake_case")]
722pub enum VoiceId {
723 #[default]
725 AfHeart,
726 AfBella,
727 AfNicole,
728 AmAdam,
729 AmMichael,
730}
731
732impl VoiceId {
733 pub fn style_index(&self) -> usize {
735 match self {
736 VoiceId::AfHeart => 0,
737 VoiceId::AfBella => 1,
738 VoiceId::AfNicole => 2,
739 VoiceId::AmAdam => 3,
740 VoiceId::AmMichael => 4,
741 }
742 }
743
744 pub fn as_str(&self) -> &'static str {
746 match self {
747 VoiceId::AfHeart => "af_heart",
748 VoiceId::AfBella => "af_bella",
749 VoiceId::AfNicole => "af_nicole",
750 VoiceId::AmAdam => "am_adam",
751 VoiceId::AmMichael => "am_michael",
752 }
753 }
754}
755
756impl std::str::FromStr for VoiceId {
757 type Err = anyhow::Error;
758
759 fn from_str(s: &str) -> anyhow::Result<Self> {
760 match s {
761 "af_heart" => Ok(VoiceId::AfHeart),
762 "af_bella" => Ok(VoiceId::AfBella),
763 "af_nicole" => Ok(VoiceId::AfNicole),
764 "am_adam" => Ok(VoiceId::AmAdam),
765 "am_michael" => Ok(VoiceId::AmMichael),
766 other => anyhow::bail!(
767 "unknown voice ID '{other}' \
768 (valid: af_heart, af_bella, af_nicole, am_adam, am_michael)"
769 ),
770 }
771 }
772}
773
774#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
777pub struct VoiceConfig {
778 #[serde(default)]
780 pub enabled: bool,
781 #[serde(default)]
783 pub voice_id: VoiceId,
784 #[serde(default, skip_serializing_if = "Option::is_none")]
787 pub input_device: Option<String>,
788}
789
790#[derive(Debug, Default, Clone, PartialEq, Serialize, Deserialize)]
796pub struct CompanionConfig {
797 #[serde(default)]
798 pub enabled: bool,
799 #[serde(default = "default_locale")]
800 pub locale: String,
801 #[serde(default)]
802 pub relationship: Relationship,
803 #[serde(default)]
804 pub voice_overrides: VoiceOverrides,
805 #[serde(default)]
806 pub onboarding: OnboardingState,
807 #[serde(default)]
808 pub rhythm: RhythmConfig,
809 #[serde(default)]
810 pub proactive: ProactiveConfig,
811}
812
813pub fn default_locale() -> String {
816 std::env::var("LANG")
817 .ok()
818 .and_then(|v| v.split('.').next().map(|s| s.replace('_', "-")))
819 .unwrap_or_else(|| "en-US".into())
820}
821
822#[derive(Debug, Default, Clone, PartialEq, Serialize, Deserialize)]
823pub struct VoiceOverrides {
824 #[serde(default, skip_serializing_if = "Option::is_none")]
825 pub name_for_user: Option<String>,
826 #[serde(default, skip_serializing_if = "Option::is_none")]
827 pub formality: Option<Formality>,
828 #[serde(default, skip_serializing_if = "Option::is_none")]
829 pub extra_instructions: Option<String>,
830}
831
832#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
833pub struct FirstMemory {
834 pub text: String,
835 pub established_at: chrono::DateTime<chrono::Utc>,
836}
837
838#[derive(Debug, Default, Clone, PartialEq, Serialize, Deserialize)]
839pub struct OnboardingState {
840 #[serde(default, skip_serializing_if = "Option::is_none")]
841 pub completed_at: Option<chrono::DateTime<chrono::Utc>>,
842 #[serde(default)]
843 pub version: u32,
844 #[serde(default, skip_serializing_if = "Option::is_none")]
845 pub agent_display_name: Option<String>,
846 #[serde(default, skip_serializing_if = "Option::is_none")]
847 pub first_memory: Option<FirstMemory>,
848}
849
850#[derive(Debug, Default, Clone, PartialEq, Serialize, Deserialize)]
853pub struct RhythmConfig {
854 #[serde(default)]
855 pub enabled: bool,
856}
857
858#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
859pub struct ProactiveConfig {
860 #[serde(default)]
861 pub enabled: bool,
862 #[serde(default, skip_serializing_if = "Option::is_none")]
864 pub learning_until: Option<chrono::DateTime<chrono::Utc>>,
865 #[serde(default, skip_serializing_if = "Option::is_none")]
866 pub quiet_hours: Option<QuietHours>,
867 #[serde(default, skip_serializing_if = "Option::is_none")]
868 pub active_hours: Option<ActiveHours>,
869 #[serde(default = "default_daily_cap")]
870 pub daily_cap: u8,
871 #[serde(default = "default_channels")]
872 pub channels: Vec<String>,
873 #[serde(default, skip_serializing_if = "Option::is_none")]
874 pub paused_until: Option<chrono::DateTime<chrono::Utc>>,
875}
876
877impl Default for ProactiveConfig {
878 fn default() -> Self {
879 Self {
880 enabled: false,
881 learning_until: None,
882 quiet_hours: None,
883 active_hours: None,
884 daily_cap: default_daily_cap(),
885 channels: default_channels(),
886 paused_until: None,
887 }
888 }
889}
890
891fn default_daily_cap() -> u8 {
892 3
893}
894fn default_channels() -> Vec<String> {
895 vec!["stdout".into()]
896}
897
898#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
899pub struct QuietHours {
900 pub start: String,
901 pub end: String,
902}
903
904#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
905pub struct ActiveHours {
906 pub start: String,
907 pub end: String,
908}
909
910#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
915pub struct AgentAppearance {
916 #[serde(default = "default_style_preset")]
918 pub style_preset: String,
919 #[serde(default)]
920 pub behavior_preset: BehaviorPreset,
921 #[serde(default, skip_serializing_if = "Option::is_none")]
923 pub source_image_path: Option<std::path::PathBuf>,
924 #[serde(default = "default_expressions_dir")]
926 pub expressions_dir: std::path::PathBuf,
927 #[serde(default, skip_serializing_if = "Option::is_none")]
928 pub last_rendered_at: Option<chrono::DateTime<chrono::Utc>>,
929 #[serde(default)]
930 pub render_status: RenderStatus,
931}
932
933fn default_style_preset() -> String {
934 "default-blob".into()
935}
936
937fn default_expressions_dir() -> std::path::PathBuf {
938 std::path::PathBuf::from("expressions")
939}
940
941impl Default for AgentAppearance {
942 fn default() -> Self {
943 Self {
944 style_preset: default_style_preset(),
945 behavior_preset: BehaviorPreset::Normal,
946 source_image_path: None,
947 expressions_dir: default_expressions_dir(),
948 last_rendered_at: None,
949 render_status: RenderStatus::Pending,
950 }
951 }
952}
953
954#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
955#[serde(rename_all = "snake_case")]
956pub enum BehaviorPreset {
957 Quiet,
958 #[default]
959 Normal,
960 Lively,
961}
962
963#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
964#[serde(tag = "status", rename_all = "snake_case")]
965pub enum RenderStatus {
966 #[default]
967 Pending,
968 Rendering {
969 done: u8,
970 total: u8,
971 },
972 Ready,
973 Failed {
974 reason: String,
975 },
976}
977
978#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
984#[serde(rename_all = "kebab-case")]
985pub enum SnapshotPolicy {
986 #[default]
987 PullOnStart,
988 PullPeriodic,
989 Manual,
990}
991
992#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
994pub struct PatternFilter {
995 #[serde(default, skip_serializing_if = "Vec::is_empty")]
996 pub applies_in: Vec<String>,
997 #[serde(default, skip_serializing_if = "Vec::is_empty")]
998 pub tier: Vec<String>,
999 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1000 pub maturity: Vec<String>,
1001 #[serde(default)]
1002 pub importance_min: f64,
1003 #[serde(default = "default_max_snapshot_count")]
1004 pub max_count: usize,
1005 #[serde(default)]
1006 pub snapshot_policy: SnapshotPolicy,
1007}
1008
1009fn default_max_snapshot_count() -> usize {
1010 200
1011}
1012
1013impl Default for PatternFilter {
1014 fn default() -> Self {
1015 Self {
1016 applies_in: vec![],
1017 tier: vec![],
1018 maturity: vec![],
1019 importance_min: 0.0,
1020 max_count: 200,
1021 snapshot_policy: SnapshotPolicy::default(),
1022 }
1023 }
1024}
1025
1026#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
1028pub struct SnapshotRef {
1029 pub knowledge_commit: String,
1030 pub taken_at: String,
1031 pub filter: PatternFilter,
1032}
1033
1034#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
1036pub struct FederationConfig {
1037 #[serde(default)]
1038 pub filter: PatternFilter,
1039 #[serde(default, skip_serializing_if = "Option::is_none")]
1040 pub snapshot_ref: Option<SnapshotRef>,
1041 #[serde(default)]
1042 pub evidence_flush_interval_minutes: u32,
1043}
1044
1045impl AgentProfile {
1046 #[doc(hidden)]
1052 pub fn default_for_tests() -> Self {
1053 serde_yaml_ng::from_str(include_str!("../tests/fixtures/minimal_profile.yaml"))
1054 .expect("minimal profile fixture")
1055 }
1056}
1057
1058#[cfg(test)]
1059mod tests {
1060 use super::*;
1061
1062 #[test]
1063 fn profile_round_trip_yaml() {
1064 let yaml = r#"
1065schema: 1
1066id: 01JQX4TM8Y9K7VQH6B2N3R5DPE
1067name: agent_a
1068display_name: "Price Hunter"
1069version: "0.1.0"
1070persona:
1071 category: research
1072 description: "Finds prices"
1073 traits: { tone: concise, risk: cautious, verbosity: low }
1074sys_prompt_file: "sys_prompt.md"
1075model: { provider: ollama, name: "llama3.2:3b", params: { temperature: 0.2, max_tokens: 4096 } }
1076mcp_servers: []
1077skills: []
1078transport:
1079 stdio: true
1080 socket: { enabled: true, bind: "unix:///tmp/a.sock" }
1081communication: { accepts_from: ["*"], sends_to: [] }
1082capabilities: ["a2a.message.send", "a2a.tasks"]
1083entitlements:
1084 network:
1085 inbound: { ports: [] }
1086 outbound: { mode: restricted, allow_hosts: [], protocols: ["tcp"], resolve_dns: { mode: system } }
1087 filesystem: { read: [], write: [], deny: [] }
1088 processes: { spawn: { mode: allowlist, allowed: [] } }
1089 syscalls: { mode: default }
1090 limits: { memory_mb: 512, file_descriptors: 1024, processes: 32 }
1091notifications: { on_task_complete: [], on_error: [], on_shutdown: [] }
1092retry:
1093 llm: { max_retries: 3, backoff: exponential, initial_delay_ms: 1000, max_delay_ms: 30000, retry_on: [rate_limit, timeout, connection_error] }
1094 tool: { max_retries: 1, backoff: fixed, initial_delay_ms: 500 }
1095lifecycle: { restart: on_failure, max_restarts: 3, restart_window_secs: 600, stop_timeout_secs: 15, mcp_required: true }
1096created_at: "2026-04-22T10:00:00+08:00"
1097updated_at: "2026-04-22T10:00:00+08:00"
1098"#;
1099 let profile: AgentProfile = serde_yaml_ng::from_str(yaml).expect("parse");
1100 assert_eq!(profile.name, "agent_a");
1101 assert_eq!(profile.persona.category, PersonaCategory::Research);
1102 assert_eq!(
1103 profile.entitlements.network.outbound.mode,
1104 NetworkOutboundMode::Restricted
1105 );
1106 let reserialized = serde_yaml_ng::to_string(&profile).expect("emit");
1107 let round_tripped: AgentProfile = serde_yaml_ng::from_str(&reserialized).expect("re-parse");
1108 assert_eq!(profile.id, round_tripped.id);
1109 }
1110}
1111
1112#[cfg(test)]
1113mod model_ref_tests {
1114 use super::*;
1115
1116 #[test]
1117 fn legacy_profile_without_model_ref_still_parses() {
1118 let yaml = include_str!("../tests/fixtures/profile_p0a_minimal.yaml");
1119 let p: AgentProfile = serde_yaml_ng::from_str(yaml).unwrap();
1120 assert!(
1121 p.model_ref.is_none(),
1122 "legacy profile must not have model_ref"
1123 );
1124 }
1125
1126 #[test]
1127 fn round_trip_with_model_ref_preserves_field() {
1128 let yaml = include_str!("../tests/fixtures/profile_p0a_minimal.yaml");
1129 let mut p: AgentProfile = serde_yaml_ng::from_str(yaml).unwrap();
1130 p.model_ref = Some("anthropic_opus_4_7".into());
1131 let s = serde_yaml_ng::to_string(&p).unwrap();
1132 assert!(s.contains("model_ref: anthropic_opus_4_7"), "yaml: {s}");
1133 let p2: AgentProfile = serde_yaml_ng::from_str(&s).unwrap();
1134 assert_eq!(p2.model_ref.as_deref(), Some("anthropic_opus_4_7"));
1135 }
1136}
1137
1138#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1146#[serde(rename_all = "snake_case")]
1147pub enum ProactiveTier {
1148 Off,
1149 WarmOnly,
1150 WarmAndBehavior,
1151 All,
1152}
1153
1154impl ProactiveTier {
1155 pub fn from_config(c: &CompanionConfig) -> Self {
1156 match (c.enabled, c.rhythm.enabled, c.proactive.enabled) {
1157 (false, _, _) => Self::Off,
1158 (true, false, false) => Self::WarmOnly,
1159 (true, true, false) => Self::WarmAndBehavior,
1160 (true, _, true) => Self::All,
1161 }
1162 }
1163
1164 pub fn apply(&self, c: &mut CompanionConfig) {
1165 match self {
1166 Self::Off => {
1167 c.enabled = false;
1168 c.rhythm.enabled = false;
1169 c.proactive.enabled = false;
1170 }
1171 Self::WarmOnly => {
1172 c.enabled = true;
1173 c.rhythm.enabled = false;
1174 c.proactive.enabled = false;
1175 }
1176 Self::WarmAndBehavior => {
1177 c.enabled = true;
1178 c.rhythm.enabled = true;
1179 c.proactive.enabled = false;
1180 }
1181 Self::All => {
1182 c.enabled = true;
1183 c.rhythm.enabled = true;
1184 c.proactive.enabled = true;
1185 }
1186 }
1187 }
1188}
1189
1190#[cfg(test)]
1191mod mcp_pin_tests {
1192 use super::*;
1193
1194 #[test]
1198 fn pre_m9_entry_roundtrips_without_pin_fields() {
1199 let yaml = r#"
1200name: weather
1201command: /opt/mcp/weather
1202args: ["--port", "0"]
1203"#;
1204 let entry: McpServerEntry = serde_yaml_ng::from_str(yaml).unwrap();
1205 assert_eq!(entry.name, "weather");
1206 assert_eq!(entry.binary_sha256, None);
1207 assert_eq!(entry.description_hash, None);
1208 assert_eq!(entry.publisher, None);
1209 assert_eq!(entry.installed_at, None);
1210
1211 let out = serde_yaml_ng::to_string(&entry).unwrap();
1214 assert!(!out.contains("binary_sha256"), "got {out}");
1215 assert!(!out.contains("description_hash"), "got {out}");
1216 assert!(!out.contains("publisher"), "got {out}");
1217 assert!(!out.contains("installed_at"), "got {out}");
1218 }
1219
1220 #[test]
1222 fn full_m9_entry_roundtrips_all_fields() {
1223 let yaml = r#"
1224name: weather
1225command: /opt/mcp/weather
1226args: []
1227binary_sha256: "3f4abca8b0e6e2c1d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b81c"
1228description_hash: "9a01b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9c7e2"
1229publisher:
1230 name: "@anthropic-mcp/weather"
1231 homepage: "https://github.com/anthropic-mcp/weather"
1232 registry_id: "@anthropic-mcp/weather@1.2.3"
1233installed_at: "2026-05-06T08:00:00Z"
1234"#;
1235 let entry: McpServerEntry = serde_yaml_ng::from_str(yaml).unwrap();
1236 assert!(
1237 entry
1238 .binary_sha256
1239 .as_deref()
1240 .unwrap()
1241 .starts_with("3f4abca8")
1242 );
1243 assert!(
1244 entry
1245 .description_hash
1246 .as_deref()
1247 .unwrap()
1248 .starts_with("9a01b2c3")
1249 );
1250 let pub_info = entry.publisher.clone().unwrap();
1251 assert_eq!(pub_info.name, "@anthropic-mcp/weather");
1252 assert_eq!(
1253 pub_info.homepage.as_deref(),
1254 Some("https://github.com/anthropic-mcp/weather"),
1255 );
1256 assert_eq!(
1257 pub_info.registry_id.as_deref(),
1258 Some("@anthropic-mcp/weather@1.2.3"),
1259 );
1260 let installed = entry.installed_at.unwrap();
1261 assert_eq!(installed.to_rfc3339(), "2026-05-06T08:00:00+00:00");
1262 }
1263
1264 #[test]
1268 fn partial_pin_only_binary_sha_roundtrips() {
1269 let yaml = r#"
1270name: weather
1271command: /opt/mcp/weather
1272args: []
1273binary_sha256: "deadbeef00112233445566778899aabbccddeeff00112233445566778899aabb"
1274"#;
1275 let entry: McpServerEntry = serde_yaml_ng::from_str(yaml).unwrap();
1276 assert_eq!(
1277 entry.binary_sha256.as_deref(),
1278 Some("deadbeef00112233445566778899aabbccddeeff00112233445566778899aabb"),
1279 );
1280 assert_eq!(entry.description_hash, None);
1281 assert_eq!(entry.publisher, None);
1282 }
1283
1284 #[test]
1287 fn publisher_minimal_just_name() {
1288 let yaml = r#"
1289name: weather
1290command: /opt/mcp/weather
1291args: []
1292publisher:
1293 name: "alice"
1294"#;
1295 let entry: McpServerEntry = serde_yaml_ng::from_str(yaml).unwrap();
1296 let p = entry.publisher.as_ref().unwrap();
1297 assert_eq!(p.name, "alice");
1298 assert_eq!(p.homepage, None);
1299 assert_eq!(p.registry_id, None);
1300
1301 let out = serde_yaml_ng::to_string(&entry).unwrap();
1303 assert!(!out.contains("homepage:"), "got {out}");
1304 assert!(!out.contains("registry_id:"), "got {out}");
1305 }
1306}
1307
1308#[cfg(test)]
1309mod voice_tests {
1310 use super::*;
1311 use std::str::FromStr;
1312
1313 #[test]
1314 fn voice_config_round_trips() {
1315 let base = include_str!("../tests/fixtures/profile_p0a_minimal.yaml");
1317 let yaml = format!("{base}voice:\n enabled: true\n voice_id: af_bella\n");
1318
1319 let profile: AgentProfile = serde_yaml_ng::from_str(&yaml).expect("parse with voice");
1320 assert!(profile.voice.enabled);
1321 assert_eq!(profile.voice.voice_id, VoiceId::AfBella);
1322
1323 let legacy: AgentProfile = serde_yaml_ng::from_str(base).expect("parse without voice");
1325 assert!(!legacy.voice.enabled);
1326 assert_eq!(legacy.voice.voice_id, VoiceId::AfHeart);
1327 }
1328
1329 #[test]
1330 fn voice_id_from_str_roundtrips() {
1331 let cases = [
1332 ("af_heart", VoiceId::AfHeart),
1333 ("af_bella", VoiceId::AfBella),
1334 ("af_nicole", VoiceId::AfNicole),
1335 ("am_adam", VoiceId::AmAdam),
1336 ("am_michael", VoiceId::AmMichael),
1337 ];
1338 for (s, expected) in cases {
1339 assert_eq!(VoiceId::from_str(s).unwrap(), expected);
1340 assert_eq!(expected.as_str(), s);
1341 }
1342 }
1343
1344 #[test]
1345 fn voice_id_from_str_rejects_unknown() {
1346 assert!(VoiceId::from_str("bogus").is_err());
1347 }
1348}
1349
1350#[cfg(test)]
1351mod idle_trigger_tests {
1352 use super::*;
1353
1354 #[test]
1355 fn idle_trigger_yaml_round_trip() {
1356 let yaml = r#"
1357restart: on_failure
1358idle_triggers:
1359 - after_secs: 3600
1360 message: "still there?"
1361 sends_to: other_agent
1362 cooldown_secs: 1800
1363 respect_quiet_hours: true
1364"#;
1365 let cfg: LifecycleConfig = serde_yaml_ng::from_str(yaml).unwrap();
1366 assert_eq!(cfg.idle_triggers.len(), 1);
1367 assert_eq!(cfg.idle_triggers[0].after_secs, 3600);
1368 assert_eq!(cfg.idle_triggers[0].message, "still there?");
1369 assert_eq!(
1370 cfg.idle_triggers[0].sends_to.as_deref(),
1371 Some("other_agent")
1372 );
1373 assert_eq!(cfg.idle_triggers[0].cooldown_secs, 1800);
1374 assert!(cfg.idle_triggers[0].respect_quiet_hours);
1375 }
1376
1377 #[test]
1378 fn idle_trigger_defaults_when_omitted() {
1379 let yaml = "restart: on_failure\n";
1380 let cfg: LifecycleConfig = serde_yaml_ng::from_str(yaml).unwrap();
1381 assert!(cfg.idle_triggers.is_empty());
1382 }
1383}
1384
1385#[cfg(test)]
1386mod appearance_tests {
1387 use super::*;
1388
1389 #[test]
1390 fn appearance_default_style_preset_is_default_blob() {
1391 assert_eq!(AgentAppearance::default().style_preset, "default-blob");
1392 }
1393
1394 #[test]
1395 fn appearance_default_behavior_is_normal() {
1396 assert_eq!(
1397 AgentAppearance::default().behavior_preset,
1398 BehaviorPreset::Normal
1399 );
1400 }
1401
1402 #[test]
1403 fn appearance_default_render_status_is_pending() {
1404 assert_eq!(
1405 AgentAppearance::default().render_status,
1406 RenderStatus::Pending
1407 );
1408 }
1409
1410 #[test]
1411 fn render_status_serde_round_trip() {
1412 let cases = [
1413 RenderStatus::Pending,
1414 RenderStatus::Rendering { done: 3, total: 12 },
1415 RenderStatus::Ready,
1416 RenderStatus::Failed {
1417 reason: "out of quota".into(),
1418 },
1419 ];
1420 for status in cases {
1421 let yaml = serde_yaml_ng::to_string(&status).expect("serialize");
1422 let back: RenderStatus = serde_yaml_ng::from_str(&yaml).expect("deserialize");
1423 assert_eq!(status, back);
1424 }
1425 }
1426
1427 #[test]
1428 fn agent_profile_with_appearance_round_trips() {
1429 let base = include_str!("../tests/fixtures/profile_p0a_minimal.yaml");
1430 let yaml = format!(
1431 "{base}appearance:\n style_preset: chiikawa\n render_status:\n status: ready\n"
1432 );
1433 let profile: AgentProfile = serde_yaml_ng::from_str(&yaml).expect("parse with appearance");
1434 assert_eq!(profile.appearance.style_preset, "chiikawa");
1435 assert_eq!(profile.appearance.render_status, RenderStatus::Ready);
1436
1437 let out = serde_yaml_ng::to_string(&profile).expect("serialize");
1438 let back: AgentProfile = serde_yaml_ng::from_str(&out).expect("re-parse");
1439 assert_eq!(profile.appearance, back.appearance);
1440 }
1441
1442 #[test]
1443 fn legacy_profile_without_appearance_uses_default() {
1444 let yaml = include_str!("../tests/fixtures/profile_p0a_minimal.yaml");
1445 let profile: AgentProfile = serde_yaml_ng::from_str(yaml).expect("parse legacy");
1446 assert_eq!(profile.appearance.style_preset, "default-blob");
1447 assert_eq!(profile.appearance.behavior_preset, BehaviorPreset::Normal);
1448 assert_eq!(profile.appearance.render_status, RenderStatus::Pending);
1449 }
1450}
1451
1452#[cfg(test)]
1453mod federation_tests {
1454 use super::*;
1455
1456 #[test]
1457 fn test_pattern_filter_default() {
1458 let f = PatternFilter::default();
1459 assert_eq!(f.max_count, 200);
1460 assert_eq!(f.importance_min, 0.0);
1461 assert!(f.tier.is_empty());
1462 }
1463
1464 #[test]
1465 fn test_federation_config_roundtrip() {
1466 let cfg = FederationConfig {
1467 filter: PatternFilter {
1468 tier: vec!["core".into()],
1469 max_count: 50,
1470 ..Default::default()
1471 },
1472 snapshot_ref: Some(SnapshotRef {
1473 knowledge_commit: "abc123def456".into(),
1474 taken_at: "2026-05-19T00:00:00Z".into(),
1475 filter: PatternFilter::default(),
1476 }),
1477 evidence_flush_interval_minutes: 15,
1478 };
1479 let yaml = serde_yaml_ng::to_string(&cfg).unwrap();
1480 let back: FederationConfig = serde_yaml_ng::from_str(&yaml).unwrap();
1481 assert_eq!(cfg, back);
1482 }
1483
1484 #[test]
1485 fn test_agent_profile_federation_defaults() {
1486 let cfg = FederationConfig::default();
1490 assert_eq!(cfg.evidence_flush_interval_minutes, 0);
1491 assert!(cfg.snapshot_ref.is_none());
1492 }
1493}