tiny-agent 0.3.0

一个小而完整的 Rust LLM Agent 运行时:可中断、可恢复、可观测、可插拔的 agent loop / A small but complete LLM agent runtime in Rust — an interruptible, resumable, observable, pluggable agent loop.
Documentation
use serde::{Deserialize, Serialize};
use serde_json::json;
use std::{
    collections::HashMap,
    fs,
    path::{Component, Path, PathBuf},
};

const SKILL_DOC: &str = "SKILL.md";

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SkillIndexEntry {
    pub id: String,
    pub name: String,
    pub description: String,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SkillReadResult {
    pub skill_id: String,
    pub path: Option<String>,
    pub content: String,
    pub resources: Vec<SkillResourceIndexEntry>,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SkillResourceIndexEntry {
    pub path: String,
    pub description: Option<String>,
}

#[derive(thiserror::Error, Debug, Clone, PartialEq, Eq)]
pub enum SkillError {
    #[error("skill root does not exist: {0}")]
    RootNotFound(String),
    #[error("skill not found: {0}")]
    NotFound(String),
    #[error("skill resource not found: {skill_id}:{path}")]
    ResourceNotFound { skill_id: String, path: String },
    #[error("invalid skill path: {0}")]
    InvalidPath(String),
    #[error("invalid skill metadata in {skill_id}: {message}")]
    InvalidMetadata { skill_id: String, message: String },
    #[error("skill io error: {0}")]
    Io(String),
}

#[derive(Debug, Clone)]
struct SkillEntry {
    id: String,
    name: String,
    description: String,
    dir: PathBuf,
}

/// 从一个或多个目录发现 skills 的管理器。
///
/// 发现规则:
/// - `skill_dir/SKILL.md` 存在时,`skill_dir` 自身是一个 skill;
/// - `skill_root/*/SKILL.md` 存在时,每个子目录是一个 skill;
/// - skill id 使用目录名;
/// - skill name 与 description 来自 `SKILL.md` 的 YAML frontmatter。
#[derive(Debug, Default)]
pub struct SkillManager {
    skills: HashMap<String, SkillEntry>,
}

impl SkillManager {
    pub fn discover_dir(path: impl AsRef<Path>) -> Result<Self, SkillError> {
        Self::discover_dirs([path])
    }

    pub fn discover_dirs<I, P>(paths: I) -> Result<Self, SkillError>
    where
        I: IntoIterator<Item = P>,
        P: AsRef<Path>,
    {
        let mut manager = Self::default();
        for path in paths {
            manager.discover_one(path.as_ref())?;
        }
        Ok(manager)
    }

    pub fn list(&self) -> Vec<SkillIndexEntry> {
        let mut entries = self
            .skills
            .values()
            .map(|skill| SkillIndexEntry {
                id: skill.id.clone(),
                name: skill.name.clone(),
                description: skill.description.clone(),
            })
            .collect::<Vec<_>>();
        entries.sort_by(|a, b| a.id.cmp(&b.id));
        entries
    }

    pub fn read(&self, skill_id: &str, path: Option<&str>) -> Result<SkillReadResult, SkillError> {
        let skill = self
            .skills
            .get(skill_id)
            .ok_or_else(|| SkillError::NotFound(skill_id.to_string()))?;
        let resources = resource_index(&skill.dir)?;

        match path {
            Some(path) => {
                validate_relative_path(path)?;
                let resource_path = skill.dir.join(path);
                if !resource_path.is_file() || path == SKILL_DOC {
                    return Err(SkillError::ResourceNotFound {
                        skill_id: skill_id.to_string(),
                        path: path.to_string(),
                    });
                }
                Ok(SkillReadResult {
                    skill_id: skill.id.clone(),
                    path: Some(path.to_string()),
                    content: fs::read_to_string(resource_path)
                        .map_err(|e| SkillError::Io(e.to_string()))?,
                    resources,
                })
            }
            None => Ok(SkillReadResult {
                skill_id: skill.id.clone(),
                path: None,
                content: fs::read_to_string(skill.dir.join(SKILL_DOC))
                    .map_err(|e| SkillError::Io(e.to_string()))?,
                resources,
            }),
        }
    }

    fn discover_one(&mut self, root: &Path) -> Result<(), SkillError> {
        if !root.exists() {
            return Err(SkillError::RootNotFound(root.display().to_string()));
        }
        if root.join(SKILL_DOC).is_file() {
            self.insert_skill_dir(root)?;
        }
        if root.is_dir() {
            for entry in fs::read_dir(root).map_err(|e| SkillError::Io(e.to_string()))? {
                let entry = entry.map_err(|e| SkillError::Io(e.to_string()))?;
                let path = entry.path();
                if path.is_dir() && path.join(SKILL_DOC).is_file() {
                    self.insert_skill_dir(&path)?;
                }
            }
        }
        Ok(())
    }

    fn insert_skill_dir(&mut self, dir: &Path) -> Result<(), SkillError> {
        let id = dir
            .file_name()
            .and_then(|name| name.to_str())
            .ok_or_else(|| SkillError::InvalidPath(dir.display().to_string()))?
            .to_string();
        let doc =
            fs::read_to_string(dir.join(SKILL_DOC)).map_err(|e| SkillError::Io(e.to_string()))?;
        let (name, description) = parse_skill_doc(&id, &doc)?;
        self.skills.insert(
            id.clone(),
            SkillEntry {
                id,
                name,
                description,
                dir: dir.to_path_buf(),
            },
        );
        Ok(())
    }
}

#[derive(Debug, Deserialize)]
struct SkillFrontmatter {
    name: String,
    description: String,
}

impl SkillReadResult {
    pub fn into_value(self) -> serde_json::Value {
        json!({
            "skill_id": self.skill_id,
            "path": self.path,
            "content": self.content,
            "resources": self.resources,
        })
    }
}

fn parse_skill_doc(id: &str, doc: &str) -> Result<(String, String), SkillError> {
    let frontmatter = extract_frontmatter(id, doc)?;
    let metadata = serde_yaml::from_str::<SkillFrontmatter>(&frontmatter).map_err(|e| {
        SkillError::InvalidMetadata {
            skill_id: id.to_string(),
            message: e.to_string(),
        }
    })?;
    let name = metadata.name.trim();
    let description = metadata.description.trim();
    if name.is_empty() {
        return Err(SkillError::InvalidMetadata {
            skill_id: id.to_string(),
            message: "missing or empty `name`".to_string(),
        });
    }
    if description.is_empty() {
        return Err(SkillError::InvalidMetadata {
            skill_id: id.to_string(),
            message: "missing or empty `description`".to_string(),
        });
    }
    Ok((name.to_string(), description.to_string()))
}

fn extract_frontmatter(id: &str, doc: &str) -> Result<String, SkillError> {
    let doc = doc.strip_prefix('\u{feff}').unwrap_or(doc);
    let mut lines = doc.lines();
    match lines.next() {
        Some(line) if line.trim() == "---" => {}
        _ => {
            return Err(SkillError::InvalidMetadata {
                skill_id: id.to_string(),
                message: "`SKILL.md` must start with YAML frontmatter".to_string(),
            });
        }
    }

    let mut frontmatter = Vec::new();
    for line in lines {
        if line.trim() == "---" {
            return Ok(frontmatter.join("\n"));
        }
        frontmatter.push(line);
    }
    Err(SkillError::InvalidMetadata {
        skill_id: id.to_string(),
        message: "missing closing frontmatter delimiter".to_string(),
    })
}

fn resource_index(skill_dir: &Path) -> Result<Vec<SkillResourceIndexEntry>, SkillError> {
    let mut out = Vec::new();
    collect_resources(skill_dir, skill_dir, &mut out)?;
    out.sort_by(|a, b| a.path.cmp(&b.path));
    Ok(out)
}

fn collect_resources(
    base: &Path,
    dir: &Path,
    out: &mut Vec<SkillResourceIndexEntry>,
) -> Result<(), SkillError> {
    for entry in fs::read_dir(dir).map_err(|e| SkillError::Io(e.to_string()))? {
        let entry = entry.map_err(|e| SkillError::Io(e.to_string()))?;
        let path = entry.path();
        if path.is_dir() {
            collect_resources(base, &path, out)?;
            continue;
        }
        let rel = path
            .strip_prefix(base)
            .map_err(|e| SkillError::InvalidPath(e.to_string()))?;
        let rel = rel.to_string_lossy().replace('\\', "/");
        if rel == SKILL_DOC {
            continue;
        }
        out.push(SkillResourceIndexEntry {
            path: rel,
            description: None,
        });
    }
    Ok(())
}

fn validate_relative_path(path: &str) -> Result<(), SkillError> {
    let path = Path::new(path);
    if path.is_absolute() {
        return Err(SkillError::InvalidPath(path.display().to_string()));
    }
    for component in path.components() {
        if matches!(
            component,
            Component::ParentDir | Component::RootDir | Component::Prefix(_)
        ) {
            return Err(SkillError::InvalidPath(path.display().to_string()));
        }
    }
    Ok(())
}

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

    #[test]
    fn parses_required_yaml_frontmatter() {
        let doc = r#"---
name: "docs"
description: |
  Work with document files.
---

# Documents

main skill instructions
"#;

        let (name, description) = parse_skill_doc("docs", doc).unwrap();

        assert_eq!(name, "docs");
        assert_eq!(description, "Work with document files.");
    }

    #[test]
    fn rejects_skill_doc_without_frontmatter() {
        let err = parse_skill_doc("docs", "# Documents\n\nWork with document files").unwrap_err();

        assert!(matches!(
            err,
            SkillError::InvalidMetadata { skill_id, .. } if skill_id == "docs"
        ));
    }
}