scud/agents/
mod.rs

1//! Agent definitions for model routing
2//!
3//! Agent definitions specify which harness and model to use for a task,
4//! along with an optional custom prompt template.
5
6use anyhow::{Context, Result};
7use serde::Deserialize;
8use std::path::Path;
9
10use crate::commands::spawn::terminal::Harness;
11
12/// Agent definition loaded from .scud/agents/<name>.toml
13#[derive(Debug, Clone, Deserialize)]
14pub struct AgentDef {
15    pub agent: AgentMeta,
16    pub model: ModelConfig,
17    #[serde(default)]
18    pub prompt: PromptConfig,
19}
20
21#[derive(Debug, Clone, Deserialize)]
22pub struct AgentMeta {
23    pub name: String,
24    #[serde(default)]
25    pub description: String,
26}
27
28#[derive(Debug, Clone, Deserialize)]
29pub struct ModelConfig {
30    /// Harness to use: "claude" or "opencode"
31    #[serde(default = "default_harness")]
32    pub harness: String,
33    /// Model name to pass to CLI (e.g., "sonnet", "opus", "grok-4")
34    #[serde(default)]
35    pub model: Option<String>,
36}
37
38fn default_harness() -> String {
39    "opencode".to_string()
40}
41
42#[derive(Debug, Clone, Deserialize, Default)]
43pub struct PromptConfig {
44    /// Inline prompt template (supports {task.title}, {task.description}, etc.)
45    pub template: Option<String>,
46    /// Path to prompt template file (relative to .scud/agents/)
47    pub template_file: Option<String>,
48}
49
50impl AgentDef {
51    /// Load agent definition from .scud/agents/<name>.toml
52    pub fn load(name: &str, project_root: &Path) -> Result<Self> {
53        let path = project_root
54            .join(".scud")
55            .join("agents")
56            .join(format!("{}.toml", name));
57
58        if !path.exists() {
59            anyhow::bail!(
60                "Agent definition '{}' not found at {}",
61                name,
62                path.display()
63            );
64        }
65
66        let content = std::fs::read_to_string(&path)
67            .with_context(|| format!("Failed to read agent file: {}", path.display()))?;
68
69        toml::from_str(&content)
70            .with_context(|| format!("Failed to parse agent file: {}", path.display()))
71    }
72
73    /// Try to load agent definition, return None if not found
74    pub fn try_load(name: &str, project_root: &Path) -> Option<Self> {
75        Self::load(name, project_root).ok()
76    }
77
78    /// Get the harness for this agent
79    pub fn harness(&self) -> Result<Harness> {
80        Harness::parse(&self.model.harness)
81    }
82
83    /// Get the model name for this agent (if specified)
84    pub fn model(&self) -> Option<&str> {
85        self.model.model.as_deref()
86    }
87
88    /// Get the prompt template (if specified)
89    pub fn prompt_template(&self, project_root: &Path) -> Option<String> {
90        // Try inline template first
91        if let Some(ref template) = self.prompt.template {
92            return Some(template.clone());
93        }
94
95        // Try template file
96        if let Some(ref template_file) = self.prompt.template_file {
97            let path = project_root
98                .join(".scud")
99                .join("agents")
100                .join(template_file);
101            if let Ok(content) = std::fs::read_to_string(&path) {
102                return Some(content);
103            }
104        }
105
106        None
107    }
108
109    /// Create a default agent (OpenCode with xai/grok-code-fast-1, no custom prompt)
110    pub fn default_builder() -> Self {
111        AgentDef {
112            agent: AgentMeta {
113                name: "builder".to_string(),
114                description: "Default code implementation agent".to_string(),
115            },
116            model: ModelConfig {
117                harness: "opencode".to_string(),
118                model: Some("xai/grok-code-fast-1".to_string()),
119            },
120            prompt: PromptConfig::default(),
121        }
122    }
123}
124
125#[cfg(test)]
126mod tests {
127    use super::*;
128    use tempfile::TempDir;
129
130    #[test]
131    fn test_load_agent_definition() {
132        let temp = TempDir::new().unwrap();
133        let agents_dir = temp.path().join(".scud").join("agents");
134        std::fs::create_dir_all(&agents_dir).unwrap();
135
136        let agent_file = agents_dir.join("reviewer.toml");
137        let content = r#"
138[agent]
139name = "reviewer"
140description = "Code review agent"
141
142[model]
143harness = "claude"
144model = "opus"
145"#;
146        std::fs::write(&agent_file, content).unwrap();
147
148        let agent = AgentDef::load("reviewer", temp.path()).unwrap();
149        assert_eq!(agent.agent.name, "reviewer");
150        assert_eq!(agent.model.harness, "claude");
151        assert_eq!(agent.model.model, Some("opus".to_string()));
152    }
153
154    #[test]
155    fn test_agent_not_found() {
156        let temp = TempDir::new().unwrap();
157        let result = AgentDef::load("nonexistent", temp.path());
158        assert!(result.is_err());
159    }
160
161    #[test]
162    fn test_default_builder() {
163        let agent = AgentDef::default_builder();
164        assert_eq!(agent.agent.name, "builder");
165        assert_eq!(agent.model.harness, "opencode");
166        assert_eq!(agent.model.model, Some("xai/grok-code-fast-1".to_string()));
167    }
168
169    #[test]
170    fn test_try_load_returns_none_for_missing() {
171        let temp = TempDir::new().unwrap();
172        let result = AgentDef::try_load("nonexistent", temp.path());
173        assert!(result.is_none());
174    }
175
176    #[test]
177    fn test_prompt_template_inline() {
178        let temp = TempDir::new().unwrap();
179        let agents_dir = temp.path().join(".scud").join("agents");
180        std::fs::create_dir_all(&agents_dir).unwrap();
181
182        let agent_file = agents_dir.join("custom.toml");
183        let content = r#"
184[agent]
185name = "custom"
186
187[model]
188harness = "claude"
189
190[prompt]
191template = "You are a custom agent for {task.title}"
192"#;
193        std::fs::write(&agent_file, content).unwrap();
194
195        let agent = AgentDef::load("custom", temp.path()).unwrap();
196        let template = agent.prompt_template(temp.path());
197        assert_eq!(
198            template,
199            Some("You are a custom agent for {task.title}".to_string())
200        );
201    }
202
203    #[test]
204    fn test_prompt_template_file() {
205        let temp = TempDir::new().unwrap();
206        let agents_dir = temp.path().join(".scud").join("agents");
207        std::fs::create_dir_all(&agents_dir).unwrap();
208
209        // Create template file
210        let template_content = "Custom template from file: {task.description}";
211        std::fs::write(agents_dir.join("custom.prompt"), template_content).unwrap();
212
213        let agent_file = agents_dir.join("file-agent.toml");
214        let content = r#"
215[agent]
216name = "file-agent"
217
218[model]
219harness = "opencode"
220
221[prompt]
222template_file = "custom.prompt"
223"#;
224        std::fs::write(&agent_file, content).unwrap();
225
226        let agent = AgentDef::load("file-agent", temp.path()).unwrap();
227        let template = agent.prompt_template(temp.path());
228        assert_eq!(template, Some(template_content.to_string()));
229    }
230
231    #[test]
232    fn test_harness_parsing() {
233        let agent = AgentDef::default_builder();
234        let harness = agent.harness().unwrap();
235        assert_eq!(harness, Harness::OpenCode);
236    }
237}