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}