tirea_extension_skills/
types.rs

1use async_trait::async_trait;
2use serde::{Deserialize, Serialize};
3use std::collections::HashMap;
4use std::path::PathBuf;
5use std::sync::Arc;
6use tirea_state::{GSet, State};
7
8#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
9pub struct SkillMeta {
10    pub id: String,
11    pub name: String,
12    pub description: String,
13    pub allowed_tools: Vec<String>,
14}
15
16#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
17pub struct SkillWarning {
18    pub path: PathBuf,
19    pub reason: String,
20}
21
22#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
23#[serde(rename_all = "snake_case")]
24pub enum SkillResourceKind {
25    Reference,
26    Asset,
27}
28
29impl SkillResourceKind {
30    pub fn as_str(self) -> &'static str {
31        match self {
32            Self::Reference => "reference",
33            Self::Asset => "asset",
34        }
35    }
36}
37
38#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
39pub struct LoadedReference {
40    pub skill: String,
41    pub path: String,
42    pub sha256: String,
43    pub truncated: bool,
44    pub content: String,
45    pub bytes: u64,
46}
47
48#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
49pub struct ScriptResult {
50    pub skill: String,
51    pub script: String,
52    pub sha256: String,
53    pub truncated_stdout: bool,
54    pub truncated_stderr: bool,
55    pub exit_code: i32,
56    pub stdout: String,
57    pub stderr: String,
58}
59
60#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
61pub struct LoadedAsset {
62    pub skill: String,
63    pub path: String,
64    pub sha256: String,
65    pub truncated: bool,
66    pub bytes: u64,
67    pub media_type: Option<String>,
68    pub encoding: String,
69    pub content: String,
70}
71
72/// Persisted skill state — only the CRDT active set.
73///
74/// Material content (instructions, references, scripts, assets) is delivered
75/// inline via `ToolResult` / `with_user_message` and never stored in state,
76/// avoiding parallel-branch conflicts on HashMap writes.
77#[derive(Debug, Clone, Default, Serialize, Deserialize, State)]
78#[tirea(path = "skills", action = "SkillStateAction", scope = "thread")]
79pub struct SkillState {
80    /// Activated skill IDs (grow-only set for conflict-free parallel merges).
81    #[serde(default)]
82    #[tirea(lattice)]
83    pub active: GSet<String>,
84}
85
86/// Action type for [`SkillState`] reducer.
87#[derive(Serialize, Deserialize)]
88pub enum SkillStateAction {
89    /// Mark a skill as activated (insert into the grow-only set).
90    Activate(String),
91}
92
93impl SkillState {
94    fn reduce(&mut self, action: SkillStateAction) {
95        match action {
96            SkillStateAction::Activate(id) => {
97                self.active.insert(id);
98            }
99        }
100    }
101}
102
103/// Build a stable map key for skill materials.
104pub fn material_key(skill_id: &str, relative_path: &str) -> String {
105    format!("{skill_id}:{relative_path}")
106}
107
108#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
109#[serde(tag = "kind", content = "resource", rename_all = "snake_case")]
110pub enum SkillResource {
111    Reference(LoadedReference),
112    Asset(LoadedAsset),
113}
114
115#[derive(Debug, thiserror::Error)]
116pub enum SkillMaterializeError {
117    #[error("invalid relative path: {0}")]
118    InvalidPath(String),
119
120    #[error("path is outside skill root")]
121    PathEscapesRoot,
122
123    #[error("unsupported path (expected under {0})")]
124    UnsupportedPath(String),
125
126    #[error("io error: {0}")]
127    Io(String),
128
129    #[error("script runtime not supported for: {0}")]
130    UnsupportedRuntime(String),
131
132    #[error("script timed out after {0}s")]
133    Timeout(u64),
134
135    #[error("invalid script arguments: {0}")]
136    InvalidScriptArgs(String),
137}
138
139#[derive(Debug, thiserror::Error)]
140pub enum SkillError {
141    #[error("unknown skill: {0}")]
142    UnknownSkill(String),
143
144    #[error("invalid SKILL.md: {0}")]
145    InvalidSkillMd(String),
146
147    #[error("materialize error: {0}")]
148    Materialize(#[from] SkillMaterializeError),
149
150    #[error("io error: {0}")]
151    Io(String),
152
153    #[error("duplicate skill id: {0}")]
154    DuplicateSkillId(String),
155
156    #[error("unsupported operation: {0}")]
157    Unsupported(String),
158}
159
160/// A single skill with its own IO capabilities.
161///
162/// Each implementation encapsulates how to read instructions, load resources,
163/// and run scripts. This replaces the old `SkillRegistry` trait where a single
164/// registry handled all skills and required `skill_id` parameters.
165#[async_trait]
166pub trait Skill: Send + Sync + std::fmt::Debug {
167    /// Metadata for this skill (id, name, description, allowed_tools).
168    fn meta(&self) -> &SkillMeta;
169
170    /// Read the raw SKILL.md content.
171    async fn read_instructions(&self) -> Result<String, SkillError>;
172
173    /// Load a resource (reference or asset) by relative path.
174    async fn load_resource(
175        &self,
176        kind: SkillResourceKind,
177        path: &str,
178    ) -> Result<SkillResource, SkillError>;
179
180    /// Run a script by relative path with arguments.
181    async fn run_script(&self, script: &str, args: &[String]) -> Result<ScriptResult, SkillError>;
182}
183
184/// Collect skills into a map, failing on duplicate IDs.
185pub fn collect_skills(
186    skills: Vec<Arc<dyn Skill>>,
187) -> Result<HashMap<String, Arc<dyn Skill>>, SkillError> {
188    let mut map: HashMap<String, Arc<dyn Skill>> = HashMap::new();
189    for skill in skills {
190        let id = skill.meta().id.clone();
191        if map.contains_key(&id) {
192            return Err(SkillError::DuplicateSkillId(id));
193        }
194        map.insert(id, skill);
195    }
196    Ok(map)
197}
198
199#[cfg(test)]
200mod tests {
201    use super::*;
202
203    #[test]
204    fn skill_error_preserves_materialize_variant() {
205        let err: SkillError = SkillMaterializeError::PathEscapesRoot.into();
206        assert!(matches!(
207            err,
208            SkillError::Materialize(SkillMaterializeError::PathEscapesRoot)
209        ));
210    }
211
212    #[test]
213    fn collect_skills_rejects_duplicates() {
214        #[derive(Debug)]
215        struct MockSkill(SkillMeta);
216
217        #[async_trait]
218        impl Skill for MockSkill {
219            fn meta(&self) -> &SkillMeta {
220                &self.0
221            }
222            async fn read_instructions(&self) -> Result<String, SkillError> {
223                Ok(String::new())
224            }
225            async fn load_resource(
226                &self,
227                _kind: SkillResourceKind,
228                _path: &str,
229            ) -> Result<SkillResource, SkillError> {
230                Err(SkillError::Unsupported("mock".into()))
231            }
232            async fn run_script(
233                &self,
234                _script: &str,
235                _args: &[String],
236            ) -> Result<ScriptResult, SkillError> {
237                Err(SkillError::Unsupported("mock".into()))
238            }
239        }
240
241        fn meta(id: &str) -> SkillMeta {
242            SkillMeta {
243                id: id.to_string(),
244                name: id.to_string(),
245                description: format!("{id} skill"),
246                allowed_tools: Vec::new(),
247            }
248        }
249
250        let skills: Vec<Arc<dyn Skill>> = vec![
251            Arc::new(MockSkill(meta("a"))),
252            Arc::new(MockSkill(meta("a"))),
253        ];
254        let err = collect_skills(skills).unwrap_err();
255        assert!(matches!(err, SkillError::DuplicateSkillId(ref id) if id == "a"));
256    }
257
258    #[test]
259    fn collect_skills_succeeds_for_unique_ids() {
260        #[derive(Debug)]
261        struct MockSkill(SkillMeta);
262
263        #[async_trait]
264        impl Skill for MockSkill {
265            fn meta(&self) -> &SkillMeta {
266                &self.0
267            }
268            async fn read_instructions(&self) -> Result<String, SkillError> {
269                Ok(String::new())
270            }
271            async fn load_resource(
272                &self,
273                _kind: SkillResourceKind,
274                _path: &str,
275            ) -> Result<SkillResource, SkillError> {
276                Err(SkillError::Unsupported("mock".into()))
277            }
278            async fn run_script(
279                &self,
280                _script: &str,
281                _args: &[String],
282            ) -> Result<ScriptResult, SkillError> {
283                Err(SkillError::Unsupported("mock".into()))
284            }
285        }
286
287        fn meta(id: &str) -> SkillMeta {
288            SkillMeta {
289                id: id.to_string(),
290                name: id.to_string(),
291                description: format!("{id} skill"),
292                allowed_tools: Vec::new(),
293            }
294        }
295
296        let skills: Vec<Arc<dyn Skill>> = vec![
297            Arc::new(MockSkill(meta("alpha"))),
298            Arc::new(MockSkill(meta("beta"))),
299        ];
300        let map = collect_skills(skills).unwrap();
301        assert_eq!(map.len(), 2);
302        assert!(map.contains_key("alpha"));
303        assert!(map.contains_key("beta"));
304    }
305}