rho-coding-agent 0.6.0

A lightweight agent harness inspired by Pi
use async_trait::async_trait;
use serde::Deserialize;

use crate::{
    skills,
    tool::{truncate, Tool, ToolContext, ToolError, ToolResult, ToolSpec},
};

pub struct Skill;

#[derive(Deserialize)]
struct Args {
    name: String,
}

#[async_trait]
impl Tool for Skill {
    fn spec(&self) -> ToolSpec {
        ToolSpec {
            name: "skill".into(),
            description: "Load the full SKILL.md content for an available skill by name.".into(),
            input_schema: serde_json::json!({
                "type": "object",
                "properties": {
                    "name": {
                        "type": "string",
                        "description": "The skill name to load"
                    }
                },
                "required": ["name"],
                "additionalProperties": false
            }),
        }
    }

    fn display_style(&self) -> crate::tool::ToolDisplayStyle {
        crate::tool::ToolDisplayStyle::skill()
    }

    fn display_content(&self, args: &serde_json::Value, _ctx: &ToolContext) -> Option<String> {
        args.get("name")
            .and_then(|name| name.as_str())
            .map(|name| format!("skill {name}"))
    }

    fn display_lines(
        &self,
        args: &serde_json::Value,
        ctx: &ToolContext,
        result: &ToolResult,
    ) -> Vec<String> {
        vec![self
            .display_content(args, ctx)
            .unwrap_or_else(|| result.content.clone())]
    }

    async fn call(
        &self,
        args: serde_json::Value,
        ctx: ToolContext,
        id: String,
    ) -> Result<ToolResult, ToolError> {
        let args: Args = serde_json::from_value(args)?;
        let skill = skills::discover(&ctx.cwd)
            .into_iter()
            .find(|skill| skill.name == args.name)
            .ok_or_else(|| ToolError::Message(format!("unknown skill: {}", args.name)))?;

        Ok(ToolResult {
            id,
            ok: true,
            content: truncate(skill.contents, ctx.max_output_bytes),
        })
    }
}

#[cfg(test)]
mod tests {
    use tempfile::TempDir;

    use super::*;

    #[tokio::test]
    async fn loads_skill_contents() {
        let root = TempDir::new().unwrap();
        let skill_dir = root.path().join(".agents/skills/test-skill");
        std::fs::create_dir_all(&skill_dir).unwrap();
        std::fs::write(
            skill_dir.join("SKILL.md"),
            "---\nname: test-skill\ndescription: test desc\n---\nbody contents\n",
        )
        .unwrap();

        let result = Skill
            .call(
                serde_json::json!({"name": "test-skill"}),
                ToolContext {
                    cwd: root.path().to_path_buf(),
                    max_output_bytes: 12000,
                },
                "call_1".into(),
            )
            .await
            .unwrap();

        assert!(result.ok);
        assert!(result.content.contains("body contents"));
    }

    #[tokio::test]
    async fn rejects_unknown_skill_name() {
        let root = TempDir::new().unwrap();

        let err = Skill
            .call(
                serde_json::json!({"name": "missing-skill"}),
                ToolContext {
                    cwd: root.path().to_path_buf(),
                    max_output_bytes: 12000,
                },
                "call_1".into(),
            )
            .await
            .unwrap_err();

        assert_eq!(err.to_string(), "unknown skill: missing-skill");
    }

    #[tokio::test]
    async fn truncates_loaded_skill_contents() {
        let root = TempDir::new().unwrap();
        let skill_dir = root.path().join(".agents/skills/short-skill");
        std::fs::create_dir_all(&skill_dir).unwrap();
        std::fs::write(
            skill_dir.join("SKILL.md"),
            "---\nname: short-skill\ndescription: short desc\n---\nlong body contents\n",
        )
        .unwrap();

        let result = Skill
            .call(
                serde_json::json!({"name": "short-skill"}),
                ToolContext {
                    cwd: root.path().to_path_buf(),
                    max_output_bytes: 16,
                },
                "call_1".into(),
            )
            .await
            .unwrap();

        assert!(result.content.ends_with("\n[truncated]"));
    }
}