enact-config 0.0.2

Unified configuration management for Enact - secure storage with keychain and encrypted files
Documentation
//! Agent definitions — one YAML file per agent under ENACT_HOME/agents/<name>/agent.yaml

use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use std::path::{Path, PathBuf};

use crate::config::{ApprovalConfig, MemoryConfig, SessionConfigOverride};
use crate::home::enact_home;
use crate::hook_config::HookConfig;

/// Simple bot configuration for a channel.
/// Allows per-agent override of bot name and token environment variable.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
#[serde(deny_unknown_fields)]
pub struct ChannelBotConfig {
    /// Bot display name/username for this channel.
    #[serde(default)]
    pub bot_name: Option<String>,
    /// Environment variable name for the bot token.
    /// If not set, uses the default env var for this channel type.
    #[serde(default)]
    pub bot_token: Option<String>,
}

/// Per-agent definition (agents/<name>/agent.yaml).
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(deny_unknown_fields)]
pub struct AgentDef {
    pub name: String,
    #[serde(default)]
    pub description: Option<String>,
    #[serde(default = "default_version")]
    pub version: String,
    /// Overrides global provider deployment/model for this agent.
    #[serde(default)]
    pub model: Option<String>,
    #[serde(default)]
    pub system_prompt: Option<String>,
    #[serde(default)]
    pub tools: Vec<String>,
    /// Path to workflow YAML or null for LLM-only.
    #[serde(default)]
    pub workflow: Option<String>,
    /// Per-agent config overrides (merged over global config).
    #[serde(default)]
    pub approval: Option<ApprovalConfig>,
    #[serde(default)]
    pub memory: Option<MemoryConfig>,
    /// Channels this agent listens on (e.g., ["telegram", "whatsapp"]).
    /// Agents declare their channels; channels are transport-only.
    #[serde(default)]
    pub channels: Vec<String>,
    /// Telegram bot configuration for this agent (optional).
    /// If set, overrides global telegram settings from channels.yaml.
    #[serde(default)]
    pub telegram: Option<ChannelBotConfig>,
    /// WhatsApp bot configuration for this agent (optional).
    /// If set, overrides global whatsapp settings from channels.yaml.
    #[serde(default)]
    pub whatsapp: Option<ChannelBotConfig>,
    /// Teams bot configuration for this agent (optional).
    /// If set, overrides global teams settings from channels.yaml.
    #[serde(default)]
    pub teams: Option<ChannelBotConfig>,
    /// Per-agent session config overrides.
    #[serde(default)]
    pub session: Option<SessionConfigOverride>,
    /// Per-agent hook overrides (merged with global ~/.enact/hooks.yaml).
    #[serde(default)]
    pub hooks: Option<Vec<HookConfig>>,
}

fn default_version() -> String {
    "1.0.0".to_string()
}

impl Default for AgentDef {
    fn default() -> Self {
        Self {
            name: String::new(),
            description: None,
            version: default_version(),
            model: None,
            system_prompt: None,
            tools: Vec::new(),
            workflow: None,
            approval: None,
            memory: None,
            channels: Vec::new(),
            telegram: None,
            whatsapp: None,
            teams: None,
            session: None,
            hooks: None,
        }
    }
}

impl AgentDef {
    /// Path to this agent's directory under ENACT_HOME.
    pub fn agent_dir(home: &Path, name: &str) -> PathBuf {
        home.join("agents").join(name)
    }

    /// Path to agent.yaml for this agent.
    pub fn agent_yaml_path(home: &Path, name: &str) -> PathBuf {
        Self::agent_dir(home, name).join("agent.yaml")
    }

    /// Path to this agent's sessions directory.
    pub fn sessions_dir(home: &Path, name: &str) -> PathBuf {
        Self::agent_dir(home, name).join("sessions")
    }

    /// Path to this agent's memory directory.
    pub fn memory_dir(home: &Path, name: &str) -> PathBuf {
        Self::agent_dir(home, name).join("memory")
    }

    /// Path to agent's threads directory (conversation metadata).
    pub fn threads_dir(home: &Path, agent_name: &str) -> PathBuf {
        Self::agent_dir(home, agent_name).join("threads")
    }

    /// Load agent definition from ENACT_HOME/agents/<name>/agent.yaml.
    pub fn load(home: &Path, name: &str) -> Result<Option<Self>> {
        let path = Self::agent_yaml_path(home, name);
        if !path.exists() {
            return Ok(None);
        }
        let s = std::fs::read_to_string(&path).context("Failed to read agent.yaml")?;
        let def: AgentDef = serde_yaml::from_str(&s).context("Failed to parse agent.yaml")?;
        Ok(Some(def))
    }

    /// Save agent definition to ENACT_HOME/agents/<name>/agent.yaml.
    pub fn save(&self, home: &Path) -> Result<()> {
        let dir = Self::agent_dir(home, &self.name);
        std::fs::create_dir_all(&dir).context("Failed to create agent directory")?;
        let path = dir.join("agent.yaml");
        let s = serde_yaml::to_string(self).context("Failed to serialize agent to YAML")?;
        std::fs::write(&path, s).context("Failed to write agent.yaml")?;
        Ok(())
    }
}

/// Registry that discovers and loads agents from ENACT_HOME/agents/.
pub struct AgentRegistry;

impl AgentRegistry {
    /// List agent names (directory names under agents/ that contain agent.yaml).
    pub fn list(home: &Path) -> Result<Vec<String>> {
        let agents_dir = home.join("agents");
        if !agents_dir.exists() {
            return Ok(Vec::new());
        }
        let mut names = Vec::new();
        for e in std::fs::read_dir(agents_dir).context("Failed to read agents directory")? {
            let e = e?;
            let path = e.path();
            if path.is_dir() && path.join("agent.yaml").exists() {
                if let Some(name) = path.file_name().and_then(|n| n.to_str()) {
                    names.push(name.to_string());
                }
            }
        }
        names.sort();
        Ok(names)
    }

    /// Load a single agent by name.
    pub fn get(home: &Path, name: &str) -> Result<Option<AgentDef>> {
        AgentDef::load(home, name)
    }

    /// Load agent from default ENACT_HOME.
    pub fn get_default(name: &str) -> Result<Option<AgentDef>> {
        Self::get(&enact_home(), name)
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn agent_dir_path() {
        let home = Path::new("/tmp/.enact");
        assert_eq!(
            AgentDef::agent_yaml_path(home, "assistant"),
            PathBuf::from("/tmp/.enact/agents/assistant/agent.yaml")
        );
    }
}