tirea_extension_skills/
tools.rs

1use crate::skill_md::{parse_allowed_tool_token, parse_skill_md};
2use crate::{
3    Skill, SkillError, SkillMaterializeError, SkillRegistry, SkillResource, SkillResourceKind,
4    SkillState, SkillStateAction, SKILL_ACTIVATE_TOOL_ID, SKILL_LOAD_RESOURCE_TOOL_ID,
5    SKILL_SCRIPT_TOOL_ID,
6};
7use serde_json::{json, Value};
8use std::collections::{HashMap, HashSet};
9use std::path::{Component, Path};
10use std::sync::Arc;
11use tirea_contract::runtime::phase::AfterToolExecuteAction;
12use tirea_contract::runtime::state::AnyStateAction;
13use tirea_contract::runtime::tool_call::{
14    Tool, ToolCallContext, ToolDescriptor, ToolError, ToolExecutionEffect, ToolResult, ToolStatus,
15};
16use tirea_contract::scope::{is_scope_allowed, ScopeDomain};
17use tirea_extension_permission::{
18    permission_override_action, PermissionAction, ToolPermissionBehavior,
19};
20use tracing::{debug, warn};
21
22#[derive(Debug)]
23struct ToolArgError {
24    code: &'static str,
25    message: String,
26}
27
28type ToolArgResult<T> = Result<T, ToolArgError>;
29
30impl ToolArgError {
31    fn new(code: &'static str, message: impl Into<String>) -> Self {
32        Self {
33            code,
34            message: message.into(),
35        }
36    }
37
38    fn into_tool_result(self, tool_name: &str) -> ToolResult {
39        tool_error(tool_name, self.code, self.message)
40    }
41}
42
43#[derive(Clone)]
44pub struct SkillActivateTool {
45    registry: Arc<dyn SkillRegistry>,
46}
47
48impl std::fmt::Debug for SkillActivateTool {
49    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
50        f.debug_struct("SkillActivateTool").finish_non_exhaustive()
51    }
52}
53
54impl SkillActivateTool {
55    pub fn new(registry: Arc<dyn SkillRegistry>) -> Self {
56        Self { registry }
57    }
58
59    fn resolve(&self, key: &str) -> Option<Arc<dyn Skill>> {
60        self.registry.get(key.trim())
61    }
62
63    async fn execute_effect_impl(
64        &self,
65        args: Value,
66        ctx: &ToolCallContext<'_>,
67    ) -> Result<ToolExecutionEffect, ToolError> {
68        let key = match required_string_arg(&args, "skill") {
69            Ok(v) => v,
70            Err(err) => {
71                return Ok(ToolExecutionEffect::from(
72                    err.into_tool_result(SKILL_ACTIVATE_TOOL_ID),
73                ));
74            }
75        };
76
77        let skill = self.resolve(&key).ok_or_else(|| {
78            tool_error(
79                SKILL_ACTIVATE_TOOL_ID,
80                "unknown_skill",
81                format!("Unknown skill: {key}"),
82            )
83        });
84        let skill = match skill {
85            Ok(s) => s,
86            Err(r) => return Ok(ToolExecutionEffect::from(r)),
87        };
88        let meta = skill.meta();
89        if !is_scope_allowed(Some(ctx.run_policy()), &meta.id, ScopeDomain::Skill) {
90            return Ok(ToolExecutionEffect::from(tool_error(
91                SKILL_ACTIVATE_TOOL_ID,
92                "forbidden_skill",
93                format!("Skill '{}' is not allowed by current policy", meta.id),
94            )));
95        }
96
97        let raw = skill
98            .read_instructions()
99            .await
100            .map_err(|e| map_skill_error(SKILL_ACTIVATE_TOOL_ID, e));
101        let raw = match raw {
102            Ok(v) => v,
103            Err(r) => return Ok(ToolExecutionEffect::from(r)),
104        };
105
106        let doc = parse_skill_md(&raw).map_err(|e| {
107            tool_error(
108                SKILL_ACTIVATE_TOOL_ID,
109                "invalid_skill_md",
110                format!("invalid SKILL.md: {e}"),
111            )
112        });
113        let doc = match doc {
114            Ok(v) => v,
115            Err(r) => return Ok(ToolExecutionEffect::from(r)),
116        };
117        let instructions = doc.body;
118        let instruction_for_message = instructions.clone();
119
120        let activate_action =
121            AnyStateAction::new::<SkillState>(SkillStateAction::Activate(meta.id.clone()));
122        let mut applied_tool_ids: Vec<String> = Vec::new();
123        let mut skipped_tokens: Vec<String> = Vec::new();
124        let mut seen: HashSet<String> = HashSet::new();
125        let mut permission_actions = Vec::new();
126        for token in meta.allowed_tools.iter() {
127            match parse_allowed_tool_token(token.clone()) {
128                Ok(parsed) if parsed.scope.is_none() => {
129                    if seen.insert(parsed.tool_id.clone()) {
130                        permission_actions.push(PermissionAction::SetTool {
131                            tool_id: parsed.tool_id.clone(),
132                            behavior: ToolPermissionBehavior::Allow,
133                        });
134                        applied_tool_ids.push(parsed.tool_id);
135                    }
136                }
137                Ok(parsed) => {
138                    skipped_tokens.push(parsed.raw);
139                }
140                Err(_) => {
141                    skipped_tokens.push(token.clone());
142                }
143            }
144        }
145
146        debug!(
147            skill_id = %meta.id,
148            call_id = %ctx.call_id(),
149            declared_allowed_tools = meta.allowed_tools.len(),
150            applied_allowed_tools = applied_tool_ids.len(),
151            skipped_allowed_tools = skipped_tokens.len(),
152            "skill activated"
153        );
154
155        if !skipped_tokens.is_empty() {
156            warn!(
157                skill_id = %meta.id,
158                skipped_tokens = ?skipped_tokens,
159                "skipped scoped/unsupported allowed-tools tokens"
160            );
161        }
162
163        let result = ToolResult {
164            tool_name: SKILL_ACTIVATE_TOOL_ID.to_string(),
165            status: ToolStatus::Success,
166            data: json!({
167                "activated": true,
168                "skill_id": meta.id,
169            }),
170            message: Some(format!("Launching skill: {}", meta.id)),
171            metadata: HashMap::new(),
172            suspension: None,
173        };
174
175        let mut effect = ToolExecutionEffect::from(result).with_action(activate_action);
176        for action in permission_actions {
177            effect = effect.with_action(permission_override_action(action));
178        }
179        if !instruction_for_message.trim().is_empty() {
180            effect = effect.with_action(AfterToolExecuteAction::AddUserMessage(
181                instruction_for_message,
182            ));
183        }
184        Ok(effect)
185    }
186}
187
188#[async_trait::async_trait]
189impl Tool for SkillActivateTool {
190    fn descriptor(&self) -> ToolDescriptor {
191        ToolDescriptor::new(
192            SKILL_ACTIVATE_TOOL_ID,
193            "Skill",
194            "Activate a skill and persist its instructions",
195        )
196        .with_parameters(json!({
197            "type": "object",
198            "properties": {
199                "skill": { "type": "string", "description": "Skill id or name" },
200                "args": { "type": "string", "description": "Optional arguments for the skill" }
201            },
202            "required": ["skill"]
203        }))
204    }
205
206    async fn execute(
207        &self,
208        args: Value,
209        ctx: &ToolCallContext<'_>,
210    ) -> Result<ToolResult, ToolError> {
211        Ok(self.execute_effect_impl(args, ctx).await?.result)
212    }
213
214    async fn execute_effect(
215        &self,
216        args: Value,
217        ctx: &ToolCallContext<'_>,
218    ) -> Result<ToolExecutionEffect, ToolError> {
219        self.execute_effect_impl(args, ctx).await
220    }
221}
222
223#[derive(Clone)]
224pub struct LoadSkillResourceTool {
225    registry: Arc<dyn SkillRegistry>,
226}
227
228impl std::fmt::Debug for LoadSkillResourceTool {
229    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
230        f.debug_struct("LoadSkillResourceTool")
231            .finish_non_exhaustive()
232    }
233}
234
235impl LoadSkillResourceTool {
236    pub fn new(registry: Arc<dyn SkillRegistry>) -> Self {
237        Self { registry }
238    }
239
240    fn resolve(&self, key: &str) -> Option<Arc<dyn Skill>> {
241        self.registry.get(key.trim())
242    }
243}
244
245#[async_trait::async_trait]
246impl Tool for LoadSkillResourceTool {
247    fn descriptor(&self) -> ToolDescriptor {
248        ToolDescriptor::new(
249            SKILL_LOAD_RESOURCE_TOOL_ID,
250            "Load Skill Resource",
251            "Load a skill resource file (references/** or assets/**) into persisted state",
252        )
253        .with_parameters(json!({
254            "type": "object",
255            "properties": {
256                "skill": { "type": "string", "description": "Skill id or name" },
257                "path": { "type": "string", "description": "Relative path under references/** or assets/**" },
258                "kind": { "type": "string", "enum": ["reference", "asset"], "description": "Optional resource kind; when omitted, inferred from path prefix" }
259            },
260            "required": ["skill", "path"]
261        }))
262    }
263
264    async fn execute(
265        &self,
266        args: Value,
267        ctx: &ToolCallContext<'_>,
268    ) -> Result<ToolResult, ToolError> {
269        let tool_name = SKILL_LOAD_RESOURCE_TOOL_ID;
270        let key = match required_string_arg(&args, "skill") {
271            Ok(v) => v,
272            Err(err) => return Ok(err.into_tool_result(tool_name)),
273        };
274        let path = match required_string_arg(&args, "path") {
275            Ok(v) => v,
276            Err(err) => return Ok(err.into_tool_result(tool_name)),
277        };
278        let kind = match parse_resource_kind(args.get("kind"), &path) {
279            Ok(v) => v,
280            Err(err) => return Ok(err.into_tool_result(tool_name)),
281        };
282
283        let skill = self
284            .resolve(&key)
285            .ok_or_else(|| tool_error(tool_name, "unknown_skill", format!("Unknown skill: {key}")));
286        let skill = match skill {
287            Ok(v) => v,
288            Err(r) => return Ok(r),
289        };
290        let meta = skill.meta();
291        if !is_scope_allowed(Some(ctx.run_policy()), &meta.id, ScopeDomain::Skill) {
292            return Ok(tool_error(
293                tool_name,
294                "forbidden_skill",
295                format!("Skill '{}' is not allowed by current policy", meta.id),
296            ));
297        }
298
299        let resource = skill
300            .load_resource(kind, &path)
301            .await
302            .map_err(|e| map_skill_error(tool_name, e));
303        let resource = match resource {
304            Ok(v) => v,
305            Err(r) => return Ok(r),
306        };
307
308        match resource {
309            SkillResource::Reference(mat) => {
310                debug!(
311                    call_id = %ctx.call_id(),
312                    skill_id = %meta.id,
313                    kind = kind.as_str(),
314                    path = %mat.path,
315                    bytes = mat.bytes,
316                    truncated = mat.truncated,
317                    "loaded skill resource"
318                );
319
320                Ok(ToolResult::success(
321                    tool_name,
322                    json!({
323                        "loaded": true,
324                        "skill_id": meta.id,
325                        "kind": kind.as_str(),
326                        "path": mat.path,
327                        "bytes": mat.bytes,
328                        "truncated": mat.truncated,
329                        "content": mat.content,
330                    }),
331                ))
332            }
333            SkillResource::Asset(asset) => {
334                debug!(
335                    call_id = %ctx.call_id(),
336                    skill_id = %meta.id,
337                    kind = kind.as_str(),
338                    path = %asset.path,
339                    bytes = asset.bytes,
340                    truncated = asset.truncated,
341                    media_type = asset.media_type.as_deref().unwrap_or("application/octet-stream"),
342                    "loaded skill resource"
343                );
344
345                Ok(ToolResult::success(
346                    tool_name,
347                    json!({
348                        "loaded": true,
349                        "skill_id": meta.id,
350                        "kind": kind.as_str(),
351                        "path": asset.path,
352                        "bytes": asset.bytes,
353                        "truncated": asset.truncated,
354                        "media_type": asset.media_type,
355                        "encoding": asset.encoding,
356                        "content": asset.content,
357                    }),
358                ))
359            }
360        }
361    }
362}
363
364#[derive(Clone)]
365pub struct SkillScriptTool {
366    registry: Arc<dyn SkillRegistry>,
367}
368
369impl std::fmt::Debug for SkillScriptTool {
370    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
371        f.debug_struct("SkillScriptTool").finish_non_exhaustive()
372    }
373}
374
375impl SkillScriptTool {
376    pub fn new(registry: Arc<dyn SkillRegistry>) -> Self {
377        Self { registry }
378    }
379
380    fn resolve(&self, key: &str) -> Option<Arc<dyn Skill>> {
381        self.registry.get(key.trim())
382    }
383}
384
385#[async_trait::async_trait]
386impl Tool for SkillScriptTool {
387    fn descriptor(&self) -> ToolDescriptor {
388        ToolDescriptor::new(
389            SKILL_SCRIPT_TOOL_ID,
390            "Skill Script",
391            "Run a skill script (scripts/**) and persist its result",
392        )
393        .with_parameters(json!({
394            "type": "object",
395            "properties": {
396                "skill": { "type": "string", "description": "Skill id or name" },
397                "script": { "type": "string", "description": "Relative path under scripts/** (e.g. scripts/run.sh)" },
398                "args": { "type": "array", "items": { "type": "string" }, "description": "Optional script arguments" }
399            },
400            "required": ["skill", "script"]
401        }))
402    }
403
404    async fn execute(
405        &self,
406        args: Value,
407        ctx: &ToolCallContext<'_>,
408    ) -> Result<ToolResult, ToolError> {
409        let key = match required_string_arg(&args, "skill") {
410            Ok(v) => v,
411            Err(err) => return Ok(err.into_tool_result(SKILL_SCRIPT_TOOL_ID)),
412        };
413        let script = match required_string_arg(&args, "script") {
414            Ok(v) => v,
415            Err(err) => return Ok(err.into_tool_result(SKILL_SCRIPT_TOOL_ID)),
416        };
417        let argv: Vec<String> = args
418            .get("args")
419            .and_then(|v| v.as_array())
420            .map(|a| {
421                a.iter()
422                    .filter_map(|v| v.as_str().map(|s| s.to_string()))
423                    .collect()
424            })
425            .unwrap_or_default();
426
427        let skill = self.resolve(&key).ok_or_else(|| {
428            tool_error(
429                SKILL_SCRIPT_TOOL_ID,
430                "unknown_skill",
431                format!("Unknown skill: {key}"),
432            )
433        });
434        let skill = match skill {
435            Ok(v) => v,
436            Err(r) => return Ok(r),
437        };
438        let meta = skill.meta();
439        if !is_scope_allowed(Some(ctx.run_policy()), &meta.id, ScopeDomain::Skill) {
440            return Ok(tool_error(
441                SKILL_SCRIPT_TOOL_ID,
442                "forbidden_skill",
443                format!("Skill '{}' is not allowed by current policy", meta.id),
444            ));
445        }
446
447        let res = skill
448            .run_script(&script, &argv)
449            .await
450            .map_err(|e| map_skill_error(SKILL_SCRIPT_TOOL_ID, e));
451        let res = match res {
452            Ok(v) => v,
453            Err(r) => return Ok(r),
454        };
455
456        debug!(
457            call_id = %ctx.call_id(),
458            skill_id = %meta.id,
459            script = %res.script,
460            exit_code = res.exit_code,
461            stdout_truncated = res.truncated_stdout,
462            stderr_truncated = res.truncated_stderr,
463            "executed skill script"
464        );
465
466        Ok(ToolResult::success(
467            SKILL_SCRIPT_TOOL_ID,
468            json!({
469                "ok": res.exit_code == 0,
470                "skill_id": meta.id,
471                "script": res.script,
472                "exit_code": res.exit_code,
473                "stdout_truncated": res.truncated_stdout,
474                "stderr_truncated": res.truncated_stderr,
475                "stdout": res.stdout,
476                "stderr": res.stderr,
477            }),
478        ))
479    }
480}
481
482fn required_string_arg(args: &Value, key: &str) -> ToolArgResult<String> {
483    let value = args.get(key).and_then(|v| v.as_str()).map(str::trim);
484    match value {
485        Some(v) if !v.is_empty() => Ok(v.to_string()),
486        _ => Err(ToolArgError::new(
487            "invalid_arguments",
488            format!("missing '{key}'"),
489        )),
490    }
491}
492
493fn parse_resource_kind(kind: Option<&Value>, path: &str) -> ToolArgResult<SkillResourceKind> {
494    let from_kind = kind.and_then(|v| v.as_str()).map(str::trim);
495
496    if is_obviously_invalid_relative_path(path) {
497        return Err(ToolArgError::new("invalid_path", "invalid relative path"));
498    }
499
500    let from_path = if path.starts_with("references/") {
501        Some(SkillResourceKind::Reference)
502    } else if path.starts_with("assets/") {
503        Some(SkillResourceKind::Asset)
504    } else {
505        None
506    };
507
508    let parsed_kind = match from_kind {
509        Some("reference") => Some(SkillResourceKind::Reference),
510        Some("asset") => Some(SkillResourceKind::Asset),
511        Some(other) => {
512            return Err(ToolArgError::new(
513                "invalid_arguments",
514                format!("invalid 'kind': {other}"),
515            ));
516        }
517        None => None,
518    };
519
520    let Some(kind) = parsed_kind.or(from_path) else {
521        return Err(ToolArgError::new(
522            "unsupported_path",
523            "path must start with 'references/' or 'assets/'",
524        ));
525    };
526
527    if let Some(expected) = parsed_kind {
528        if let Some(inferred) = from_path {
529            if expected != inferred {
530                return Err(ToolArgError::new(
531                    "invalid_arguments",
532                    format!(
533                        "kind '{}' does not match path prefix for '{}'",
534                        expected.as_str(),
535                        path
536                    ),
537                ));
538            }
539        }
540    }
541
542    Ok(kind)
543}
544
545fn is_obviously_invalid_relative_path(path: &str) -> bool {
546    if path.trim().is_empty() {
547        return true;
548    }
549
550    let p = Path::new(path);
551    if p.is_absolute() {
552        return true;
553    }
554
555    p.components().any(|c| {
556        matches!(
557            c,
558            Component::ParentDir | Component::RootDir | Component::Prefix(_)
559        )
560    })
561}
562
563fn tool_error(tool_name: &str, code: &str, message: impl Into<String>) -> ToolResult {
564    ToolResult::error_with_code(tool_name, code, message)
565}
566
567fn map_skill_error(tool_name: &str, e: SkillError) -> ToolResult {
568    match e {
569        SkillError::UnknownSkill(id) => {
570            tool_error(tool_name, "unknown_skill", format!("Unknown skill: {id}"))
571        }
572        SkillError::InvalidSkillMd(msg) => tool_error(tool_name, "invalid_skill_md", msg),
573        SkillError::Materialize(err) => match err {
574            SkillMaterializeError::InvalidPath(msg) => tool_error(tool_name, "invalid_path", msg),
575            SkillMaterializeError::PathEscapesRoot => {
576                tool_error(tool_name, "path_escapes_root", "path is outside skill root")
577            }
578            SkillMaterializeError::UnsupportedPath(msg) => tool_error(
579                tool_name,
580                "unsupported_path",
581                format!("expected under {msg}"),
582            ),
583            SkillMaterializeError::Io(msg) => tool_error(tool_name, "io_error", msg),
584            SkillMaterializeError::UnsupportedRuntime(msg) => {
585                tool_error(tool_name, "unsupported_runtime", msg)
586            }
587            SkillMaterializeError::Timeout(secs) => tool_error(
588                tool_name,
589                "script_timeout",
590                format!("script timed out after {secs}s"),
591            ),
592            SkillMaterializeError::InvalidScriptArgs(msg) => {
593                tool_error(tool_name, "invalid_arguments", msg)
594            }
595        },
596        SkillError::Io(msg) => tool_error(tool_name, "io_error", msg),
597        SkillError::DuplicateSkillId(id) => tool_error(
598            tool_name,
599            "duplicate_skill_id",
600            format!("duplicate skill id: {id}"),
601        ),
602        SkillError::Unsupported(msg) => tool_error(tool_name, "unsupported_operation", msg),
603    }
604}