Skip to main content

enact_config/
agent_def.rs

1//! Agent definitions — one YAML file per agent under ENACT_HOME/agents/<name>/agent.yaml
2
3use anyhow::{Context, Result};
4use serde::{Deserialize, Serialize};
5use std::path::{Path, PathBuf};
6
7use crate::config::{ApprovalConfig, MemoryConfig, SessionConfigOverride};
8use crate::home::enact_home;
9use crate::hook_config::HookConfig;
10
11/// Simple bot configuration for a channel.
12/// Allows per-agent override of bot name and token environment variable.
13#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
14#[serde(deny_unknown_fields)]
15pub struct ChannelBotConfig {
16    /// Bot display name/username for this channel.
17    #[serde(default)]
18    pub bot_name: Option<String>,
19    /// Environment variable name for the bot token.
20    /// If not set, uses the default env var for this channel type.
21    #[serde(default)]
22    pub bot_token: Option<String>,
23}
24
25/// Per-agent definition (agents/<name>/agent.yaml).
26#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
27#[serde(deny_unknown_fields)]
28pub struct AgentDef {
29    pub name: String,
30    #[serde(default)]
31    pub description: Option<String>,
32    #[serde(default = "default_version")]
33    pub version: String,
34    /// Overrides global provider deployment/model for this agent.
35    #[serde(default)]
36    pub model: Option<String>,
37    #[serde(default)]
38    pub system_prompt: Option<String>,
39    #[serde(default)]
40    pub tools: Vec<String>,
41    /// Path to workflow YAML or null for LLM-only.
42    #[serde(default)]
43    pub workflow: Option<String>,
44    /// Per-agent config overrides (merged over global config).
45    #[serde(default)]
46    pub approval: Option<ApprovalConfig>,
47    #[serde(default)]
48    pub memory: Option<MemoryConfig>,
49    /// Channels this agent listens on (e.g., ["telegram", "whatsapp"]).
50    /// Agents declare their channels; channels are transport-only.
51    #[serde(default)]
52    pub channels: Vec<String>,
53    /// Telegram bot configuration for this agent (optional).
54    /// If set, overrides global telegram settings from channels.yaml.
55    #[serde(default)]
56    pub telegram: Option<ChannelBotConfig>,
57    /// WhatsApp bot configuration for this agent (optional).
58    /// If set, overrides global whatsapp settings from channels.yaml.
59    #[serde(default)]
60    pub whatsapp: Option<ChannelBotConfig>,
61    /// Teams bot configuration for this agent (optional).
62    /// If set, overrides global teams settings from channels.yaml.
63    #[serde(default)]
64    pub teams: Option<ChannelBotConfig>,
65    /// Per-agent session config overrides.
66    #[serde(default)]
67    pub session: Option<SessionConfigOverride>,
68    /// Per-agent hook overrides (merged with global ~/.enact/hooks.yaml).
69    #[serde(default)]
70    pub hooks: Option<Vec<HookConfig>>,
71}
72
73fn default_version() -> String {
74    "1.0.0".to_string()
75}
76
77impl Default for AgentDef {
78    fn default() -> Self {
79        Self {
80            name: String::new(),
81            description: None,
82            version: default_version(),
83            model: None,
84            system_prompt: None,
85            tools: Vec::new(),
86            workflow: None,
87            approval: None,
88            memory: None,
89            channels: Vec::new(),
90            telegram: None,
91            whatsapp: None,
92            teams: None,
93            session: None,
94            hooks: None,
95        }
96    }
97}
98
99impl AgentDef {
100    /// Path to this agent's directory under ENACT_HOME.
101    pub fn agent_dir(home: &Path, name: &str) -> PathBuf {
102        home.join("agents").join(name)
103    }
104
105    /// Path to agent.yaml for this agent.
106    pub fn agent_yaml_path(home: &Path, name: &str) -> PathBuf {
107        Self::agent_dir(home, name).join("agent.yaml")
108    }
109
110    /// Path to this agent's sessions directory.
111    pub fn sessions_dir(home: &Path, name: &str) -> PathBuf {
112        Self::agent_dir(home, name).join("sessions")
113    }
114
115    /// Path to this agent's memory directory.
116    pub fn memory_dir(home: &Path, name: &str) -> PathBuf {
117        Self::agent_dir(home, name).join("memory")
118    }
119
120    /// Path to agent's threads directory (conversation metadata).
121    pub fn threads_dir(home: &Path, agent_name: &str) -> PathBuf {
122        Self::agent_dir(home, agent_name).join("threads")
123    }
124
125    /// Load agent definition from ENACT_HOME/agents/<name>/agent.yaml.
126    pub fn load(home: &Path, name: &str) -> Result<Option<Self>> {
127        let path = Self::agent_yaml_path(home, name);
128        if !path.exists() {
129            return Ok(None);
130        }
131        let s = std::fs::read_to_string(&path).context("Failed to read agent.yaml")?;
132        let def: AgentDef = serde_yaml::from_str(&s).context("Failed to parse agent.yaml")?;
133        Ok(Some(def))
134    }
135
136    /// Save agent definition to ENACT_HOME/agents/<name>/agent.yaml.
137    pub fn save(&self, home: &Path) -> Result<()> {
138        let dir = Self::agent_dir(home, &self.name);
139        std::fs::create_dir_all(&dir).context("Failed to create agent directory")?;
140        let path = dir.join("agent.yaml");
141        let s = serde_yaml::to_string(self).context("Failed to serialize agent to YAML")?;
142        std::fs::write(&path, s).context("Failed to write agent.yaml")?;
143        Ok(())
144    }
145}
146
147/// Registry that discovers and loads agents from ENACT_HOME/agents/.
148pub struct AgentRegistry;
149
150impl AgentRegistry {
151    /// List agent names (directory names under agents/ that contain agent.yaml).
152    pub fn list(home: &Path) -> Result<Vec<String>> {
153        let agents_dir = home.join("agents");
154        if !agents_dir.exists() {
155            return Ok(Vec::new());
156        }
157        let mut names = Vec::new();
158        for e in std::fs::read_dir(agents_dir).context("Failed to read agents directory")? {
159            let e = e?;
160            let path = e.path();
161            if path.is_dir() && path.join("agent.yaml").exists() {
162                if let Some(name) = path.file_name().and_then(|n| n.to_str()) {
163                    names.push(name.to_string());
164                }
165            }
166        }
167        names.sort();
168        Ok(names)
169    }
170
171    /// Load a single agent by name.
172    pub fn get(home: &Path, name: &str) -> Result<Option<AgentDef>> {
173        AgentDef::load(home, name)
174    }
175
176    /// Load agent from default ENACT_HOME.
177    pub fn get_default(name: &str) -> Result<Option<AgentDef>> {
178        Self::get(&enact_home(), name)
179    }
180}
181
182#[cfg(test)]
183mod tests {
184    use super::*;
185
186    #[test]
187    fn agent_dir_path() {
188        let home = Path::new("/tmp/.enact");
189        assert_eq!(
190            AgentDef::agent_yaml_path(home, "assistant"),
191            PathBuf::from("/tmp/.enact/agents/assistant/agent.yaml")
192        );
193    }
194}