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 roots win over user-global ones. A multi-root project has
108 // more than one of them (see `workspace.rs`), ordered primary root first.
109 for dir in crate::workspace::project_dirs() {
110 roots.push(dir.join(".sigit").join("agents"));
111 roots.push(dir.join(".claude").join("agents"));
112 }
113
114 // User-global siGit config dir (honours SIGIT_CONFIG_DIR).
115 if let Some(config_dir) = sigit_config_dir() {
116 roots.push(config_dir.join("agents"));
117 }
118
119 // Shared with the broader Claude Code ecosystem.
120 if let Some(home) = home_dir() {
121 roots.push(home.join(".claude").join("agents"));
122 }
123
124 roots
125 }
126
127 fn sigit_config_dir() -> Option<PathBuf> {
128 if let Ok(dir) = std::env::var("SIGIT_CONFIG_DIR")
129 && !dir.is_empty()
130 {
131 return Some(PathBuf::from(dir));
132 }
133 home_dir().map(|home| home.join(".config").join("sigit"))
134 }
135
136 fn home_dir() -> Option<PathBuf> {
137 std::env::var("HOME").ok().map(PathBuf::from)
138 }
139
140 /// Scan a single root (non-recursive, matching [`crate::skills`]) for `*.md`
141 /// files, appending newly-seen types.
142 fn collect_agent_types_from_root(
143 root: &Path,
144 types: &mut Vec<AgentType>,
145 seen_names: &mut Vec<String>,
146 ) {
147 let entries = match std::fs::read_dir(root) {
148 Ok(entries) => entries,
149 // Most roots won't exist; that's expected, not an error.
150 Err(_) => return,
151 };
152
153 let mut paths: Vec<PathBuf> = entries.flatten().map(|e| e.path()).collect();
154 paths.sort();
155
156 for path in paths {
157 if path.extension().and_then(|e| e.to_str()) != Some("md") {
158 continue;
159 }
160
161 let contents = match std::fs::read_to_string(&path) {
162 Ok(contents) => contents,
163 Err(error) => {
164 log::warn!("skipping agent type at {}: {error}", path.display());
165 continue;
166 }
167 };
168
169 let agent_type = match parse_agent_type(&contents, &path) {
170 Ok(agent_type) => agent_type,
171 Err(error) => {
172 log::warn!("skipping invalid agent type at {}: {error}", path.display());
173 continue;
174 }
175 };
176
177 if seen_names.iter().any(|n| n == &agent_type.name) {
178 log::debug!(
179 "agent type \"{}\" at {} shadowed by an earlier definition",
180 agent_type.name,
181 path.display()
182 );
183 continue;
184 }
185
186 seen_names.push(agent_type.name.clone());
187 types.push(agent_type);
188 }
189 }
190
191 /// Parse a subagent-type file into an [`AgentType`]. `name` and
192 /// `description` are required, mirroring [`crate::skills`]'s `SKILL.md`
193 /// validation.
194 fn parse_agent_type(contents: &str, path: &Path) -> Result<AgentType, String> {
195 let frontmatter = extract_frontmatter(contents)
196 .ok_or_else(|| "missing YAML frontmatter (expected leading `---` block)".to_string())?;
197
198 let fields = parse_frontmatter_fields(frontmatter);
199
200 let name = fields
201 .iter()
202 .find(|(k, _)| k == "name")
203 .map(|(_, v)| v.clone())
204 .ok_or_else(|| "frontmatter is missing required field `name`".to_string())?;
205 validate_name(&name)?;
206
207 let description = fields
208 .iter()
209 .find(|(k, _)| k == "description")
210 .map(|(_, v)| v.clone())
211 .ok_or_else(|| "frontmatter is missing required field `description`".to_string())?;
212 if description.is_empty() {
213 return Err("`description` must not be empty".to_string());
214 }
215
216 let tools = fields
217 .iter()
218 .find(|(k, _)| k == "tools")
219 .map(|(_, v)| v.clone())
220 .filter(|v| !v.is_empty())
221 .map(|raw| {
222 raw.split(',')
223 .map(|t| t.trim().to_string())
224 .filter(|t| !t.is_empty())
225 .collect::<Vec<String>>()
226 });
227
228 let system_prompt = strip_frontmatter(contents).trim().to_string();
229 if system_prompt.is_empty() {
230 return Err("body (the system prompt) must not be empty".to_string());
231 }
232
233 Ok(AgentType {
234 name,
235 description,
236 tools,
237 system_prompt,
238 path: path.to_path_buf(),
239 })
240 }
241
242 #[cfg(test)]
243 mod tests {
244 use super::*;
245 use std::fs;
246
247 fn unique_dir(name: &str) -> PathBuf {
248 let nanos = std::time::SystemTime::now()
249 .duration_since(std::time::UNIX_EPOCH)
250 .unwrap()
251 .as_nanos();
252 std::env::temp_dir().join(format!("sigit-subagents-test-{name}-{nanos}"))
253 }
254
255 #[test]
256 fn parse_agent_type_reads_frontmatter_and_body() {
257 let contents = "---\nname: code-reviewer\ndescription: Reviews diffs\ntools: read_file, search_files\n---\n\nYou are a reviewer.\n";
258 let agent_type = parse_agent_type(contents, Path::new("/x/code-reviewer.md")).unwrap();
259 assert_eq!(agent_type.name, "code-reviewer");
260 assert_eq!(agent_type.description, "Reviews diffs");
261 assert_eq!(
262 agent_type.tools,
263 Some(vec!["read_file".to_string(), "search_files".to_string()])
264 );
265 assert_eq!(agent_type.system_prompt, "You are a reviewer.");
266 }
267
268 #[test]
269 fn parse_agent_type_without_tools_field_is_none() {
270 let contents = "---\nname: generalist\ndescription: does anything\n---\n\nBe helpful.\n";
271 let agent_type = parse_agent_type(contents, Path::new("/x/generalist.md")).unwrap();
272 assert!(agent_type.tools.is_none());
273 }
274
275 #[test]
276 fn parse_agent_type_requires_name_description_and_body() {
277 let dir = Path::new("/tmp/x.md");
278 assert!(parse_agent_type("---\ndescription: x\n---\nbody", dir).is_err());
279 assert!(parse_agent_type("---\nname: x\n---\nbody", dir).is_err());
280 assert!(parse_agent_type("---\nname: x\ndescription: y\n---\n\n", dir).is_err());
281 assert!(parse_agent_type("---\nname: Bad Name\ndescription: y\n---\nbody", dir).is_err());
282 }
283
284 #[test]
285 fn discover_and_resolve_roundtrip() {
286 let _guard = crate::ENV_TEST_LOCK
287 .lock()
288 .unwrap_or_else(|poisoned| poisoned.into_inner());
289 let root = unique_dir("roundtrip");
290 let agents_root = root.join(".sigit").join("agents");
291 fs::create_dir_all(&agents_root).unwrap();
292 fs::write(
293 agents_root.join("code-reviewer.md"),
294 "---\nname: code-reviewer\ndescription: Reviews diffs\ntools: read_file, search_files\n---\n\nYou are a reviewer.\n",
295 )
296 .unwrap();
297
298 let prev = std::env::current_dir().unwrap();
299 std::env::set_current_dir(&root).unwrap();
300
301 let types = discover_agent_types();
302 assert_eq!(types.len(), 1);
303 assert_eq!(types[0].name, "code-reviewer");
304
305 let resolved = resolve_agent_type("code-reviewer").expect("resolve by name");
306 assert_eq!(resolved.system_prompt, "You are a reviewer.");
307 assert!(resolve_agent_type("nope").is_none());
308
309 std::env::set_current_dir(prev).unwrap();
310 let _ = fs::remove_dir_all(&root);
311 }
312
313 #[test]
314 fn format_agent_types_list_reports_none_found() {
315 let _guard = crate::ENV_TEST_LOCK
316 .lock()
317 .unwrap_or_else(|poisoned| poisoned.into_inner());
318 let root = unique_dir("empty");
319 fs::create_dir_all(&root).unwrap();
320 let prev = std::env::current_dir().unwrap();
321 std::env::set_current_dir(&root).unwrap();
322
323 assert!(format_agent_types_list().contains("No subagent types found"));
324
325 std::env::set_current_dir(prev).unwrap();
326 let _ = fs::remove_dir_all(&root);
327 }
328 }