llama-cpp-v3-agent-sdk 0.1.7

Agentic tool-use loop on top of llama-cpp-v3 — local LLM agents with built-in tools
Documentation
//! Skill discovery and loading.
//!
//! Skills are reusable prompt modules that extend the agent's capabilities.
//! Each skill is a directory containing a `SKILL.md` file with YAML-like
//! frontmatter (name + description) followed by Markdown instructions.
//!
//! Skills are discovered from:
//! 1. `./.llama-agent/skills/`  — project-local
//! 2. `./.agents/skills/`       — project-local (alt)
//! 3. `~/.llama-agent/skills/`  — user-global
//! 4. `~/.agents/skills/`       — user-global (alt)
//! 5. Custom paths via `SkillRegistry::add_search_path`

use std::path::{Path, PathBuf};
use std::collections::HashMap;

/// Metadata extracted from SKILL.md frontmatter.
#[derive(Debug, Clone)]
pub struct SkillMeta {
    /// Unique skill name (from frontmatter `name:`).
    pub name: String,
    /// When/why to activate this skill (from frontmatter `description:`).
    pub description: String,
    /// Path to the skill directory.
    pub path: PathBuf,
}

/// A fully loaded skill (metadata + instructions).
#[derive(Debug, Clone)]
pub struct Skill {
    pub meta: SkillMeta,
    /// Full Markdown body after the frontmatter.
    pub instructions: String,
    /// JSON Schemas found in the `schemas/` subdirectory.
    pub schemas: HashMap<String, String>,
    /// Markdown references found in the `references/` subdirectory.
    pub references: HashMap<String, String>,
}

/// Registry that discovers skills from multiple search paths.
pub struct SkillRegistry {
    search_paths: Vec<PathBuf>,
    /// Discovered skill metadata (loaded lazily by name).
    discovered: Vec<SkillMeta>,
    /// Fully loaded skills (activated on demand).
    loaded: Vec<Skill>,
}

impl SkillRegistry {
    pub fn new() -> Self {
        Self {
            search_paths: Vec::new(),
            discovered: Vec::new(),
            loaded: Vec::new(),
        }
    }

    /// Create a registry pre-populated with the default search paths.
    pub fn with_defaults() -> Self {
        let mut reg = Self::new();
        reg.add_default_paths();
        reg
    }

    /// Add the standard search paths (project-local + user-global).
    pub fn add_default_paths(&mut self) {
        // Project-local
        if let Ok(cwd) = std::env::current_dir() {
            self.search_paths.push(cwd.join(".llama-agent").join("skills"));
            self.search_paths.push(cwd.join(".agents").join("skills"));
        }
        // User-global
        if let Some(home) = dirs::home_dir() {
            self.search_paths.push(home.join(".llama-agent").join("skills"));
            self.search_paths.push(home.join(".agents").join("skills"));
        }
    }

    /// Add an extra search path for skills.
    pub fn add_search_path(&mut self, path: PathBuf) {
        self.search_paths.push(path);
    }

    /// Scan all search paths and discover skills (reads only frontmatter).
    pub fn discover(&mut self) {
        self.discovered.clear();
        for search_path in &self.search_paths {
            if !search_path.is_dir() {
                continue;
            }
            if let Ok(entries) = std::fs::read_dir(search_path) {
                for entry in entries.flatten() {
                    let skill_dir = entry.path();
                    if skill_dir.is_dir() {
                        let skill_md = skill_dir.join("SKILL.md");
                        if skill_md.is_file() {
                            if let Some(meta) = parse_skill_frontmatter(&skill_md, &skill_dir) {
                                // Dedup by name (first found wins)
                                if !self.discovered.iter().any(|s| s.name == meta.name) {
                                    self.discovered.push(meta);
                                }
                            }
                        }
                    }
                }
            }
        }
    }

    /// Return all discovered skill metadata.
    pub fn discovered(&self) -> &[SkillMeta] {
        &self.discovered
    }

    /// Load (activate) a skill by name — reads the full SKILL.md body.
    pub fn load(&mut self, name: &str) -> Option<&Skill> {
        // Already loaded?
        if let Some(idx) = self.loaded.iter().position(|s| s.meta.name == name) {
            return Some(&self.loaded[idx]);
        }

        // Find in discovered
        let meta = self.discovered.iter().find(|s| s.name == name)?.clone();
        let skill_md = meta.path.join("SKILL.md");
        let content = std::fs::read_to_string(&skill_md).ok()?;
        let instructions = strip_frontmatter(&content);

        // Load schemas
        let mut schemas = HashMap::new();
        let schemas_dir = meta.path.join("schemas");
        if schemas_dir.is_dir() {
            if let Ok(entries) = std::fs::read_dir(schemas_dir) {
                for entry in entries.flatten() {
                    let path = entry.path();
                    if path.extension().and_then(|s| s.to_str()) == Some("json") {
                        if let (
                            Some(name),
                            Ok(content),
                        ) = (
                            path.file_name().and_then(|s| s.to_str()),
                            std::fs::read_to_string(&path),
                        ) {
                            schemas.insert(name.to_string(), content);
                        }
                    }
                }
            }
        }

        // Load references
        let mut references = HashMap::new();
        let refs_dir = meta.path.join("references");
        if refs_dir.is_dir() {
            if let Ok(entries) = std::fs::read_dir(refs_dir) {
                for entry in entries.flatten() {
                    let path = entry.path();
                    if path.extension().and_then(|s| s.to_str()) == Some("md") {
                        if let (
                            Some(name),
                            Ok(content),
                        ) = (
                            path.file_name().and_then(|s| s.to_str()),
                            std::fs::read_to_string(&path),
                        ) {
                            references.insert(name.to_string(), content);
                        }
                    }
                }
            }
        }

        let skill = Skill {
            meta,
            instructions,
            schemas,
            references,
        };
        self.loaded.push(skill);
        self.loaded.last()
    }

    /// Load all discovered skills.
    pub fn load_all(&mut self) {
        let names: Vec<String> = self.discovered.iter().map(|s| s.name.clone()).collect();
        for name in names {
            self.load(&name);
        }
    }

    /// Get all loaded (activated) skills.
    pub fn loaded(&self) -> &[Skill] {
        &self.loaded
    }

    /// Generate a system prompt fragment listing discovered skills.
    ///
    /// Only name + description are included (the full instructions are
    /// injected when a skill is activated).
    pub fn skills_summary_prompt(&self) -> String {
        if self.discovered.is_empty() {
            return String::new();
        }
        let mut lines = Vec::new();
        lines.push("# Available Skills".to_string());
        lines.push("The following skills are available. They will be activated when relevant:\n".to_string());
        for skill in &self.discovered {
            lines.push(format!("- **{}**: {}", skill.name, skill.description));
        }
        lines.push(String::new());
        lines.join("\n")
    }

    /// Generate a prompt fragment with the full instructions of all loaded skills.
    pub fn loaded_skills_prompt(&self) -> String {
        if self.loaded.is_empty() {
            return String::new();
        }
        let mut lines = Vec::new();
        lines.push("# Active Skill Details\n".to_string());
        for skill in &self.loaded {
            lines.push(format!("## Skill: {}\n", skill.meta.name));
            lines.push(skill.instructions.clone());
            lines.push(String::new());

            if !skill.schemas.is_empty() {
                lines.push("### Required Output Schemas".to_string());
                lines.push("When generating JSON, you MUST adhere to these schemas:".to_string());
                for (name, content) in &skill.schemas {
                    lines.push(format!("\n#### Schema: {}\n```json\n{}\n```", name, content));
                }
                lines.push(String::new());
            }

            if !skill.references.is_empty() {
                lines.push("### References & Guidelines".to_string());
                for (name, content) in &skill.references {
                    lines.push(format!("\n#### Reference: {}\n{}", name, content));
                }
                lines.push(String::new());
            }
        }
        lines.join("\n")
    }
}

impl Default for SkillRegistry {
    fn default() -> Self {
        Self::new()
    }
}

// ─── Frontmatter parsing ──────────────────────────────────────────────────

/// Parse YAML-like frontmatter from a SKILL.md file.
///
/// Expected format:
/// ```text
/// ---
/// name: skill-name
/// description: What and when
/// ---
/// Markdown body...
/// ```
fn parse_skill_frontmatter(skill_md: &Path, skill_dir: &Path) -> Option<SkillMeta> {
    let content = std::fs::read_to_string(skill_md).ok()?;
    let content = content.trim();

    if !content.starts_with("---") {
        return None;
    }

    // Find closing ---
    let rest = &content[3..];
    let end = rest.find("---")?;
    let frontmatter = &rest[..end];

    let mut name = None;
    let mut description = None;

    for line in frontmatter.lines() {
        let line = line.trim();
        if let Some(val) = line.strip_prefix("name:") {
            name = Some(val.trim().trim_matches('"').trim_matches('\'').to_string());
        } else if let Some(val) = line.strip_prefix("description:") {
            description = Some(val.trim().trim_matches('"').trim_matches('\'').to_string());
        }
    }

    Some(SkillMeta {
        name: name?,
        description: description.unwrap_or_default(),
        path: skill_dir.to_path_buf(),
    })
}

/// Strip frontmatter (everything between opening and closing `---`) from content.
fn strip_frontmatter(content: &str) -> String {
    let content = content.trim();
    if !content.starts_with("---") {
        return content.to_string();
    }
    let rest = &content[3..];
    if let Some(end) = rest.find("---") {
        rest[end + 3..].trim().to_string()
    } else {
        content.to_string()
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_strip_frontmatter() {
        let content = "---\nname: test\ndescription: A test skill\n---\n\n# Instructions\nDo something.";
        let body = strip_frontmatter(content);
        assert!(body.starts_with("# Instructions"));
        assert!(body.contains("Do something."));
    }

    #[test]
    fn test_strip_frontmatter_no_frontmatter() {
        let content = "Just regular markdown.";
        assert_eq!(strip_frontmatter(content), content);
    }
}