1use anyhow::{Context, Result};
7use serde::Deserialize;
8use std::path::Path;
9
10use crate::commands::spawn::terminal::Harness;
11
12#[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 #[serde(default = "default_harness")]
32 pub harness: String,
33 #[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 pub template: Option<String>,
46 pub template_file: Option<String>,
48}
49
50impl AgentDef {
51 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 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 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 pub fn harness(&self) -> Result<Harness> {
102 Harness::parse(&self.model.harness)
103 }
104
105 pub fn model(&self) -> Option<&str> {
107 self.model.model.as_deref()
108 }
109
110 pub fn prompt_template(&self, project_root: &Path) -> Option<String> {
112 if let Some(ref template) = self.prompt.template {
114 return Some(template.clone());
115 }
116
117 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 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 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}