Skip to main content

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    "rho".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    /// Falls back to embedded agent definitions if not found on disk.
75    pub fn try_load(name: &str, project_root: &Path) -> Option<Self> {
76        Self::load(name, project_root)
77            .ok()
78            .or_else(|| Self::load_embedded(name))
79    }
80
81    /// Load from embedded agent definitions (compiled into the binary)
82    fn load_embedded(name: &str) -> Option<Self> {
83        let content = match name {
84            "builder" => Some(include_str!("../assets/spawn-agents/builder.toml")),
85            "fast-builder" => Some(include_str!("../assets/spawn-agents/fast-builder.toml")),
86            "reviewer" => Some(include_str!("../assets/spawn-agents/reviewer.toml")),
87            "planner" => Some(include_str!("../assets/spawn-agents/planner.toml")),
88            "researcher" => Some(include_str!("../assets/spawn-agents/researcher.toml")),
89            "analyzer" => Some(include_str!("../assets/spawn-agents/analyzer.toml")),
90            "repairer" => Some(include_str!("../assets/spawn-agents/repairer.toml")),
91            "tester" => Some(include_str!("../assets/spawn-agents/tester.toml")),
92            "outside-generalist" => {
93                Some(include_str!("../assets/spawn-agents/outside-generalist.toml"))
94            }
95            _ => None,
96        };
97        content.and_then(|c| toml::from_str(c).ok())
98    }
99
100    /// Get the harness for this agent
101    pub fn harness(&self) -> Result<Harness> {
102        Harness::parse(&self.model.harness)
103    }
104
105    /// Get the model name for this agent (if specified)
106    pub fn model(&self) -> Option<&str> {
107        self.model.model.as_deref()
108    }
109
110    /// Get the prompt template (if specified)
111    pub fn prompt_template(&self, project_root: &Path) -> Option<String> {
112        // Try inline template first
113        if let Some(ref template) = self.prompt.template {
114            return Some(template.clone());
115        }
116
117        // Try template file
118        if let Some(ref template_file) = self.prompt.template_file {
119            let path = project_root
120                .join(".scud")
121                .join("agents")
122                .join(template_file);
123            if let Ok(content) = std::fs::read_to_string(&path) {
124                return Some(content);
125            }
126        }
127
128        None
129    }
130
131    /// Create a default agent (Rho with claude-sonnet, no custom prompt)
132    pub fn default_builder() -> Self {
133        AgentDef {
134            agent: AgentMeta {
135                name: "builder".to_string(),
136                description: "Default code implementation agent".to_string(),
137            },
138            model: ModelConfig {
139                harness: "rho".to_string(),
140                model: Some("claude-sonnet".to_string()),
141            },
142            prompt: PromptConfig::default(),
143        }
144    }
145}
146
147#[cfg(test)]
148mod tests {
149    use super::*;
150    use tempfile::TempDir;
151
152    #[test]
153    fn test_load_agent_definition() {
154        let temp = TempDir::new().unwrap();
155        let agents_dir = temp.path().join(".scud").join("agents");
156        std::fs::create_dir_all(&agents_dir).unwrap();
157
158        let agent_file = agents_dir.join("reviewer.toml");
159        let content = r#"
160[agent]
161name = "reviewer"
162description = "Code review agent"
163
164[model]
165harness = "claude"
166model = "opus"
167"#;
168        std::fs::write(&agent_file, content).unwrap();
169
170        let agent = AgentDef::load("reviewer", temp.path()).unwrap();
171        assert_eq!(agent.agent.name, "reviewer");
172        assert_eq!(agent.model.harness, "claude");
173        assert_eq!(agent.model.model, Some("opus".to_string()));
174    }
175
176    #[test]
177    fn test_agent_not_found() {
178        let temp = TempDir::new().unwrap();
179        let result = AgentDef::load("nonexistent", temp.path());
180        assert!(result.is_err());
181    }
182
183    #[test]
184    fn test_default_builder() {
185        let agent = AgentDef::default_builder();
186        assert_eq!(agent.agent.name, "builder");
187        assert_eq!(agent.model.harness, "rho");
188        assert_eq!(agent.model.model, Some("claude-sonnet".to_string()));
189    }
190
191    #[test]
192    fn test_try_load_returns_none_for_missing() {
193        let temp = TempDir::new().unwrap();
194        let result = AgentDef::try_load("nonexistent", temp.path());
195        assert!(result.is_none());
196    }
197
198    #[test]
199    fn test_prompt_template_inline() {
200        let temp = TempDir::new().unwrap();
201        let agents_dir = temp.path().join(".scud").join("agents");
202        std::fs::create_dir_all(&agents_dir).unwrap();
203
204        let agent_file = agents_dir.join("custom.toml");
205        let content = r#"
206[agent]
207name = "custom"
208
209[model]
210harness = "claude"
211
212[prompt]
213template = "You are a custom agent for {task.title}"
214"#;
215        std::fs::write(&agent_file, content).unwrap();
216
217        let agent = AgentDef::load("custom", temp.path()).unwrap();
218        let template = agent.prompt_template(temp.path());
219        assert_eq!(
220            template,
221            Some("You are a custom agent for {task.title}".to_string())
222        );
223    }
224
225    #[test]
226    fn test_prompt_template_file() {
227        let temp = TempDir::new().unwrap();
228        let agents_dir = temp.path().join(".scud").join("agents");
229        std::fs::create_dir_all(&agents_dir).unwrap();
230
231        // Create template file
232        let template_content = "Custom template from file: {task.description}";
233        std::fs::write(agents_dir.join("custom.prompt"), template_content).unwrap();
234
235        let agent_file = agents_dir.join("file-agent.toml");
236        let content = r#"
237[agent]
238name = "file-agent"
239
240[model]
241harness = "opencode"
242
243[prompt]
244template_file = "custom.prompt"
245"#;
246        std::fs::write(&agent_file, content).unwrap();
247
248        let agent = AgentDef::load("file-agent", temp.path()).unwrap();
249        let template = agent.prompt_template(temp.path());
250        assert_eq!(template, Some(template_content.to_string()));
251    }
252
253    #[test]
254    fn test_harness_parsing() {
255        let agent = AgentDef::default_builder();
256        let harness = agent.harness().unwrap();
257        assert_eq!(harness, Harness::Rho);
258    }
259}