1 //! Configurable subagent types for the `task` tool.
2 //!
3 //! A subagent type is a Markdown file with YAML frontmatter:
4 //!
5 //! ```markdown
6 //! ---
7 //! name: code-reviewer
8 //! description: Reviews a diff for correctness bugs. Use when the user asks for a review.
9 //! tools: read_file, search_files, glob
10 //! ---
11 //!
12 //! You are a meticulous code reviewer. Focus on correctness bugs first...
13 //! ```
14 //!
15 //! Discovered from, in priority order (earlier wins on name clashes):
16 //!
17 //! - `<cwd>/.sigit/agents/` and `<cwd>/.claude/agents/` (project-local)
18 //! - `$SIGIT_CONFIG_DIR/agents/` (default `~/.config/sigit/agents/`)
19 //! - `~/.claude/agents/` (shared with the broader Claude Code ecosystem)
20 //!
21 //! `name` and `description` are required (same slug rules as
22 //! [`crate::skills`]); the Markdown body becomes the subagent's system
23 //! prompt, replacing [`crate::tools::SUBAGENT_SYSTEM_PROMPT`] for that run.
24 //!
25 //! `tools` is optional and, when present, is a comma-separated allow-list.
26 //! It can only ever *narrow* the subagent's toolset — [`crate::tools`] owns
27 //! the actual read-only allow-list ([`crate::tools::SUBAGENT_TOOL_NAMES`])
28 //! and intersects it against this list, so a type file cannot grant itself
29 //! `edit_file`, `run_command`, or any other mutating tool. That security
30 //! boundary is why the intersection logic lives in `tools.rs` next to the
31 //! constant it's protecting, not here — this module only discovers and
32 //! parses files, it never decides what a subagent may execute.
33
34 use std::path::{Path, PathBuf};
35
36 use crate::frontmatter::{extract_frontmatter, parse_frontmatter_fields, strip_frontmatter};
37 use crate::skills::validate_name;
38
39 /// A discovered subagent type.
40 #[derive(Debug, Clone, PartialEq, Eq)]
41 pub struct AgentType {
42 /// The `name` from frontmatter — the value passed as `task`'s
43 /// `subagent_type` argument.
44 pub name: String,
45 /// The `description` from frontmatter: what the type is for and when to
46 /// use it, embedded in the `task` tool's description.
47 pub description: String,
48 /// Raw `tools` list from frontmatter, if present. Unfiltered — see
49 /// [`crate::tools`] for the security-relevant intersection against the
50 /// base read-only allow-list.
51 pub tools: Option<Vec<String>>,
52 /// The Markdown body (frontmatter stripped): this type's system prompt.
53 pub system_prompt: String,
54 /// Where this type's file lives on disk, for diagnostics.
55 pub path: PathBuf,
56 }
57
58 /// Discover all valid subagent types across the known roots. Earlier roots
59 /// win when two types share a `name`. Sorted by name for stable output.
60 pub fn discover_agent_types() -> Vec<AgentType> {
61 let mut types: Vec<AgentType> = Vec::new();
62 let mut seen_names: Vec<String> = Vec::new();
63
64 for root in agent_type_roots() {
65 collect_agent_types_from_root(&root, &mut types, &mut seen_names);
66 }
67
68 types.sort_by(|a, b| a.name.cmp(&b.name));
69 types
70 }
71
72 /// Re-discover types and find one by name.
73 pub fn resolve_agent_type(name: &str) -> Option<AgentType> {
74 discover_agent_types().into_iter().find(|t| t.name == name)
75 }
76
77 /// Human-readable list of discovered subagent types, for the `/agents` slash
78 /// command.
79 pub fn format_agent_types_list() -> String {
80 let types = discover_agent_types();
81 if types.is_empty() {
82 return "No subagent types found. Add a Markdown file under \
83 .sigit/agents/ or .claude/agents/ in your project (e.g. \
84 .sigit/agents/code-reviewer.md), or under \
85 ~/.config/sigit/agents/. Without one, `task` uses the \
86 default general-purpose research subagent."
87 .to_string();
88 }
89
90 let mut lines = vec![format!("{} subagent type(s) available:", types.len())];
91 for agent_type in &types {
92 lines.push(format!("- {}: {}", agent_type.name, agent_type.description));
93 }
94 lines.push(String::new());
95 lines.push(
96 "Pass one of these names as `subagent_type` when calling `task`; omit it for the \
97 default general-purpose research subagent."
98 .to_string(),
99 );
100 lines.join("\n")
101 }
102
103 /// The subagent-type directories to scan, in priority order.
104 fn agent_type_roots() -> Vec<PathBuf> {
105 let mut roots = Vec::new();
106
107 // Project-local types win over user-global ones.
108 if let Ok(cwd) = std::env::current_dir() {
109 roots.push(cwd.join(".sigit").join("agents"));
110 roots.push(cwd.join(".claude").join("agents"));
111 }
112
113 // User-global siGit config dir (honours SIGIT_CONFIG_DIR).
114 if let Some(config_dir) = sigit_config_dir() {
115 roots.push(config_dir.join("agents"));
116 }
117
118 // Shared with the broader Claude Code ecosystem.
119 if let Some(home) = home_dir() {
120 roots.push(home.join(".claude").join("agents"));
121 }
122
123 roots
124 }
125
126 fn sigit_config_dir() -> Option<PathBuf> {
127 if let Ok(dir) = std::env::var("SIGIT_CONFIG_DIR")
128 && !dir.is_empty()
129 {
130 return Some(PathBuf::from(dir));
131 }
132 home_dir().map(|home| home.join(".config").join("sigit"))
133 }
134
135 fn home_dir() -> Option<PathBuf> {
136 std::env::var("HOME").ok().map(PathBuf::from)
137 }
138
139 /// Scan a single root (non-recursive, matching [`crate::skills`]) for `*.md`
140 /// files, appending newly-seen types.
141 fn collect_agent_types_from_root(
142 root: &Path,
143 types: &mut Vec<AgentType>,
144 seen_names: &mut Vec<String>,
145 ) {
146 let entries = match std::fs::read_dir(root) {
147 Ok(entries) => entries,
148 // Most roots won't exist; that's expected, not an error.
149 Err(_) => return,
150 };
151
152 let mut paths: Vec<PathBuf> = entries.flatten().map(|e| e.path()).collect();
153 paths.sort();
154
155 for path in paths {
156 if path.extension().and_then(|e| e.to_str()) != Some("md") {
157 continue;
158 }
159
160 let contents = match std::fs::read_to_string(&path) {
161 Ok(contents) => contents,
162 Err(error) => {
163 log::warn!("skipping agent type at {}: {error}", path.display());
164 continue;
165 }
166 };
167
168 let agent_type = match parse_agent_type(&contents, &path) {
169 Ok(agent_type) => agent_type,
170 Err(error) => {
171 log::warn!("skipping invalid agent type at {}: {error}", path.display());
172 continue;
173 }
174 };
175
176 if seen_names.iter().any(|n| n == &agent_type.name) {
177 log::debug!(
178 "agent type \"{}\" at {} shadowed by an earlier definition",
179 agent_type.name,
180 path.display()
181 );
182 continue;
183 }
184
185 seen_names.push(agent_type.name.clone());
186 types.push(agent_type);
187 }
188 }
189
190 /// Parse a subagent-type file into an [`AgentType`]. `name` and
191 /// `description` are required, mirroring [`crate::skills`]'s `SKILL.md`
192 /// validation.
193 fn parse_agent_type(contents: &str, path: &Path) -> Result<AgentType, String> {
194 let frontmatter = extract_frontmatter(contents)
195 .ok_or_else(|| "missing YAML frontmatter (expected leading `---` block)".to_string())?;
196
197 let fields = parse_frontmatter_fields(frontmatter);
198
199 let name = fields
200 .iter()
201 .find(|(k, _)| k == "name")
202 .map(|(_, v)| v.clone())
203 .ok_or_else(|| "frontmatter is missing required field `name`".to_string())?;
204 validate_name(&name)?;
205
206 let description = fields
207 .iter()
208 .find(|(k, _)| k == "description")
209 .map(|(_, v)| v.clone())
210 .ok_or_else(|| "frontmatter is missing required field `description`".to_string())?;
211 if description.is_empty() {
212 return Err("`description` must not be empty".to_string());
213 }
214
215 let tools = fields
216 .iter()
217 .find(|(k, _)| k == "tools")
218 .map(|(_, v)| v.clone())
219 .filter(|v| !v.is_empty())
220 .map(|raw| {
221 raw.split(',')
222 .map(|t| t.trim().to_string())
223 .filter(|t| !t.is_empty())
224 .collect::<Vec<String>>()
225 });
226
227 let system_prompt = strip_frontmatter(contents).trim().to_string();
228 if system_prompt.is_empty() {
229 return Err("body (the system prompt) must not be empty".to_string());
230 }
231
232 Ok(AgentType {
233 name,
234 description,
235 tools,
236 system_prompt,
237 path: path.to_path_buf(),
238 })
239 }
240
241 #[cfg(test)]
242 mod tests {
243 use super::*;
244 use std::fs;
245
246 fn unique_dir(name: &str) -> PathBuf {
247 let nanos = std::time::SystemTime::now()
248 .duration_since(std::time::UNIX_EPOCH)
249 .unwrap()
250 .as_nanos();
251 std::env::temp_dir().join(format!("sigit-subagents-test-{name}-{nanos}"))
252 }
253
254 #[test]
255 fn parse_agent_type_reads_frontmatter_and_body() {
256 let contents = "---\nname: code-reviewer\ndescription: Reviews diffs\ntools: read_file, search_files\n---\n\nYou are a reviewer.\n";
257 let agent_type = parse_agent_type(contents, Path::new("/x/code-reviewer.md")).unwrap();
258 assert_eq!(agent_type.name, "code-reviewer");
259 assert_eq!(agent_type.description, "Reviews diffs");
260 assert_eq!(
261 agent_type.tools,
262 Some(vec!["read_file".to_string(), "search_files".to_string()])
263 );
264 assert_eq!(agent_type.system_prompt, "You are a reviewer.");
265 }
266
267 #[test]
268 fn parse_agent_type_without_tools_field_is_none() {
269 let contents = "---\nname: generalist\ndescription: does anything\n---\n\nBe helpful.\n";
270 let agent_type = parse_agent_type(contents, Path::new("/x/generalist.md")).unwrap();
271 assert!(agent_type.tools.is_none());
272 }
273
274 #[test]
275 fn parse_agent_type_requires_name_description_and_body() {
276 let dir = Path::new("/tmp/x.md");
277 assert!(parse_agent_type("---\ndescription: x\n---\nbody", dir).is_err());
278 assert!(parse_agent_type("---\nname: x\n---\nbody", dir).is_err());
279 assert!(parse_agent_type("---\nname: x\ndescription: y\n---\n\n", dir).is_err());
280 assert!(parse_agent_type("---\nname: Bad Name\ndescription: y\n---\nbody", dir).is_err());
281 }
282
283 #[test]
284 fn discover_and_resolve_roundtrip() {
285 let _guard = crate::ENV_TEST_LOCK
286 .lock()
287 .unwrap_or_else(|poisoned| poisoned.into_inner());
288 let root = unique_dir("roundtrip");
289 let agents_root = root.join(".sigit").join("agents");
290 fs::create_dir_all(&agents_root).unwrap();
291 fs::write(
292 agents_root.join("code-reviewer.md"),
293 "---\nname: code-reviewer\ndescription: Reviews diffs\ntools: read_file, search_files\n---\n\nYou are a reviewer.\n",
294 )
295 .unwrap();
296
297 let prev = std::env::current_dir().unwrap();
298 std::env::set_current_dir(&root).unwrap();
299
300 let types = discover_agent_types();
301 assert_eq!(types.len(), 1);
302 assert_eq!(types[0].name, "code-reviewer");
303
304 let resolved = resolve_agent_type("code-reviewer").expect("resolve by name");
305 assert_eq!(resolved.system_prompt, "You are a reviewer.");
306 assert!(resolve_agent_type("nope").is_none());
307
308 std::env::set_current_dir(prev).unwrap();
309 let _ = fs::remove_dir_all(&root);
310 }
311
312 #[test]
313 fn format_agent_types_list_reports_none_found() {
314 let _guard = crate::ENV_TEST_LOCK
315 .lock()
316 .unwrap_or_else(|poisoned| poisoned.into_inner());
317 let root = unique_dir("empty");
318 fs::create_dir_all(&root).unwrap();
319 let prev = std::env::current_dir().unwrap();
320 std::env::set_current_dir(&root).unwrap();
321
322 assert!(format_agent_types_list().contains("No subagent types found"));
323
324 std::env::set_current_dir(prev).unwrap();
325 let _ = fs::remove_dir_all(&root);
326 }
327 }