use crate::companion::{Formality, Relationship};
use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
pub struct SkillCardEntry {
pub name: String,
#[serde(default, skip_serializing_if = "String::is_empty")]
pub version: String,
#[serde(default, skip_serializing_if = "String::is_empty")]
pub publisher: String,
#[serde(default, skip_serializing_if = "String::is_empty")]
pub description: String,
#[serde(default, skip_serializing_if = "String::is_empty")]
pub category: String,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub tags: Vec<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub triggers: Vec<SkillCardTrigger>,
#[serde(default, skip_serializing_if = "String::is_empty", rename = "abstract")]
pub abstract_text: String,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub transfer_chain: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
pub struct SkillCardTrigger {
#[serde(rename = "type")]
pub kind: String,
#[serde(default, skip_serializing_if = "String::is_empty")]
pub pattern: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct AgentProfile {
pub schema: u32,
pub id: String, pub name: String,
pub display_name: String,
pub version: String,
pub persona: Persona,
pub sys_prompt_file: String,
pub model: ModelConfig,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub model_ref: Option<String>,
#[serde(default)]
pub mcp_servers: Vec<McpServerEntry>,
#[serde(default)]
pub skills: Vec<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub installed_skills: Vec<SkillCardEntry>,
pub transport: TransportConfig,
pub communication: CommunicationConfig,
#[serde(default)]
pub capabilities: Vec<String>,
pub entitlements: Entitlements,
#[serde(default)]
pub notifications: NotificationsConfig,
pub retry: RetryConfig,
pub lifecycle: LifecycleConfig,
#[serde(default)]
pub identity: IdentityConfig,
#[serde(default)]
pub file_transfer: FileTransferConfig,
#[serde(default)]
pub deployment: DeploymentConfig,
#[serde(default)]
pub companion: CompanionConfig,
#[serde(default)]
pub voice: VoiceConfig,
#[serde(default)]
pub hooks: crate::HooksConfig,
#[serde(default)]
pub trusted_peers: Vec<crate::bridge::peer::TrustedPeer>,
pub created_at: String,
pub updated_at: String,
#[serde(default)]
pub appearance: AgentAppearance,
#[serde(default)]
pub federation: FederationConfig,
}
fn default_algorithm() -> String {
"ed25519".into()
}
pub const SUPPORTED_ALGORITHMS: &[&str] = &["ed25519"];
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct IdentityConfig {
#[serde(default)]
pub pubkey: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub owner: Option<String>,
#[serde(default = "default_algorithm")]
pub algorithm: String,
#[serde(default)]
pub key_version: u32,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub created_at_key: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub previous_pubkey: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub previous_key_version: Option<u32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub grace_expires_at: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub rotated_at: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub emergency_rekey_at: Option<String>,
}
impl Default for IdentityConfig {
fn default() -> Self {
Self {
pubkey: String::new(),
owner: None,
algorithm: default_algorithm(),
key_version: 0,
created_at_key: None,
previous_pubkey: None,
previous_key_version: None,
grace_expires_at: None,
rotated_at: None,
emergency_rekey_at: None,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct Persona {
pub category: PersonaCategory,
pub description: String,
pub traits: PersonaTraits,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
pub enum PersonaCategory {
Research,
Automation,
Monitor,
Notify,
Commerce,
Custom,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct PersonaTraits {
pub tone: String,
pub risk: String,
pub verbosity: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct ModelConfig {
pub provider: String,
pub name: String,
#[serde(default)]
pub params: BTreeMap<String, serde_yaml_ng::Value>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
pub struct McpServerEntry {
pub name: String,
pub command: String,
#[serde(default)]
pub args: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub binary_sha256: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub description_hash: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub publisher: Option<McpPublisherInfo>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub installed_at: Option<chrono::DateTime<chrono::Utc>>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
pub struct McpPublisherInfo {
pub name: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub homepage: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub registry_id: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct TransportConfig {
pub stdio: bool,
pub socket: SocketTransportConfig,
#[serde(default)]
pub tcp: TcpTransportConfig,
#[serde(default)]
pub webhook: WebhookTransportConfig,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
pub struct TcpTransportConfig {
#[serde(default)]
pub enabled: bool,
#[serde(default)]
pub bind: String,
#[serde(default)]
pub noise: NoiseConfig,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct WebhookTransportConfig {
#[serde(default)]
pub enabled: bool,
#[serde(default = "default_webhook_bind")]
pub bind: String,
#[serde(default = "default_webhook_port")]
pub port: u16,
#[serde(default)]
pub hmac_secret_ref: String,
}
fn default_webhook_bind() -> String {
"127.0.0.1".to_string()
}
fn default_webhook_port() -> u16 {
6789
}
impl Default for WebhookTransportConfig {
fn default() -> Self {
Self {
enabled: false,
bind: default_webhook_bind(),
port: default_webhook_port(),
hmac_secret_ref: String::new(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct NoiseConfig {
pub pattern: String,
}
impl Default for NoiseConfig {
fn default() -> Self {
Self {
pattern: "Noise_XK_25519_ChaChaPoly_BLAKE2s".into(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct SocketTransportConfig {
pub enabled: bool,
pub bind: String, #[serde(default, skip_serializing_if = "Option::is_none")]
pub auth: Option<AuthConfig>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct AuthConfig {
pub scheme: String,
pub token_file: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct CommunicationConfig {
#[serde(default = "default_accepts_all")]
pub accepts_from: Vec<String>,
#[serde(default)]
pub sends_to: Vec<String>,
}
fn default_accepts_all() -> Vec<String> {
vec!["*".to_string()]
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct Entitlements {
pub network: NetworkEntitlement,
pub filesystem: FilesystemEntitlement,
pub processes: ProcessesEntitlement,
#[serde(default)]
pub syscalls: SyscallsEntitlement,
#[serde(default)]
pub limits: LimitsEntitlement,
#[serde(default)]
pub llm: crate::bridge::llm_entitlement::LlmEntitlement,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct NetworkEntitlement {
pub inbound: InboundNetwork,
pub outbound: OutboundNetwork,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
pub struct InboundNetwork {
#[serde(default)]
pub ports: Vec<u16>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct OutboundNetwork {
pub mode: NetworkOutboundMode,
#[serde(default)]
pub allow_hosts: Vec<String>,
#[serde(default = "default_protocols")]
pub protocols: Vec<String>,
#[serde(default)]
pub resolve_dns: ResolveDnsConfig,
}
fn default_protocols() -> Vec<String> {
vec!["tcp".to_string()]
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
pub enum NetworkOutboundMode {
Unrestricted,
Restricted,
Off,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct ResolveDnsConfig {
#[serde(default = "default_dns_mode")]
pub mode: String,
#[serde(default)]
pub servers: Vec<String>,
}
impl Default for ResolveDnsConfig {
fn default() -> Self {
Self {
mode: default_dns_mode(),
servers: vec![],
}
}
}
fn default_dns_mode() -> String {
"system".to_string()
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
pub struct FilesystemEntitlement {
#[serde(default)]
pub read: Vec<String>,
#[serde(default)]
pub write: Vec<String>,
#[serde(default)]
pub deny: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct ProcessesEntitlement {
pub spawn: SpawnEntitlement,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct SpawnEntitlement {
pub mode: SpawnMode,
#[serde(default)]
pub allowed: Vec<String>,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
pub enum SpawnMode {
Allowlist,
Any,
None,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
pub struct SyscallsEntitlement {
#[serde(default = "default_syscalls_mode")]
pub mode: String,
#[serde(default)]
pub extra_deny: Vec<String>,
}
fn default_syscalls_mode() -> String {
"default".to_string()
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
pub struct LimitsEntitlement {
#[serde(default)]
pub cpu_seconds: Option<u64>,
#[serde(default = "default_memory_mb")]
pub memory_mb: u64,
#[serde(default = "default_fds")]
pub file_descriptors: u32,
#[serde(default = "default_procs")]
pub processes: u32,
}
fn default_memory_mb() -> u64 {
512
}
fn default_fds() -> u32 {
1024
}
fn default_procs() -> u32 {
32
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
pub struct NotificationsConfig {
#[serde(default)]
pub on_task_complete: Vec<NotificationTarget>,
#[serde(default)]
pub on_error: Vec<NotificationTarget>,
#[serde(default)]
pub on_shutdown: Vec<NotificationTarget>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(tag = "target", rename_all = "lowercase")]
pub enum NotificationTarget {
Agent {
name: String,
},
Commander,
Email {
address: String,
#[serde(default)]
smtp_config_file: Option<String>,
},
Slack {
#[serde(default)]
channel: Option<String>,
#[serde(default)]
webhook_url_env: Option<String>,
},
Webpush {
url: String,
},
Webhook {
url: String,
#[serde(default = "default_post")]
method: String,
#[serde(default)]
auth: Option<String>,
},
}
fn default_post() -> String {
"POST".to_string()
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct RetryConfig {
pub llm: RetryPolicy,
pub tool: RetryPolicy,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct RetryPolicy {
pub max_retries: u32,
pub backoff: BackoffStrategy,
pub initial_delay_ms: u64,
#[serde(default)]
pub max_delay_ms: Option<u64>,
#[serde(default)]
pub retry_on: Vec<String>,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
pub enum BackoffStrategy {
Linear,
Exponential,
Fixed,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct LifecycleConfig {
pub restart: RestartPolicy,
#[serde(default = "default_max_restarts")]
pub max_restarts: u32,
#[serde(default = "default_window")]
pub restart_window_secs: u64,
#[serde(default = "default_stop_timeout")]
pub stop_timeout_secs: u64,
#[serde(default = "default_mcp_required")]
pub mcp_required: bool,
#[serde(default)]
pub execution: ExecutionMode,
#[serde(default)]
pub schedule: Vec<ScheduleEntry>,
#[serde(default)]
pub idle_triggers: Vec<IdleTrigger>,
}
fn default_max_restarts() -> u32 {
3
}
fn default_window() -> u64 {
600
}
fn default_stop_timeout() -> u64 {
15
}
fn default_mcp_required() -> bool {
true
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum RestartPolicy {
Never,
OnFailure,
Always,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
#[serde(rename_all = "snake_case")]
pub enum ExecutionMode {
#[default]
Daemon,
OnDemand,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct ScheduleEntry {
pub cron: String,
pub message: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub sends_to: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct IdleTrigger {
pub after_secs: u64,
pub message: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub sends_to: Option<String>,
#[serde(default = "default_idle_cooldown")]
pub cooldown_secs: u64,
#[serde(default = "default_true")]
pub respect_quiet_hours: bool,
}
fn default_idle_cooldown() -> u64 {
600
}
fn default_true() -> bool {
true
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct FileTransferConfig {
#[serde(default = "default_accept_max")]
pub accept_incoming_file_max_bytes: u64,
#[serde(default = "default_accept_total")]
pub accept_incoming_total_per_hour: u64,
#[serde(default = "default_approval_threshold")]
pub require_approval_above_bytes: u64,
#[serde(default = "default_reject_paths")]
pub reject_paths: Vec<String>,
#[serde(default = "default_allowed_mime")]
pub allowed_mime_types: Vec<String>,
}
impl Default for FileTransferConfig {
fn default() -> Self {
Self {
accept_incoming_file_max_bytes: default_accept_max(),
accept_incoming_total_per_hour: default_accept_total(),
require_approval_above_bytes: default_approval_threshold(),
reject_paths: default_reject_paths(),
allowed_mime_types: default_allowed_mime(),
}
}
}
fn default_accept_max() -> u64 {
10_485_760
}
fn default_accept_total() -> u64 {
104_857_600
}
fn default_approval_threshold() -> u64 {
10_485_760
}
fn default_reject_paths() -> Vec<String> {
vec!["~/.ssh".into(), "~/.aws".into(), "~/.gnupg".into()]
}
fn default_allowed_mime() -> Vec<String> {
vec!["*".into()]
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
#[serde(rename_all = "snake_case")]
pub enum DeploymentType {
#[default]
Laptop,
Vm,
Docker,
K8s,
Lambda,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct DeploymentConfig {
#[serde(rename = "type", default)]
pub deployment_type: DeploymentType,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub region: Option<String>,
#[serde(default = "default_env")]
pub environment: Option<String>,
}
impl Default for DeploymentConfig {
fn default() -> Self {
Self {
deployment_type: DeploymentType::default(),
region: None,
environment: default_env(),
}
}
}
fn default_env() -> Option<String> {
Some("dev".into())
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct LockFile {
pub schema: u32,
pub uuid: String,
pub name: String,
pub pid: u32,
pub ppid: u32,
pub started_at: String,
pub binary_version: String,
pub transports: LockTransports,
pub card_digest: String,
pub capabilities: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct LockTransports {
pub stdio: bool,
#[serde(default)]
pub unix_socket: Option<String>,
#[serde(default)]
pub tcp: Option<String>,
#[serde(default)]
pub webhook: Option<String>,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
#[serde(rename_all = "snake_case")]
pub enum VoiceId {
#[default]
AfHeart,
AfBella,
AfNicole,
AmAdam,
AmMichael,
}
impl VoiceId {
pub fn style_index(&self) -> usize {
match self {
VoiceId::AfHeart => 0,
VoiceId::AfBella => 1,
VoiceId::AfNicole => 2,
VoiceId::AmAdam => 3,
VoiceId::AmMichael => 4,
}
}
pub fn as_str(&self) -> &'static str {
match self {
VoiceId::AfHeart => "af_heart",
VoiceId::AfBella => "af_bella",
VoiceId::AfNicole => "af_nicole",
VoiceId::AmAdam => "am_adam",
VoiceId::AmMichael => "am_michael",
}
}
}
impl std::str::FromStr for VoiceId {
type Err = anyhow::Error;
fn from_str(s: &str) -> anyhow::Result<Self> {
match s {
"af_heart" => Ok(VoiceId::AfHeart),
"af_bella" => Ok(VoiceId::AfBella),
"af_nicole" => Ok(VoiceId::AfNicole),
"am_adam" => Ok(VoiceId::AmAdam),
"am_michael" => Ok(VoiceId::AmMichael),
other => anyhow::bail!(
"unknown voice ID '{other}' \
(valid: af_heart, af_bella, af_nicole, am_adam, am_michael)"
),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
pub struct VoiceConfig {
#[serde(default)]
pub enabled: bool,
#[serde(default)]
pub voice_id: VoiceId,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub input_device: Option<String>,
}
#[derive(Debug, Default, Clone, PartialEq, Serialize, Deserialize)]
pub struct CompanionConfig {
#[serde(default)]
pub enabled: bool,
#[serde(default = "default_locale")]
pub locale: String,
#[serde(default)]
pub relationship: Relationship,
#[serde(default)]
pub voice_overrides: VoiceOverrides,
#[serde(default)]
pub onboarding: OnboardingState,
#[serde(default)]
pub rhythm: RhythmConfig,
#[serde(default)]
pub proactive: ProactiveConfig,
}
pub fn default_locale() -> String {
std::env::var("LANG")
.ok()
.and_then(|v| v.split('.').next().map(|s| s.replace('_', "-")))
.unwrap_or_else(|| "en-US".into())
}
#[derive(Debug, Default, Clone, PartialEq, Serialize, Deserialize)]
pub struct VoiceOverrides {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub name_for_user: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub formality: Option<Formality>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub extra_instructions: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct FirstMemory {
pub text: String,
pub established_at: chrono::DateTime<chrono::Utc>,
}
#[derive(Debug, Default, Clone, PartialEq, Serialize, Deserialize)]
pub struct OnboardingState {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub completed_at: Option<chrono::DateTime<chrono::Utc>>,
#[serde(default)]
pub version: u32,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub agent_display_name: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub first_memory: Option<FirstMemory>,
}
#[derive(Debug, Default, Clone, PartialEq, Serialize, Deserialize)]
pub struct RhythmConfig {
#[serde(default)]
pub enabled: bool,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct ProactiveConfig {
#[serde(default)]
pub enabled: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub learning_until: Option<chrono::DateTime<chrono::Utc>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub quiet_hours: Option<QuietHours>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub active_hours: Option<ActiveHours>,
#[serde(default = "default_daily_cap")]
pub daily_cap: u8,
#[serde(default = "default_channels")]
pub channels: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub paused_until: Option<chrono::DateTime<chrono::Utc>>,
}
impl Default for ProactiveConfig {
fn default() -> Self {
Self {
enabled: false,
learning_until: None,
quiet_hours: None,
active_hours: None,
daily_cap: default_daily_cap(),
channels: default_channels(),
paused_until: None,
}
}
}
fn default_daily_cap() -> u8 {
3
}
fn default_channels() -> Vec<String> {
vec!["stdout".into()]
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct QuietHours {
pub start: String,
pub end: String,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct ActiveHours {
pub start: String,
pub end: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct AgentAppearance {
#[serde(default = "default_style_preset")]
pub style_preset: String,
#[serde(default)]
pub behavior_preset: BehaviorPreset,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub source_image_path: Option<std::path::PathBuf>,
#[serde(default = "default_expressions_dir")]
pub expressions_dir: std::path::PathBuf,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub last_rendered_at: Option<chrono::DateTime<chrono::Utc>>,
#[serde(default)]
pub render_status: RenderStatus,
}
fn default_style_preset() -> String {
"default-blob".into()
}
fn default_expressions_dir() -> std::path::PathBuf {
std::path::PathBuf::from("expressions")
}
impl Default for AgentAppearance {
fn default() -> Self {
Self {
style_preset: default_style_preset(),
behavior_preset: BehaviorPreset::Normal,
source_image_path: None,
expressions_dir: default_expressions_dir(),
last_rendered_at: None,
render_status: RenderStatus::Pending,
}
}
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
#[serde(rename_all = "snake_case")]
pub enum BehaviorPreset {
Quiet,
#[default]
Normal,
Lively,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
#[serde(tag = "status", rename_all = "snake_case")]
pub enum RenderStatus {
#[default]
Pending,
Rendering {
done: u8,
total: u8,
},
Ready,
Failed {
reason: String,
},
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
#[serde(rename_all = "kebab-case")]
pub enum SnapshotPolicy {
#[default]
PullOnStart,
PullPeriodic,
Manual,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct PatternFilter {
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub applies_in: Vec<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub tier: Vec<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub maturity: Vec<String>,
#[serde(default)]
pub importance_min: f64,
#[serde(default = "default_max_snapshot_count")]
pub max_count: usize,
#[serde(default)]
pub snapshot_policy: SnapshotPolicy,
}
fn default_max_snapshot_count() -> usize {
200
}
impl Default for PatternFilter {
fn default() -> Self {
Self {
applies_in: vec![],
tier: vec![],
maturity: vec![],
importance_min: 0.0,
max_count: 200,
snapshot_policy: SnapshotPolicy::default(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct SnapshotRef {
pub knowledge_commit: String,
pub taken_at: String,
pub filter: PatternFilter,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
pub struct FederationConfig {
#[serde(default)]
pub filter: PatternFilter,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub snapshot_ref: Option<SnapshotRef>,
#[serde(default)]
pub evidence_flush_interval_minutes: u32,
}
impl AgentProfile {
#[doc(hidden)]
pub fn default_for_tests() -> Self {
serde_yaml_ng::from_str(include_str!("../tests/fixtures/minimal_profile.yaml"))
.expect("minimal profile fixture")
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn profile_round_trip_yaml() {
let yaml = r#"
schema: 1
id: 01JQX4TM8Y9K7VQH6B2N3R5DPE
name: agent_a
display_name: "Price Hunter"
version: "0.1.0"
persona:
category: research
description: "Finds prices"
traits: { tone: concise, risk: cautious, verbosity: low }
sys_prompt_file: "sys_prompt.md"
model: { provider: ollama, name: "llama3.2:3b", params: { temperature: 0.2, max_tokens: 4096 } }
mcp_servers: []
skills: []
transport:
stdio: true
socket: { enabled: true, bind: "unix:///tmp/a.sock" }
communication: { accepts_from: ["*"], sends_to: [] }
capabilities: ["a2a.message.send", "a2a.tasks"]
entitlements:
network:
inbound: { ports: [] }
outbound: { mode: restricted, allow_hosts: [], protocols: ["tcp"], resolve_dns: { mode: system } }
filesystem: { read: [], write: [], deny: [] }
processes: { spawn: { mode: allowlist, allowed: [] } }
syscalls: { mode: default }
limits: { memory_mb: 512, file_descriptors: 1024, processes: 32 }
notifications: { on_task_complete: [], on_error: [], on_shutdown: [] }
retry:
llm: { max_retries: 3, backoff: exponential, initial_delay_ms: 1000, max_delay_ms: 30000, retry_on: [rate_limit, timeout, connection_error] }
tool: { max_retries: 1, backoff: fixed, initial_delay_ms: 500 }
lifecycle: { restart: on_failure, max_restarts: 3, restart_window_secs: 600, stop_timeout_secs: 15, mcp_required: true }
created_at: "2026-04-22T10:00:00+08:00"
updated_at: "2026-04-22T10:00:00+08:00"
"#;
let profile: AgentProfile = serde_yaml_ng::from_str(yaml).expect("parse");
assert_eq!(profile.name, "agent_a");
assert_eq!(profile.persona.category, PersonaCategory::Research);
assert_eq!(
profile.entitlements.network.outbound.mode,
NetworkOutboundMode::Restricted
);
let reserialized = serde_yaml_ng::to_string(&profile).expect("emit");
let round_tripped: AgentProfile = serde_yaml_ng::from_str(&reserialized).expect("re-parse");
assert_eq!(profile.id, round_tripped.id);
}
}
#[cfg(test)]
mod model_ref_tests {
use super::*;
#[test]
fn legacy_profile_without_model_ref_still_parses() {
let yaml = include_str!("../tests/fixtures/profile_p0a_minimal.yaml");
let p: AgentProfile = serde_yaml_ng::from_str(yaml).unwrap();
assert!(
p.model_ref.is_none(),
"legacy profile must not have model_ref"
);
}
#[test]
fn round_trip_with_model_ref_preserves_field() {
let yaml = include_str!("../tests/fixtures/profile_p0a_minimal.yaml");
let mut p: AgentProfile = serde_yaml_ng::from_str(yaml).unwrap();
p.model_ref = Some("anthropic_opus_4_7".into());
let s = serde_yaml_ng::to_string(&p).unwrap();
assert!(s.contains("model_ref: anthropic_opus_4_7"), "yaml: {s}");
let p2: AgentProfile = serde_yaml_ng::from_str(&s).unwrap();
assert_eq!(p2.model_ref.as_deref(), Some("anthropic_opus_4_7"));
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ProactiveTier {
Off,
WarmOnly,
WarmAndBehavior,
All,
}
impl ProactiveTier {
pub fn from_config(c: &CompanionConfig) -> Self {
match (c.enabled, c.rhythm.enabled, c.proactive.enabled) {
(false, _, _) => Self::Off,
(true, false, false) => Self::WarmOnly,
(true, true, false) => Self::WarmAndBehavior,
(true, _, true) => Self::All,
}
}
pub fn apply(&self, c: &mut CompanionConfig) {
match self {
Self::Off => {
c.enabled = false;
c.rhythm.enabled = false;
c.proactive.enabled = false;
}
Self::WarmOnly => {
c.enabled = true;
c.rhythm.enabled = false;
c.proactive.enabled = false;
}
Self::WarmAndBehavior => {
c.enabled = true;
c.rhythm.enabled = true;
c.proactive.enabled = false;
}
Self::All => {
c.enabled = true;
c.rhythm.enabled = true;
c.proactive.enabled = true;
}
}
}
}
#[cfg(test)]
mod mcp_pin_tests {
use super::*;
#[test]
fn pre_m9_entry_roundtrips_without_pin_fields() {
let yaml = r#"
name: weather
command: /opt/mcp/weather
args: ["--port", "0"]
"#;
let entry: McpServerEntry = serde_yaml_ng::from_str(yaml).unwrap();
assert_eq!(entry.name, "weather");
assert_eq!(entry.binary_sha256, None);
assert_eq!(entry.description_hash, None);
assert_eq!(entry.publisher, None);
assert_eq!(entry.installed_at, None);
let out = serde_yaml_ng::to_string(&entry).unwrap();
assert!(!out.contains("binary_sha256"), "got {out}");
assert!(!out.contains("description_hash"), "got {out}");
assert!(!out.contains("publisher"), "got {out}");
assert!(!out.contains("installed_at"), "got {out}");
}
#[test]
fn full_m9_entry_roundtrips_all_fields() {
let yaml = r#"
name: weather
command: /opt/mcp/weather
args: []
binary_sha256: "3f4abca8b0e6e2c1d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b81c"
description_hash: "9a01b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9c7e2"
publisher:
name: "@anthropic-mcp/weather"
homepage: "https://github.com/anthropic-mcp/weather"
registry_id: "@anthropic-mcp/weather@1.2.3"
installed_at: "2026-05-06T08:00:00Z"
"#;
let entry: McpServerEntry = serde_yaml_ng::from_str(yaml).unwrap();
assert!(
entry
.binary_sha256
.as_deref()
.unwrap()
.starts_with("3f4abca8")
);
assert!(
entry
.description_hash
.as_deref()
.unwrap()
.starts_with("9a01b2c3")
);
let pub_info = entry.publisher.clone().unwrap();
assert_eq!(pub_info.name, "@anthropic-mcp/weather");
assert_eq!(
pub_info.homepage.as_deref(),
Some("https://github.com/anthropic-mcp/weather"),
);
assert_eq!(
pub_info.registry_id.as_deref(),
Some("@anthropic-mcp/weather@1.2.3"),
);
let installed = entry.installed_at.unwrap();
assert_eq!(installed.to_rfc3339(), "2026-05-06T08:00:00+00:00");
}
#[test]
fn partial_pin_only_binary_sha_roundtrips() {
let yaml = r#"
name: weather
command: /opt/mcp/weather
args: []
binary_sha256: "deadbeef00112233445566778899aabbccddeeff00112233445566778899aabb"
"#;
let entry: McpServerEntry = serde_yaml_ng::from_str(yaml).unwrap();
assert_eq!(
entry.binary_sha256.as_deref(),
Some("deadbeef00112233445566778899aabbccddeeff00112233445566778899aabb"),
);
assert_eq!(entry.description_hash, None);
assert_eq!(entry.publisher, None);
}
#[test]
fn publisher_minimal_just_name() {
let yaml = r#"
name: weather
command: /opt/mcp/weather
args: []
publisher:
name: "alice"
"#;
let entry: McpServerEntry = serde_yaml_ng::from_str(yaml).unwrap();
let p = entry.publisher.as_ref().unwrap();
assert_eq!(p.name, "alice");
assert_eq!(p.homepage, None);
assert_eq!(p.registry_id, None);
let out = serde_yaml_ng::to_string(&entry).unwrap();
assert!(!out.contains("homepage:"), "got {out}");
assert!(!out.contains("registry_id:"), "got {out}");
}
}
#[cfg(test)]
mod voice_tests {
use super::*;
use std::str::FromStr;
#[test]
fn voice_config_round_trips() {
let base = include_str!("../tests/fixtures/profile_p0a_minimal.yaml");
let yaml = format!("{base}voice:\n enabled: true\n voice_id: af_bella\n");
let profile: AgentProfile = serde_yaml_ng::from_str(&yaml).expect("parse with voice");
assert!(profile.voice.enabled);
assert_eq!(profile.voice.voice_id, VoiceId::AfBella);
let legacy: AgentProfile = serde_yaml_ng::from_str(base).expect("parse without voice");
assert!(!legacy.voice.enabled);
assert_eq!(legacy.voice.voice_id, VoiceId::AfHeart);
}
#[test]
fn voice_id_from_str_roundtrips() {
let cases = [
("af_heart", VoiceId::AfHeart),
("af_bella", VoiceId::AfBella),
("af_nicole", VoiceId::AfNicole),
("am_adam", VoiceId::AmAdam),
("am_michael", VoiceId::AmMichael),
];
for (s, expected) in cases {
assert_eq!(VoiceId::from_str(s).unwrap(), expected);
assert_eq!(expected.as_str(), s);
}
}
#[test]
fn voice_id_from_str_rejects_unknown() {
assert!(VoiceId::from_str("bogus").is_err());
}
}
#[cfg(test)]
mod idle_trigger_tests {
use super::*;
#[test]
fn idle_trigger_yaml_round_trip() {
let yaml = r#"
restart: on_failure
idle_triggers:
- after_secs: 3600
message: "still there?"
sends_to: other_agent
cooldown_secs: 1800
respect_quiet_hours: true
"#;
let cfg: LifecycleConfig = serde_yaml_ng::from_str(yaml).unwrap();
assert_eq!(cfg.idle_triggers.len(), 1);
assert_eq!(cfg.idle_triggers[0].after_secs, 3600);
assert_eq!(cfg.idle_triggers[0].message, "still there?");
assert_eq!(
cfg.idle_triggers[0].sends_to.as_deref(),
Some("other_agent")
);
assert_eq!(cfg.idle_triggers[0].cooldown_secs, 1800);
assert!(cfg.idle_triggers[0].respect_quiet_hours);
}
#[test]
fn idle_trigger_defaults_when_omitted() {
let yaml = "restart: on_failure\n";
let cfg: LifecycleConfig = serde_yaml_ng::from_str(yaml).unwrap();
assert!(cfg.idle_triggers.is_empty());
}
}
#[cfg(test)]
mod appearance_tests {
use super::*;
#[test]
fn appearance_default_style_preset_is_default_blob() {
assert_eq!(AgentAppearance::default().style_preset, "default-blob");
}
#[test]
fn appearance_default_behavior_is_normal() {
assert_eq!(
AgentAppearance::default().behavior_preset,
BehaviorPreset::Normal
);
}
#[test]
fn appearance_default_render_status_is_pending() {
assert_eq!(
AgentAppearance::default().render_status,
RenderStatus::Pending
);
}
#[test]
fn render_status_serde_round_trip() {
let cases = [
RenderStatus::Pending,
RenderStatus::Rendering { done: 3, total: 12 },
RenderStatus::Ready,
RenderStatus::Failed {
reason: "out of quota".into(),
},
];
for status in cases {
let yaml = serde_yaml_ng::to_string(&status).expect("serialize");
let back: RenderStatus = serde_yaml_ng::from_str(&yaml).expect("deserialize");
assert_eq!(status, back);
}
}
#[test]
fn agent_profile_with_appearance_round_trips() {
let base = include_str!("../tests/fixtures/profile_p0a_minimal.yaml");
let yaml = format!(
"{base}appearance:\n style_preset: chiikawa\n render_status:\n status: ready\n"
);
let profile: AgentProfile = serde_yaml_ng::from_str(&yaml).expect("parse with appearance");
assert_eq!(profile.appearance.style_preset, "chiikawa");
assert_eq!(profile.appearance.render_status, RenderStatus::Ready);
let out = serde_yaml_ng::to_string(&profile).expect("serialize");
let back: AgentProfile = serde_yaml_ng::from_str(&out).expect("re-parse");
assert_eq!(profile.appearance, back.appearance);
}
#[test]
fn legacy_profile_without_appearance_uses_default() {
let yaml = include_str!("../tests/fixtures/profile_p0a_minimal.yaml");
let profile: AgentProfile = serde_yaml_ng::from_str(yaml).expect("parse legacy");
assert_eq!(profile.appearance.style_preset, "default-blob");
assert_eq!(profile.appearance.behavior_preset, BehaviorPreset::Normal);
assert_eq!(profile.appearance.render_status, RenderStatus::Pending);
}
}
#[cfg(test)]
mod federation_tests {
use super::*;
#[test]
fn test_pattern_filter_default() {
let f = PatternFilter::default();
assert_eq!(f.max_count, 200);
assert_eq!(f.importance_min, 0.0);
assert!(f.tier.is_empty());
}
#[test]
fn test_federation_config_roundtrip() {
let cfg = FederationConfig {
filter: PatternFilter {
tier: vec!["core".into()],
max_count: 50,
..Default::default()
},
snapshot_ref: Some(SnapshotRef {
knowledge_commit: "abc123def456".into(),
taken_at: "2026-05-19T00:00:00Z".into(),
filter: PatternFilter::default(),
}),
evidence_flush_interval_minutes: 15,
};
let yaml = serde_yaml_ng::to_string(&cfg).unwrap();
let back: FederationConfig = serde_yaml_ng::from_str(&yaml).unwrap();
assert_eq!(cfg, back);
}
#[test]
fn test_agent_profile_federation_defaults() {
let cfg = FederationConfig::default();
assert_eq!(cfg.evidence_flush_interval_minutes, 0);
assert!(cfg.snapshot_ref.is_none());
}
}
#[cfg(test)]
mod skill_card_tests {
use super::*;
#[test]
fn installed_skills_default_to_empty_when_absent() {
let yaml = include_str!("../tests/fixtures/profile_p0a_minimal.yaml");
let p: AgentProfile = serde_yaml_ng::from_str(yaml).unwrap();
assert!(p.installed_skills.is_empty());
}
#[test]
fn installed_skills_roundtrip_preserves_entries() {
let base = include_str!("../tests/fixtures/profile_p0a_minimal.yaml");
let yaml = format!(
"{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"
);
let p: AgentProfile = serde_yaml_ng::from_str(&yaml).unwrap();
assert_eq!(p.installed_skills.len(), 1);
assert_eq!(p.installed_skills[0].name, "s1");
assert_eq!(p.installed_skills[0].abstract_text, "does things");
assert_eq!(p.installed_skills[0].transfer_chain, vec!["agent://alice"]);
let out = serde_yaml_ng::to_string(&p).unwrap();
assert!(out.contains("abstract: does things"));
assert!(out.contains("pattern: /find"));
let back: AgentProfile = serde_yaml_ng::from_str(&out).unwrap();
assert_eq!(p.installed_skills, back.installed_skills);
}
#[test]
fn installed_skills_minimal_entry_serializes_compactly() {
let entry = SkillCardEntry {
name: "minimal".into(),
..Default::default()
};
let yaml = serde_yaml_ng::to_string(&entry).unwrap();
assert!(yaml.contains("name: minimal"));
assert!(
!yaml.contains("version:"),
"empty version must be skipped: {yaml}"
);
assert!(
!yaml.contains("publisher:"),
"empty publisher must be skipped: {yaml}"
);
assert!(
!yaml.contains("abstract:"),
"empty abstract must be skipped: {yaml}"
);
}
}