Skip to main content

synaps_cli/tools/
agent.rs

1//! Agent prompt resolution — loads agent configs from ~/.synaps-cli/agents/.
2//!
3//! Resolution order for `resolve_agent_prompt(name)`:
4//!   1. `name` contains `/` → treat as file path, read directly
5//!   2. `name` contains `:` → `plugin:agent` namespaced lookup
6//!      → search `~/.synaps-cli/plugins/<plugin>/skills/*/agents/<agent>.md`
7//!   3. bare name → `~/.synaps-cli/agents/<name>.md`
8use super::util::expand_path;
9
10/// Returns true if the name component is a safe identifier (no path separators,
11/// no `..`, non-empty, only alphanumeric + hyphens + underscores).
12fn is_valid_name(s: &str) -> bool {
13    !s.is_empty()
14        && !s.contains('/')
15        && !s.contains('\\')
16        && !s.contains("..")
17        && s.chars().all(|c| c.is_alphanumeric() || c == '-' || c == '_')
18}
19
20/// Resolve an agent name to a system prompt.
21pub fn resolve_agent_prompt(name: &str) -> std::result::Result<String, String> {
22    // Defense-in-depth: callers should already filter blank names to None, but if
23    // one slips through we must not search `~/.synaps-cli/agents/.md` — that path
24    // looks valid on disk and produces a confusing error that models retry forever.
25    // Reject names that are empty or entirely whitespace/control chars (incl. NUL).
26    if name.chars().all(|c| c.is_whitespace() || c.is_control()) {
27        return Err(
28            "Empty 'agent' parameter. Pass a non-empty agent name, omit the field entirely, \
29             or provide 'system_prompt' inline instead.".to_string()
30        );
31    }
32
33    // 1. File path — name contains '/'
34    if name.contains('/') {
35        let path = expand_path(name);
36        let content = std::fs::read_to_string(&path)
37            .map_err(|e| format!("Failed to read agent file '{}': {}", path.display(), e))?;
38        return Ok(strip_frontmatter(&content));
39    }
40
41    // 2. Namespaced — "plugin:agent" syntax
42    if let Some((plugin, agent)) = name.split_once(':') {
43        // Validate both sides are safe identifiers
44        if !is_valid_name(plugin) || !is_valid_name(agent) {
45            return Err(format!(
46                "Invalid agent syntax '{}'. Expected 'plugin:agent' where both are \
47                 identifiers (alphanumeric, hyphens, underscores).",
48                name
49            ));
50        }
51        let plugins_dir = crate::config::base_dir().join("plugins");
52        let plugin_dir = plugins_dir.join(plugin);
53        if !plugin_dir.is_dir() {
54            return Err(format!(
55                "Plugin '{}' not found at {}",
56                plugin,
57                plugin_dir.display()
58            ));
59        }
60        // Verify resolved path is still under plugins_dir (path traversal guard)
61        let canonical_plugins = plugins_dir.canonicalize().unwrap_or_else(|_| plugins_dir.clone());
62        let canonical_plugin = plugin_dir.canonicalize().unwrap_or_else(|_| plugin_dir.clone());
63        if !canonical_plugin.starts_with(&canonical_plugins) {
64            return Err(format!("Invalid plugin name: '{}'", plugin));
65        }
66        return resolve_namespaced_agent(agent, &plugin_dir);
67    }
68
69    // 3. Bare name — ~/.synaps-cli/agents/<name>.md
70    let agents_dir = crate::config::base_dir().join("agents");
71    let agent_path = agents_dir.join(format!("{}.md", name));
72
73    if agent_path.exists() {
74        let content = std::fs::read_to_string(&agent_path)
75            .map_err(|e| format!("Failed to read agent '{}': {}", agent_path.display(), e))?;
76        return Ok(strip_frontmatter(&content));
77    }
78
79    Err(format!(
80        "Agent '{}' not found. Searched:\n  - {}\n\
81         Create the file, pass a system_prompt directly, or use 'plugin:agent' syntax for plugin agents.",
82        name,
83        agent_path.display()
84    ))
85}
86
87/// Search `plugin_dir/skills/*/agents/<agent>.md` for a matching agent file.
88/// Errors if the agent is found in multiple skills (ambiguous).
89fn resolve_namespaced_agent(
90    agent: &str,
91    plugin_dir: &std::path::Path,
92) -> std::result::Result<String, String> {
93    let skills_dir = plugin_dir.join("skills");
94    let entries = std::fs::read_dir(&skills_dir).map_err(|e| {
95        format!(
96            "No skills directory in plugin at {}: {}",
97            plugin_dir.display(),
98            e
99        )
100    })?;
101
102    let mut matches: Vec<std::path::PathBuf> = Vec::new();
103    for entry in entries {
104        let entry = entry.map_err(|e| format!("Error reading skills dir: {}", e))?;
105        let agent_path = entry.path().join("agents").join(format!("{}.md", agent));
106        if agent_path.exists() {
107            matches.push(agent_path);
108        }
109    }
110
111    match matches.len() {
112        0 => Err(format!(
113            "Agent '{}' not found in plugin at {}. Searched skills/*/agents/{}.md",
114            agent,
115            plugin_dir.display(),
116            agent
117        )),
118        1 => {
119            let content = std::fs::read_to_string(&matches[0])
120                .map_err(|e| format!("Failed to read agent '{}': {}", matches[0].display(), e))?;
121            Ok(strip_frontmatter(&content))
122        }
123        n => Err(format!(
124            "Ambiguous agent '{}': found in {} skills. Use the full path instead.\n  {}",
125            agent,
126            n,
127            matches.iter().map(|p| p.display().to_string()).collect::<Vec<_>>().join("\n  ")
128        )),
129    }
130}
131
132pub(crate) fn strip_frontmatter(content: &str) -> String {
133    if let Some(rest) = content.strip_prefix("---") {
134        if let Some(end) = rest.find("\n---") {
135            // skip past the "\n---" (4 bytes) to get the body
136            return rest[end + 4..].trim().to_string();
137        }
138    }
139    content.to_string()
140}
141
142#[cfg(test)]
143mod tests {
144    use super::*;
145
146    #[test]
147    fn resolve_namespaced_agent_finds_plugin_agent() {
148        let tmp = tempfile::tempdir().unwrap();
149        let agents_dir = tmp
150            .path()
151            .join("skills")
152            .join("bbe")
153            .join("agents");
154        std::fs::create_dir_all(&agents_dir).unwrap();
155        std::fs::write(
156            agents_dir.join("sage.md"),
157            "---\nname: bbe-sage\ndescription: d\n---\nYou are sage.",
158        )
159        .unwrap();
160
161        let result = resolve_namespaced_agent("sage", tmp.path());
162        assert!(result.is_ok());
163        assert_eq!(result.unwrap(), "You are sage.");
164    }
165
166    #[test]
167    fn resolve_namespaced_agent_not_found() {
168        let tmp = tempfile::tempdir().unwrap();
169        std::fs::create_dir_all(tmp.path().join("skills")).unwrap();
170
171        let result = resolve_namespaced_agent("nonexistent", tmp.path());
172        assert!(result.is_err());
173        assert!(result.unwrap_err().contains("not found"));
174    }
175
176    #[test]
177    fn resolve_namespaced_agent_no_skills_dir() {
178        let tmp = tempfile::tempdir().unwrap();
179        let result = resolve_namespaced_agent("sage", tmp.path());
180        assert!(result.is_err());
181        assert!(result.unwrap_err().contains("No skills directory"));
182    }
183
184    #[test]
185    fn resolve_namespaced_agent_strips_frontmatter() {
186        let tmp = tempfile::tempdir().unwrap();
187        let agents_dir = tmp.path().join("skills").join("s").join("agents");
188        std::fs::create_dir_all(&agents_dir).unwrap();
189        std::fs::write(
190            agents_dir.join("a.md"),
191            "---\nname: x\ndescription: d\n---\nClean body",
192        )
193        .unwrap();
194
195        let result = resolve_namespaced_agent("a", tmp.path()).unwrap();
196        assert_eq!(result, "Clean body");
197    }
198
199    #[test]
200    fn resolve_namespaced_agent_ambiguous_errors() {
201        let tmp = tempfile::tempdir().unwrap();
202        // Create same agent in two different skills
203        let skill1 = tmp.path().join("skills").join("skill-a").join("agents");
204        let skill2 = tmp.path().join("skills").join("skill-b").join("agents");
205        std::fs::create_dir_all(&skill1).unwrap();
206        std::fs::create_dir_all(&skill2).unwrap();
207        std::fs::write(skill1.join("sage.md"), "---\nname: sage\n---\nA").unwrap();
208        std::fs::write(skill2.join("sage.md"), "---\nname: sage\n---\nB").unwrap();
209
210        let result = resolve_namespaced_agent("sage", tmp.path());
211        assert!(result.is_err());
212        let err = result.unwrap_err();
213        assert!(err.contains("Ambiguous"), "Expected ambiguity error, got: {}", err);
214        assert!(err.contains("2 skills"), "Expected '2 skills' in error, got: {}", err);
215    }
216
217    #[test]
218    fn strip_frontmatter_removes_yaml_header() {
219        let input = "---\nname: x\n---\nBody text";
220        assert_eq!(strip_frontmatter(input), "Body text");
221    }
222
223    #[test]
224    fn strip_frontmatter_passes_through_plain_text() {
225        assert_eq!(strip_frontmatter("Just text"), "Just text");
226    }
227
228    #[test]
229    fn strip_frontmatter_unclosed_returns_raw() {
230        // Unclosed frontmatter — no closing "---"
231        let input = "---\nname: x\nno closing delimiter\nBody";
232        assert_eq!(strip_frontmatter(input), input);
233    }
234
235    #[test]
236    fn is_valid_name_rejects_traversal() {
237        assert!(!is_valid_name("../../etc"));
238        assert!(!is_valid_name("foo/bar"));
239        assert!(!is_valid_name(""));
240        assert!(!is_valid_name("foo..bar"));
241        assert!(!is_valid_name("foo\\bar"));
242    }
243
244    #[test]
245    fn is_valid_name_accepts_good_names() {
246        assert!(is_valid_name("dev-tools"));
247        assert!(is_valid_name("sage"));
248        assert!(is_valid_name("my_agent_123"));
249        assert!(is_valid_name("BBE"));
250    }
251
252    #[test]
253    fn resolve_agent_prompt_rejects_blank_name() {
254        // Empty string, whitespace, and NUL must all error early — never search disk.
255        for name in ["", " ", "  \t  ", "\n", "\u{0}"] {
256            let err = resolve_agent_prompt(name).unwrap_err();
257            assert!(
258                err.contains("Empty 'agent' parameter"),
259                "blank name {:?} should produce the empty-agent error, got: {}",
260                name,
261                err,
262            );
263            // Critical: the error must NOT mention `agents/.md` — that's the bug
264            // signature that caused models to loop with sentinel agent values.
265            assert!(
266                !err.contains("agents/.md"),
267                "blank name {:?} leaked path-search error: {}",
268                name,
269                err,
270            );
271        }
272    }
273
274    #[test]
275    fn test_strip_frontmatter_removes_frontmatter() {
276        let content = "---\ntitle: test\ndate: 2023-01-01\n---\nThis is the content.";
277        let result = strip_frontmatter(content);
278        assert_eq!(result, "This is the content.");
279    }
280
281    #[test]
282    fn test_strip_frontmatter_without_frontmatter() {
283        let content = "This is just plain content.";
284        let result = strip_frontmatter(content);
285        assert_eq!(result, content);
286    }
287
288    #[test]
289    fn test_strip_frontmatter_only_opening_delimiter() {
290        let content = "---\ntitle: test\nno closing delimiter";
291        let result = strip_frontmatter(content);
292        assert_eq!(result, content);
293    }
294
295    #[test]
296    fn test_resolve_agent_prompt_nonexistent() {
297        let result = resolve_agent_prompt("definitely_does_not_exist_12345");
298        assert!(result.is_err());
299        let error = result.unwrap_err();
300        assert!(error.contains("Agent 'definitely_does_not_exist_12345' not found"));
301    }
302
303    #[test]
304    fn test_resolve_agent_prompt_path_not_found() {
305        let result = resolve_agent_prompt("/nonexistent/path/agent.md");
306        assert!(result.is_err());
307        let error = result.unwrap_err();
308        assert!(error.contains("Failed to read agent file"));
309    }
310
311    #[test]
312    fn test_resolve_agent_prompt_blank_rejected_without_agent_lookup() {
313        let result = resolve_agent_prompt("");
314        assert!(result.is_err());
315        let error = result.unwrap_err();
316        assert!(error.contains("Empty 'agent' parameter"));
317        assert!(!error.contains("agents/.md"));
318    }
319}