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 "opencode".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> {
75 Self::load(name, project_root).ok()
76 }
77
78 pub fn harness(&self) -> Result<Harness> {
80 Harness::parse(&self.model.harness)
81 }
82
83 pub fn model(&self) -> Option<&str> {
85 self.model.model.as_deref()
86 }
87
88 pub fn prompt_template(&self, project_root: &Path) -> Option<String> {
90 if let Some(ref template) = self.prompt.template {
92 return Some(template.clone());
93 }
94
95 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 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 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}