cellstate_pipeline/pack/
skills.rs

1//! Agent Skills interoperability (SKILL.md emit/parse).
2//!
3//! Bridges CELLSTATE's Pack agents to the open Agent Skills standard (agentskills.io).
4//! Each `[agents.*]` entry in cstate.toml maps to a SKILL.md with YAML frontmatter.
5//!
6//! Re-export path: cellstate_pipeline::pack::skills::*
7
8use crate::compiler::CompiledConfig;
9use serde::{Deserialize, Serialize};
10use std::collections::BTreeMap;
11
12/// CELLSTATE's version stamp for emitted SkillPack payloads.
13pub const SKILL_PACK_SCHEMA_VERSION: &str = "cellstate.skills.pack.v1";
14
15/// A single Agent Skill in the open standard format.
16#[derive(Debug, Clone, Serialize, Deserialize)]
17pub struct AgentSkill {
18    /// Skill name (from agent key).
19    pub name: String,
20    /// Human-readable description (triggers skill selection).
21    pub description: String,
22    /// Skill version (from meta.version).
23    pub version: String,
24    /// Tags for categorization.
25    #[serde(default, skip_serializing_if = "Vec::is_empty")]
26    pub tags: Vec<String>,
27    /// Markdown body (the skill instructions).
28    pub instructions: String,
29    /// Bundled resource paths (scripts, references, assets).
30    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
31    pub resources: BTreeMap<String, SkillResourceKind>,
32}
33
34/// Kind of bundled resource in a skill directory.
35#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
36#[serde(rename_all = "snake_case")]
37pub enum SkillResourceKind {
38    Script,
39    Reference,
40    Asset,
41}
42
43/// Complete skill pack output (all agents as skills).
44#[derive(Debug, Clone, Serialize, Deserialize)]
45pub struct SkillPack {
46    /// CELLSTATE schema version for backwards compatibility tracking.
47    #[serde(default = "default_skill_pack_schema_version")]
48    pub schema_version: String,
49    /// Enabled skills emitted from the pack.
50    #[serde(default)]
51    pub skills: Vec<AgentSkill>,
52}
53
54fn default_skill_pack_schema_version() -> String {
55    SKILL_PACK_SCHEMA_VERSION.to_string()
56}
57
58/// Generate Agent Skills from a compiled CELLSTATE config.
59///
60/// Each enabled pack agent becomes a skill. The SKILL.md format:
61/// ```markdown
62/// ---
63/// name: agent-name
64/// description: What this skill does and when to use it.
65/// ---
66///
67/// Instructions from the agent's prompt_md content.
68/// ```
69pub fn generate_skill_pack(config: &CompiledConfig) -> SkillPack {
70    let version = config
71        .pack_meta_version
72        .as_deref()
73        .unwrap_or("1.0")
74        .to_string();
75
76    let skills: Vec<AgentSkill> = config
77        .pack_agents
78        .iter()
79        .filter(|agent| agent.enabled)
80        .map(|agent| {
81            let description = agent
82                .description
83                .clone()
84                .unwrap_or_else(|| format!("CELLSTATE agent: {}", agent.name));
85
86            // Map toolsets to resource references
87            let mut resources = BTreeMap::new();
88            for toolset in &agent.toolsets {
89                resources.insert(
90                    format!("toolsets/{}", toolset),
91                    SkillResourceKind::Reference,
92                );
93            }
94
95            AgentSkill {
96                name: agent.name.clone(),
97                description,
98                version: version.clone(),
99                tags: agent.tags.clone(),
100                instructions: format!(
101                    "<!-- Source: {} -->\n\n\
102                     Agent prompt loaded from: {}\n\n\
103                     Profile: {}\n\
104                     Token budget: {}\n\
105                     Toolsets: {}",
106                    agent.prompt_md,
107                    agent.prompt_md,
108                    agent.profile,
109                    agent
110                        .token_budget
111                        .map(|b| b.to_string())
112                        .unwrap_or_else(|| "default".to_string()),
113                    if agent.toolsets.is_empty() {
114                        "none".to_string()
115                    } else {
116                        agent.toolsets.join(", ")
117                    },
118                ),
119                resources,
120            }
121        })
122        .collect();
123
124    SkillPack {
125        schema_version: SKILL_PACK_SCHEMA_VERSION.to_string(),
126        skills,
127    }
128}
129
130/// Render a single AgentSkill as a SKILL.md string (YAML frontmatter + Markdown body).
131pub fn render_skill_md(skill: &AgentSkill) -> String {
132    let mut out = String::new();
133    out.push_str("---\n");
134    out.push_str(&format!("name: {}\n", skill.name));
135    // Escape description for YAML if it contains special chars
136    if skill.description.contains(':')
137        || skill.description.contains('#')
138        || skill.description.contains('\n')
139    {
140        out.push_str(&format!(
141            "description: |\n  {}\n",
142            skill.description.replace('\n', "\n  ")
143        ));
144    } else {
145        out.push_str(&format!("description: {}\n", skill.description));
146    }
147    if !skill.version.is_empty() {
148        out.push_str(&format!("version: {}\n", skill.version));
149    }
150    if !skill.tags.is_empty() {
151        out.push_str("tags:\n");
152        for tag in &skill.tags {
153            out.push_str(&format!("  - {}\n", tag));
154        }
155    }
156    out.push_str("---\n\n");
157    out.push_str(&skill.instructions);
158    if !skill.instructions.ends_with('\n') {
159        out.push('\n');
160    }
161    out
162}
163
164/// Parse a SKILL.md string into an AgentSkill.
165///
166/// Expected format:
167/// ```text
168/// ---
169/// name: skill-name
170/// description: What this skill does
171/// ---
172///
173/// Markdown instructions here.
174/// ```
175pub fn parse_skill_md(content: &str) -> Result<AgentSkill, SkillParseError> {
176    let content = content.trim();
177    if !content.starts_with("---") {
178        return Err(SkillParseError::MissingFrontmatter);
179    }
180
181    // Find the closing ---
182    let rest = &content[3..];
183    let end = rest
184        .find("\n---")
185        .ok_or(SkillParseError::MissingFrontmatter)?;
186
187    let frontmatter = &rest[..end].trim();
188    let body = rest[end + 4..].trim();
189
190    // Parse YAML frontmatter
191    let meta: SkillFrontmatter = serde_yaml::from_str(frontmatter)
192        .map_err(|e| SkillParseError::InvalidYaml(e.to_string()))?;
193
194    Ok(AgentSkill {
195        name: meta.name.ok_or(SkillParseError::MissingName)?,
196        description: meta
197            .description
198            .ok_or(SkillParseError::MissingDescription)?,
199        version: meta.version.unwrap_or_else(|| "1.0".to_string()),
200        tags: meta.tags.unwrap_or_default(),
201        instructions: body.to_string(),
202        resources: BTreeMap::new(),
203    })
204}
205
206#[derive(Debug, Deserialize)]
207struct SkillFrontmatter {
208    name: Option<String>,
209    description: Option<String>,
210    version: Option<String>,
211    tags: Option<Vec<String>>,
212}
213
214/// Errors from parsing SKILL.md files.
215#[derive(Debug, Clone)]
216pub enum SkillParseError {
217    MissingFrontmatter,
218    InvalidYaml(String),
219    MissingName,
220    MissingDescription,
221}
222
223impl std::fmt::Display for SkillParseError {
224    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
225        match self {
226            SkillParseError::MissingFrontmatter => {
227                write!(f, "SKILL.md must start with YAML frontmatter (---)")
228            }
229            SkillParseError::InvalidYaml(e) => write!(f, "Invalid YAML frontmatter: {}", e),
230            SkillParseError::MissingName => write!(f, "SKILL.md frontmatter missing 'name' field"),
231            SkillParseError::MissingDescription => {
232                write!(f, "SKILL.md frontmatter missing 'description' field")
233            }
234        }
235    }
236}
237
238impl std::error::Error for SkillParseError {}
239
240#[cfg(test)]
241mod tests {
242    use super::*;
243
244    #[test]
245    fn test_render_skill_md_basic() {
246        let skill = AgentSkill {
247            name: "researcher".to_string(),
248            description: "Research agent for web and document analysis".to_string(),
249            version: "1.0".to_string(),
250            tags: vec!["research".to_string(), "analysis".to_string()],
251            instructions: "You are a research agent.\n\nSearch the web and analyze documents."
252                .to_string(),
253            resources: BTreeMap::new(),
254        };
255
256        let md = render_skill_md(&skill);
257
258        assert!(md.starts_with("---\n"));
259        assert!(md.contains("name: researcher"));
260        assert!(md.contains("description: Research agent"));
261        assert!(md.contains("version: 1.0"));
262        assert!(md.contains("  - research"));
263        assert!(md.contains("  - analysis"));
264        assert!(md.contains("You are a research agent."));
265    }
266
267    #[test]
268    fn test_parse_skill_md_roundtrip() {
269        let original = AgentSkill {
270            name: "support".to_string(),
271            description: "Customer support agent".to_string(),
272            version: "2.0".to_string(),
273            tags: vec!["support".to_string()],
274            instructions: "Help customers with their issues.".to_string(),
275            resources: BTreeMap::new(),
276        };
277
278        let rendered = render_skill_md(&original);
279        let parsed = parse_skill_md(&rendered).expect("should parse");
280
281        assert_eq!(parsed.name, original.name);
282        assert_eq!(parsed.description, original.description);
283        assert_eq!(parsed.version, original.version);
284        assert_eq!(parsed.tags, original.tags);
285        assert_eq!(parsed.instructions, original.instructions);
286    }
287
288    #[test]
289    fn test_parse_skill_md_missing_frontmatter() {
290        let result = parse_skill_md("No frontmatter here");
291        assert!(result.is_err());
292    }
293
294    #[test]
295    fn test_parse_skill_md_missing_name() {
296        let md = "---\ndescription: test\n---\n\nBody";
297        let result = parse_skill_md(md);
298        assert!(matches!(result, Err(SkillParseError::MissingName)));
299    }
300
301    #[test]
302    fn test_parse_skill_md_missing_description() {
303        let md = "---\nname: test\n---\n\nBody";
304        let result = parse_skill_md(md);
305        assert!(matches!(result, Err(SkillParseError::MissingDescription)));
306    }
307
308    #[test]
309    fn test_generate_skill_pack_empty_config() {
310        let config = CompiledConfig::default();
311        let pack = generate_skill_pack(&config);
312        assert_eq!(pack.schema_version, SKILL_PACK_SCHEMA_VERSION);
313        assert!(pack.skills.is_empty());
314    }
315
316    #[test]
317    fn test_skill_pack_json_includes_schema_version() {
318        let config = CompiledConfig::default();
319        let pack = generate_skill_pack(&config);
320        let json = serde_json::to_string(&pack).expect("serialize skill pack");
321        assert!(json.contains("schema_version"));
322        assert!(json.contains(SKILL_PACK_SCHEMA_VERSION));
323    }
324}