capo-agent 0.6.0

Coding-agent library built on motosan-agent-loop. Composable, embeddable.
Documentation
#![cfg_attr(test, allow(clippy::expect_used, clippy::unwrap_used))]

use crate::skills::types::Skill;

/// Render the `<available_skills>` block sent to the LLM. Skills with
/// `disable_model_invocation == true` are filtered out. Returns empty
/// string when nothing is invokable.
pub fn render_available_skills_xml(skills: &[Skill]) -> String {
    let visible: Vec<&Skill> = skills
        .iter()
        .filter(|s| !s.disable_model_invocation)
        .collect();
    if visible.is_empty() {
        return String::new();
    }
    let mut out = String::new();
    out.push_str("\n\nThe following skills provide specialized instructions for specific tasks.\n");
    out.push_str(
        "Use the `read` tool to load a skill's file when the task matches its description.\n",
    );
    out.push_str("References inside a skill resolve against the skill's directory.\n");
    out.push_str("\n<available_skills>\n");
    for s in visible {
        out.push_str("  <skill>\n");
        out.push_str(&format!("    <name>{}</name>\n", escape_xml(&s.name)));
        out.push_str(&format!(
            "    <description>{}</description>\n",
            escape_xml(&s.description)
        ));
        out.push_str(&format!(
            "    <location>{}</location>\n",
            escape_xml(&s.file_path.display().to_string())
        ));
        out.push_str("  </skill>\n");
    }
    out.push_str("</available_skills>");
    out
}

fn escape_xml(s: &str) -> String {
    s.replace('&', "&amp;")
        .replace('<', "&lt;")
        .replace('>', "&gt;")
        .replace('"', "&quot;")
        .replace('\'', "&apos;")
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::skills::types::SkillSource;
    use std::path::PathBuf;

    fn s(name: &str, hidden: bool) -> Skill {
        Skill {
            name: name.into(),
            description: format!("desc for {name}"),
            file_path: PathBuf::from(format!("/skills/{name}.md")),
            base_dir: PathBuf::from("/skills"),
            disable_model_invocation: hidden,
            source: SkillSource::Global,
        }
    }

    #[test]
    fn empty_when_no_skills() {
        assert_eq!(render_available_skills_xml(&[]), "");
    }

    #[test]
    fn empty_when_all_skills_hidden() {
        assert_eq!(
            render_available_skills_xml(&[s("a", true), s("b", true)]),
            ""
        );
    }

    #[test]
    fn renders_visible_skills_only() {
        let out = render_available_skills_xml(&[s("a", false), s("b", true), s("c", false)]);
        assert!(out.contains("<available_skills>"));
        assert!(out.contains("</available_skills>"));
        assert!(out.contains("<name>a</name>"));
        assert!(out.contains("<name>c</name>"));
        assert!(!out.contains("<name>b</name>"));
        assert!(out.contains("<location>/skills/a.md</location>"));
        assert!(out.contains("<description>desc for a</description>"));
    }

    #[test]
    fn escapes_xml_special_chars() {
        let mut skill = s("ok", false);
        skill.description = "uses < and & and >".into();
        let out = render_available_skills_xml(&[skill]);
        assert!(out.contains("&lt;"));
        assert!(out.contains("&amp;"));
        assert!(out.contains("&gt;"));
    }
}