1use crate::agent::{AgentConfig, AgentLoop};
17use crate::llm::LlmClient;
18use crate::permissions::{PermissionDecision, PermissionPolicy, PermissionRule};
19use crate::skills::{Skill, SkillRegistry};
20use crate::tools::{Tool, ToolContext, ToolExecutor, ToolOutput};
21use anyhow::{anyhow, Result};
22use async_trait::async_trait;
23use serde::{Deserialize, Serialize};
24use serde_json::Value;
25use std::sync::Arc;
26
27#[derive(Debug, Serialize, Deserialize)]
29pub struct SkillArgs {
30 pub skill_name: String,
32 #[serde(default)]
34 pub prompt: Option<String>,
35}
36
37impl SkillArgs {
38 fn from_tool_args(args: &Value) -> Result<Self> {
39 fn parse_from_value(value: &Value) -> Option<SkillArgs> {
40 match value {
41 Value::String(skill_name) => Some(SkillArgs {
42 skill_name: skill_name.clone(),
43 prompt: None,
44 }),
45 Value::Object(map) => {
46 if let Some(skill_name) = map
47 .get("skill_name")
48 .or_else(|| map.get("skillName"))
49 .or_else(|| map.get("name"))
50 .and_then(|v| v.as_str())
51 {
52 let prompt = map
53 .get("prompt")
54 .or_else(|| map.get("query"))
55 .and_then(|v| v.as_str())
56 .map(ToOwned::to_owned);
57 return Some(SkillArgs {
58 skill_name: skill_name.to_string(),
59 prompt,
60 });
61 }
62
63 if let Some(nested) = map.get("input").or_else(|| map.get("arguments")) {
64 if let Some(parsed) = parse_from_value(nested) {
65 return Some(parsed);
66 }
67 }
68
69 None
70 }
71 _ => None,
72 }
73 }
74
75 parse_from_value(args).ok_or_else(|| anyhow!("missing field 'skill_name'"))
76 }
77}
78
79#[derive(Debug, Serialize, Deserialize)]
81pub struct SearchSkillsArgs {
82 pub query: String,
84 #[serde(default)]
86 pub limit: Option<usize>,
87}
88
89impl SearchSkillsArgs {
90 fn from_tool_args(args: &Value) -> Result<Self> {
91 match args {
92 Value::String(query) => Ok(Self {
93 query: query.clone(),
94 limit: None,
95 }),
96 Value::Object(map) => {
97 let query = map
98 .get("query")
99 .or_else(|| map.get("q"))
100 .and_then(|v| v.as_str())
101 .ok_or_else(|| anyhow!("missing field 'query'"))?
102 .to_string();
103 let limit = map
104 .get("limit")
105 .and_then(|v| v.as_u64())
106 .map(|v| v as usize);
107 Ok(Self { query, limit })
108 }
109 _ => Err(anyhow!(
110 "search_skills expects an object with a 'query' field"
111 )),
112 }
113 }
114}
115
116pub struct SearchSkillsTool {
118 skill_registry: Arc<SkillRegistry>,
119}
120
121impl SearchSkillsTool {
122 pub fn new(skill_registry: Arc<SkillRegistry>) -> Self {
123 Self { skill_registry }
124 }
125}
126
127#[async_trait]
128impl Tool for SearchSkillsTool {
129 fn name(&self) -> &str {
130 "search_skills"
131 }
132
133 fn description(&self) -> &str {
134 "Search available skills by name, tag, description, or content. \
135Use this before invoking Skill when specialized instructions may help."
136 }
137
138 fn parameters(&self) -> Value {
139 serde_json::json!({
140 "type": "object",
141 "additionalProperties": false,
142 "properties": {
143 "query": {
144 "type": "string",
145 "description": "Short search query for the skill you need."
146 },
147 "limit": {
148 "type": "integer",
149 "minimum": 1,
150 "maximum": 20,
151 "description": "Maximum number of skills to return. Defaults to 5."
152 }
153 },
154 "required": ["query"]
155 })
156 }
157
158 async fn execute(&self, args: &Value, _ctx: &ToolContext) -> Result<ToolOutput> {
159 let args = SearchSkillsArgs::from_tool_args(args)?;
160 let limit = args.limit.unwrap_or(5).clamp(1, 20);
161 let matches = self.skill_registry.search(&args.query, limit);
162
163 if matches.is_empty() {
164 return Ok(ToolOutput::success(
165 "No matching skills found. Continue with the core tools.".to_string(),
166 ));
167 }
168
169 let mut lines = vec![format!(
170 "Found {} matching skill(s). Invoke one with Skill using its skill_name.",
171 matches.len()
172 )];
173 let metadata: Vec<_> = matches
174 .iter()
175 .map(|skill| {
176 let kind = format!("{:?}", skill.kind).to_lowercase();
177 let allowed_tools = skill.allowed_tools.as_deref().unwrap_or("not specified");
178 lines.push(format!(
179 "- {} ({kind}): {} Allowed tools: {}.",
180 skill.name, skill.description, allowed_tools
181 ));
182 serde_json::json!({
183 "name": skill.name,
184 "description": skill.description,
185 "kind": kind,
186 "tags": skill.tags,
187 "allowed_tools": skill.allowed_tools,
188 })
189 })
190 .collect();
191
192 Ok(ToolOutput {
193 content: lines.join("\n"),
194 success: true,
195 metadata: Some(serde_json::json!({ "skills": metadata })),
196 images: Vec::new(),
197 error_kind: None,
198 })
199 }
200}
201
202pub struct SkillTool {
204 skill_registry: Arc<SkillRegistry>,
205 llm_client: Arc<dyn LlmClient>,
206 tool_executor: Arc<ToolExecutor>,
207 base_config: AgentConfig,
208}
209
210impl SkillTool {
211 pub(crate) fn new(
212 skill_registry: Arc<SkillRegistry>,
213 llm_client: Arc<dyn LlmClient>,
214 tool_executor: Arc<ToolExecutor>,
215 base_config: AgentConfig,
216 ) -> Self {
217 Self {
218 skill_registry,
219 llm_client,
220 tool_executor,
221 base_config,
222 }
223 }
224
225 fn create_skill_permission_policy(skill: &Skill) -> PermissionPolicy {
227 let permissions = skill.parse_allowed_tools();
228
229 if permissions.is_empty() {
230 tracing::warn!(
231 skill = %skill.name,
232 "Skill has no allowed-tools grants; Skill invocation remains fail-secure and will deny tool use"
233 );
234 return PermissionPolicy {
235 deny: Vec::new(),
236 allow: Vec::new(),
237 ask: Vec::new(),
238 default_decision: PermissionDecision::Deny,
239 enabled: true,
240 };
241 }
242
243 let mut allow_rules = Vec::new();
245 for perm in permissions {
246 let rule_str = if perm.pattern == "*" {
248 perm.tool.clone()
249 } else {
250 format!("{}({})", perm.tool, perm.pattern)
251 };
252 allow_rules.push(PermissionRule::new(&rule_str));
253 }
254
255 PermissionPolicy {
256 deny: Vec::new(),
257 allow: allow_rules,
258 ask: Vec::new(),
259 default_decision: PermissionDecision::Deny, enabled: true,
261 }
262 }
263}
264
265#[async_trait]
266impl Tool for SkillTool {
267 fn name(&self) -> &str {
268 "Skill"
269 }
270
271 fn description(&self) -> &str {
272 "Invoke a skill with temporary permission grants. \
273Use a JSON object with the canonical shape {\"skill_name\":\"<skill-name>\",\"prompt\":\"<optional prompt>\"}. \
274Always send the skill name in the 'skill_name' field. Do not use aliases such as 'name' or 'skillName', and do not wrap the payload in 'input' or 'arguments'. \
275The skill's allowed-tools are granted during execution and revoked after completion."
276 }
277
278 fn parameters(&self) -> Value {
279 serde_json::json!({
280 "type": "object",
281 "additionalProperties": false,
282 "properties": {
283 "skill_name": {
284 "type": "string",
285 "description": "Required. Canonical skill identifier to invoke. Always provide this exact field name: 'skill_name'."
286 },
287 "prompt": {
288 "type": "string",
289 "description": "Optional prompt or query to pass to the skill after it is loaded."
290 }
291 },
292 "required": ["skill_name"],
293 "examples": [
294 {
295 "skill_name": "code-review"
296 },
297 {
298 "skill_name": "code-review",
299 "prompt": "Review this patch for correctness and regressions."
300 }
301 ]
302 })
303 }
304
305 async fn execute(&self, args: &Value, ctx: &ToolContext) -> Result<ToolOutput> {
306 let args = SkillArgs::from_tool_args(args)?;
307
308 let skill = self
310 .skill_registry
311 .get(&args.skill_name)
312 .ok_or_else(|| anyhow!("Skill '{}' not found", args.skill_name))?;
313
314 let skill_permission_policy = Self::create_skill_permission_policy(&skill);
316
317 let mut skill_config = self.base_config.clone();
319 if ctx.has_run_governance() {
320 skill_config.permission_checker = ctx.run_permission_checker();
321 skill_config.confirmation_manager = ctx.run_confirmation_manager();
322 }
323
324 let parent_checker = skill_config.permission_checker.take();
329 let parent_policy = skill_config.permission_policy.clone();
330 skill_config.permission_checker = Some(crate::child_run::compose_permission_checker(
331 Arc::new(skill_permission_policy.clone()),
332 Some(skill_permission_policy.clone()),
333 parent_checker,
334 parent_policy,
335 ));
336 skill_config.permission_policy = Some(skill_permission_policy);
337 skill_config.enforce_active_skill_tool_restrictions = true;
338
339 let temp_registry = Arc::new(SkillRegistry::new());
341 temp_registry.register(skill.clone())?;
342 skill_config.skill_registry = Some(temp_registry);
343
344 skill_config.prompt_slots.role = Some(format!(
346 "You are executing the '{}' skill.\n\n{}\n\n{}",
347 skill.name, skill.description, skill.content
348 ));
349
350 let agent_loop = AgentLoop::new(
352 self.llm_client.clone(),
353 self.tool_executor.clone(),
354 ctx.clone(),
355 skill_config,
356 );
357
358 let prompt = args
360 .prompt
361 .unwrap_or_else(|| format!("Execute the '{}' skill", skill.name));
362
363 let cancellation = ctx.cancellation_token();
368 let result = agent_loop
369 .execute_with_session(
370 &[],
371 &prompt,
372 ctx.session_id.as_deref(),
373 None,
374 Some(&cancellation),
375 )
376 .await?;
377 if cancellation.is_cancelled() {
378 anyhow::bail!("Skill '{}' cancelled by caller", skill.name);
379 }
380
381 Ok(ToolOutput {
383 content: result.text,
384 success: true,
385 metadata: Some(serde_json::json!({
386 "skill_name": skill.name,
387 "tool_calls": result.tool_calls_count,
388 "usage": result.usage,
389 })),
390 images: Vec::new(),
391 error_kind: None,
392 })
393 }
394}
395
396#[cfg(test)]
397mod tests {
398 use super::*;
399 use crate::budget::{BudgetDecision, BudgetGuard};
400 use crate::llm::{
401 ContentBlock, LlmClient, LlmResponse, Message, StreamEvent, TokenUsage, ToolDefinition,
402 };
403 use crate::skills::SkillKind;
404 use crate::tools::ToolContext;
405 use anyhow::Result;
406 use async_trait::async_trait;
407 use std::path::PathBuf;
408 use std::sync::atomic::{AtomicUsize, Ordering};
409 use std::sync::Mutex;
410 use std::time::Duration;
411 use tokio::sync::{mpsc, Notify};
412 use tokio_util::sync::CancellationToken;
413
414 struct MockLlmClient {
415 responses: Mutex<Vec<LlmResponse>>,
416 }
417
418 impl MockLlmClient {
419 fn new(responses: Vec<LlmResponse>) -> Self {
420 Self {
421 responses: Mutex::new(responses),
422 }
423 }
424
425 fn text_response(text: &str) -> LlmResponse {
426 LlmResponse {
427 message: Message {
428 role: "assistant".to_string(),
429 content: vec![ContentBlock::Text {
430 text: text.to_string(),
431 }],
432 reasoning_content: None,
433 },
434 usage: TokenUsage {
435 prompt_tokens: 10,
436 completion_tokens: 5,
437 total_tokens: 15,
438 cache_read_tokens: None,
439 cache_write_tokens: None,
440 },
441 stop_reason: Some("end_turn".to_string()),
442 token_logprobs: Vec::new(),
443 meta: None,
444 }
445 }
446
447 fn tool_call_response(id: &str, name: &str, input: serde_json::Value) -> LlmResponse {
448 LlmResponse {
449 message: Message {
450 role: "assistant".to_string(),
451 content: vec![ContentBlock::ToolUse {
452 id: id.to_string(),
453 name: name.to_string(),
454 input,
455 }],
456 reasoning_content: None,
457 },
458 usage: TokenUsage {
459 prompt_tokens: 10,
460 completion_tokens: 5,
461 total_tokens: 15,
462 cache_read_tokens: None,
463 cache_write_tokens: None,
464 },
465 stop_reason: Some("tool_use".to_string()),
466 token_logprobs: Vec::new(),
467 meta: None,
468 }
469 }
470 }
471
472 #[async_trait]
473 impl LlmClient for MockLlmClient {
474 async fn complete(
475 &self,
476 _messages: &[Message],
477 _system: Option<&str>,
478 _tools: &[ToolDefinition],
479 ) -> Result<LlmResponse> {
480 let mut responses = self.responses.lock().unwrap();
481 if responses.is_empty() {
482 anyhow::bail!("No more mock responses available");
483 }
484 Ok(responses.remove(0))
485 }
486
487 async fn complete_streaming(
488 &self,
489 _messages: &[Message],
490 _system: Option<&str>,
491 _tools: &[ToolDefinition],
492 _cancel_token: tokio_util::sync::CancellationToken,
493 ) -> Result<mpsc::Receiver<StreamEvent>> {
494 anyhow::bail!("streaming not used in SkillTool tests")
495 }
496 }
497
498 #[derive(Default)]
499 struct SkillBudgetGuard {
500 checks: AtomicUsize,
501 records: AtomicUsize,
502 sessions: Mutex<Vec<String>>,
503 }
504
505 #[async_trait]
506 impl BudgetGuard for SkillBudgetGuard {
507 async fn check_before_llm(
508 &self,
509 session_id: &str,
510 _estimated_prompt_tokens: usize,
511 ) -> BudgetDecision {
512 self.checks.fetch_add(1, Ordering::SeqCst);
513 self.sessions.lock().unwrap().push(session_id.to_string());
514 BudgetDecision::Allow
515 }
516
517 async fn record_after_llm(&self, _session_id: &str, _usage: &TokenUsage) {
518 self.records.fetch_add(1, Ordering::SeqCst);
519 }
520 }
521
522 struct BlockingSkillClient {
523 started: Arc<Notify>,
524 calls: Arc<AtomicUsize>,
525 }
526
527 struct SkillSideEffectTool {
528 calls: Arc<AtomicUsize>,
529 }
530
531 #[async_trait]
532 impl Tool for SkillSideEffectTool {
533 fn name(&self) -> &str {
534 "side_effect"
535 }
536
537 fn description(&self) -> &str {
538 "Records a test-only side effect"
539 }
540
541 fn parameters(&self) -> serde_json::Value {
542 serde_json::json!({"type": "object", "additionalProperties": false})
543 }
544
545 async fn execute(
546 &self,
547 _args: &serde_json::Value,
548 _ctx: &ToolContext,
549 ) -> Result<ToolOutput> {
550 self.calls.fetch_add(1, Ordering::SeqCst);
551 Ok(ToolOutput::success("unexpected-side-effect"))
552 }
553 }
554
555 #[async_trait]
556 impl LlmClient for BlockingSkillClient {
557 async fn complete(
558 &self,
559 _messages: &[Message],
560 _system: Option<&str>,
561 _tools: &[ToolDefinition],
562 ) -> Result<LlmResponse> {
563 self.calls.fetch_add(1, Ordering::SeqCst);
564 self.started.notify_one();
565 std::future::pending::<Result<LlmResponse>>().await
566 }
567
568 async fn complete_streaming(
569 &self,
570 _messages: &[Message],
571 _system: Option<&str>,
572 _tools: &[ToolDefinition],
573 _cancel_token: CancellationToken,
574 ) -> Result<mpsc::Receiver<StreamEvent>> {
575 anyhow::bail!("streaming not used in SkillTool cancellation tests")
576 }
577 }
578
579 fn test_skill_registry() -> Arc<SkillRegistry> {
580 let registry = Arc::new(SkillRegistry::new());
581 registry.register_unchecked(Arc::new(Skill {
582 name: "test-skill".to_string(),
583 description: "Run a focused skill".to_string(),
584 allowed_tools: None,
585 disable_model_invocation: false,
586 kind: SkillKind::Instruction,
587 content: "Reply with the skill result.".to_string(),
588 tags: vec!["focus".to_string()],
589 version: None,
590 }));
591 registry
592 }
593
594 #[test]
595 fn test_skill_permission_policy() {
596 let skill = Skill {
597 name: "test-skill".to_string(),
598 description: "Test".to_string(),
599 allowed_tools: Some("read(*), grep(*)".to_string()),
600 disable_model_invocation: false,
601 kind: SkillKind::Instruction,
602 content: String::new(),
603 tags: Vec::new(),
604 version: None,
605 };
606
607 let policy = SkillTool::create_skill_permission_policy(&skill);
608
609 assert_eq!(
611 policy.check("read", &serde_json::json!({})),
612 PermissionDecision::Allow
613 );
614 assert_eq!(
615 policy.check(
616 "search",
617 &serde_json::json!({"mode": "grep", "query": "TODO"}),
618 ),
619 PermissionDecision::Allow
620 );
621
622 assert_eq!(
624 policy.check("write", &serde_json::json!({})),
625 PermissionDecision::Deny
626 );
627 }
628
629 #[test]
630 fn test_skill_permission_policy_denies_when_unspecified() {
631 let skill = Skill {
632 name: "test-skill".to_string(),
633 description: "Test".to_string(),
634 allowed_tools: None,
635 disable_model_invocation: false,
636 kind: SkillKind::Instruction,
637 content: String::new(),
638 tags: Vec::new(),
639 version: None,
640 };
641
642 let policy = SkillTool::create_skill_permission_policy(&skill);
643
644 assert_eq!(
645 policy.check("bash", &serde_json::json!({"command": "python --version"})),
646 PermissionDecision::Deny
647 );
648 assert_eq!(
649 policy.check("read", &serde_json::json!({"file_path": "SKILL.md"})),
650 PermissionDecision::Deny
651 );
652 }
653
654 #[test]
655 fn test_skill_permission_policy_accepts_legacy_allowed_tools() {
656 let skill = Skill {
657 name: "test-skill".to_string(),
658 description: "Test".to_string(),
659 allowed_tools: Some("Read Write Edit Bash".to_string()),
660 disable_model_invocation: false,
661 kind: SkillKind::Instruction,
662 content: String::new(),
663 tags: Vec::new(),
664 version: None,
665 };
666
667 let policy = SkillTool::create_skill_permission_policy(&skill);
668
669 assert_eq!(
670 policy.check("bash", &serde_json::json!({"command": "python --version"})),
671 PermissionDecision::Allow
672 );
673 assert_eq!(
674 policy.check("search", &serde_json::json!({"mode": "grep", "query": "x"}),),
675 PermissionDecision::Deny
676 );
677 }
678
679 #[test]
680 fn test_skill_permission_policy_accepts_wildcard_allowed_tools() {
681 let skill = Skill {
682 name: "test-skill".to_string(),
683 description: "Test".to_string(),
684 allowed_tools: Some("*".to_string()),
685 disable_model_invocation: false,
686 kind: SkillKind::Instruction,
687 content: String::new(),
688 tags: Vec::new(),
689 version: None,
690 };
691
692 let policy = SkillTool::create_skill_permission_policy(&skill);
693
694 assert_eq!(
695 policy.check("bash", &serde_json::json!({"command": "python --version"})),
696 PermissionDecision::Allow
697 );
698 assert_eq!(
699 policy.check("parallel_task", &serde_json::json!({"tasks": []})),
700 PermissionDecision::Allow
701 );
702 }
703
704 #[test]
705 fn test_skill_args_accepts_documented_shape() {
706 let args =
707 SkillArgs::from_tool_args(&serde_json::json!({"skill_name": "code-review"})).unwrap();
708 assert_eq!(args.skill_name, "code-review");
709 assert_eq!(args.prompt, None);
710 }
711
712 #[test]
713 fn test_skill_args_accepts_common_aliases_and_wrappers() {
714 let camel =
715 SkillArgs::from_tool_args(&serde_json::json!({"skillName": "code-review"})).unwrap();
716 assert_eq!(camel.skill_name, "code-review");
717
718 let name = SkillArgs::from_tool_args(&serde_json::json!({
719 "name": "code-review",
720 "query": "review this patch"
721 }))
722 .unwrap();
723 assert_eq!(name.skill_name, "code-review");
724 assert_eq!(name.prompt.as_deref(), Some("review this patch"));
725
726 let nested = SkillArgs::from_tool_args(&serde_json::json!({
727 "input": {
728 "skill_name": "code-review",
729 "prompt": "review this patch"
730 }
731 }))
732 .unwrap();
733 assert_eq!(nested.skill_name, "code-review");
734 assert_eq!(nested.prompt.as_deref(), Some("review this patch"));
735
736 let direct = SkillArgs::from_tool_args(&serde_json::json!("code-review")).unwrap();
737 assert_eq!(direct.skill_name, "code-review");
738 }
739
740 #[test]
741 fn test_skill_args_missing_skill_name_errors() {
742 let err =
743 SkillArgs::from_tool_args(&serde_json::json!({"prompt": "do something"})).unwrap_err();
744 assert!(err.to_string().contains("missing field 'skill_name'"));
745 }
746
747 #[test]
748 fn test_search_skills_args_accepts_string_and_object() {
749 let direct = SearchSkillsArgs::from_tool_args(&serde_json::json!("review code")).unwrap();
750 assert_eq!(direct.query, "review code");
751 assert_eq!(direct.limit, None);
752
753 let object =
754 SearchSkillsArgs::from_tool_args(&serde_json::json!({"query": "review", "limit": 2}))
755 .unwrap();
756 assert_eq!(object.query, "review");
757 assert_eq!(object.limit, Some(2));
758 }
759
760 #[tokio::test]
761 async fn test_search_skills_tool_returns_matching_skills() {
762 let registry = Arc::new(SkillRegistry::new());
763 registry.register_unchecked(Arc::new(Skill {
764 name: "code-review".to_string(),
765 description: "Review code changes".to_string(),
766 allowed_tools: Some("read(*), grep(*)".to_string()),
767 disable_model_invocation: false,
768 kind: SkillKind::Instruction,
769 content: "Review instructions".to_string(),
770 tags: vec!["review".to_string()],
771 version: None,
772 }));
773
774 let tool = SearchSkillsTool::new(registry);
775 let result = tool
776 .execute(
777 &serde_json::json!({"query": "review"}),
778 &ToolContext::new(PathBuf::from("/tmp")),
779 )
780 .await
781 .unwrap();
782
783 assert!(result.success);
784 assert!(result.content.contains("code-review"));
785 assert_eq!(result.metadata.unwrap()["skills"][0]["name"], "code-review");
786 }
787
788 #[tokio::test]
789 async fn test_search_skills_tool_clamps_limit_and_excludes_personas() {
790 let registry = Arc::new(SkillRegistry::new());
791 for index in 0..25 {
792 registry.register_unchecked(Arc::new(Skill {
793 name: format!("review-{index:02}"),
794 description: "Review code changes".to_string(),
795 allowed_tools: Some("read(*)".to_string()),
796 disable_model_invocation: false,
797 kind: SkillKind::Instruction,
798 content: "Review instructions".to_string(),
799 tags: vec!["review".to_string()],
800 version: None,
801 }));
802 }
803 registry.register_unchecked(Arc::new(Skill {
804 name: "review-persona".to_string(),
805 description: "Review persona".to_string(),
806 allowed_tools: None,
807 disable_model_invocation: false,
808 kind: SkillKind::Persona,
809 content: "Persona instructions".to_string(),
810 tags: vec!["review".to_string()],
811 version: None,
812 }));
813
814 let tool = SearchSkillsTool::new(registry);
815 let result = tool
816 .execute(
817 &serde_json::json!({"query": "review", "limit": 100}),
818 &ToolContext::new(PathBuf::from("/tmp")),
819 )
820 .await
821 .unwrap();
822
823 let metadata = result.metadata.unwrap();
824 let skills = metadata["skills"].as_array().unwrap();
825 assert_eq!(skills.len(), 20);
826 assert!(skills.iter().all(|skill| skill["kind"] == "instruction"));
827 }
828
829 #[test]
830 fn test_skill_tool_schema_enforces_canonical_shape() {
831 let registry = Arc::new(SkillRegistry::new());
832 let llm = Arc::new(MockLlmClient::new(vec![]));
833 let executor = Arc::new(ToolExecutor::new("/tmp".to_string()));
834 let tool = SkillTool::new(registry, llm, executor, AgentConfig::default());
835
836 let params = tool.parameters();
837 assert_eq!(params["type"], "object");
838 assert_eq!(params["additionalProperties"], serde_json::json!(false));
839 assert_eq!(params["required"], serde_json::json!(["skill_name"]));
840
841 let examples = params["examples"].as_array().unwrap();
842 assert_eq!(examples[0]["skill_name"], "code-review");
843 assert!(examples[0].get("name").is_none());
844 assert!(examples[0].get("skillName").is_none());
845 }
846
847 #[tokio::test]
848 async fn test_skill_tool_execute_runs_skill_and_returns_metadata() {
849 use crate::prompts::PlanningMode;
850
851 let registry = Arc::new(SkillRegistry::new());
852 registry.register_unchecked(Arc::new(Skill {
853 name: "test-skill".to_string(),
854 description: "Run a focused skill".to_string(),
855 allowed_tools: None,
856 disable_model_invocation: false,
857 kind: SkillKind::Instruction,
858 content: "Reply with the skill result.".to_string(),
859 tags: vec!["focus".to_string()],
860 version: None,
861 }));
862
863 let llm = Arc::new(MockLlmClient::new(vec![MockLlmClient::text_response(
864 "skill completed",
865 )]));
866 let executor = Arc::new(ToolExecutor::new("/tmp".to_string()));
867 let config = AgentConfig {
869 planning_mode: PlanningMode::Disabled,
870 continuation_enabled: false,
871 ..Default::default()
872 };
873 let tool = SkillTool::new(registry, llm, executor, config);
874
875 let result = tool
876 .execute(
877 &serde_json::json!({
878 "skill_name": "test-skill",
879 "prompt": "verify the skill result"
880 }),
881 &ToolContext::new(PathBuf::from("/tmp")),
882 )
883 .await
884 .unwrap();
885
886 assert!(result.success);
887 assert_eq!(result.content, "skill completed");
888 let metadata = result.metadata.unwrap();
889 assert_eq!(metadata["skill_name"], "test-skill");
890 assert_eq!(metadata["tool_calls"], 0);
891 }
892
893 #[tokio::test]
894 async fn skill_permissions_cannot_replace_the_parent_host_boundary() {
895 use crate::prompts::PlanningMode;
896
897 let workspace = tempfile::tempdir().unwrap();
898 let registry = Arc::new(SkillRegistry::new());
899 registry.register_unchecked(Arc::new(Skill {
900 name: "bash-skill".to_string(),
901 description: "Attempt a shell call".to_string(),
902 allowed_tools: Some("bash(*)".to_string()),
903 disable_model_invocation: false,
904 kind: SkillKind::Instruction,
905 content: "Use Bash once.".to_string(),
906 tags: Vec::new(),
907 version: None,
908 }));
909 let llm = Arc::new(MockLlmClient::new(vec![
910 MockLlmClient::tool_call_response(
911 "bash-call",
912 "bash",
913 serde_json::json!({"command": "printf leaked > skill-boundary-leak"}),
914 ),
915 MockLlmClient::text_response("The host boundary rejected the call."),
916 ]));
917 let parent_policy = PermissionPolicy::new().deny("bash(*)");
918 let config = AgentConfig {
919 planning_mode: PlanningMode::Disabled,
920 continuation_enabled: false,
921 permission_checker: Some(Arc::new(parent_policy.clone())),
922 permission_policy: Some(parent_policy),
923 ..Default::default()
924 };
925 let tool = SkillTool::new(
926 registry,
927 llm,
928 Arc::new(ToolExecutor::new(
929 workspace.path().to_string_lossy().into_owned(),
930 )),
931 config,
932 );
933
934 let result = tool
935 .execute(
936 &serde_json::json!({"skill_name": "bash-skill"}),
937 &ToolContext::new(workspace.path().to_path_buf()),
938 )
939 .await
940 .unwrap();
941
942 assert!(result.success, "{}", result.content);
943 assert!(
944 !workspace.path().join("skill-boundary-leak").exists(),
945 "a skill-local allow-list must not bypass the parent host boundary"
946 );
947 }
948
949 #[tokio::test]
950 async fn host_direct_context_does_not_leak_into_skill_model_orchestrators() {
951 use crate::prompts::PlanningMode;
952
953 struct RecordingBoundary {
954 checked: Arc<Mutex<Vec<String>>>,
955 }
956
957 impl crate::permissions::PermissionChecker for RecordingBoundary {
958 fn check(&self, tool_name: &str, _args: &serde_json::Value) -> PermissionDecision {
959 self.checked.lock().unwrap().push(tool_name.to_string());
960 if tool_name == "side_effect" {
961 PermissionDecision::Deny
962 } else {
963 PermissionDecision::Allow
964 }
965 }
966 }
967
968 let workspace = tempfile::tempdir().unwrap();
969 let calls = Arc::new(AtomicUsize::new(0));
970 let checked = Arc::new(Mutex::new(Vec::new()));
971 let registry = Arc::new(SkillRegistry::new());
972 registry.register_unchecked(Arc::new(Skill {
973 name: "orchestrator-skill".to_string(),
974 description: "Exercise governed orchestrators".to_string(),
975 allowed_tools: Some("batch(*), program(*), side_effect(*)".to_string()),
976 disable_model_invocation: false,
977 kind: SkillKind::Instruction,
978 content: "Run the requested orchestration.".to_string(),
979 tags: Vec::new(),
980 version: None,
981 }));
982 let llm = Arc::new(MockLlmClient::new(vec![
983 MockLlmClient::tool_call_response(
984 "model-batch",
985 "batch",
986 serde_json::json!({
987 "invocations": [{
988 "tool": "program",
989 "args": {
990 "type": "script",
991 "language": "javascript",
992 "source": "async function run(ctx) { return await ctx.tool('side_effect', {}); }",
993 "allowed_tools": ["side_effect"]
994 }
995 }]
996 }),
997 ),
998 MockLlmClient::text_response("The boundary held."),
999 ]));
1000 let executor = Arc::new(ToolExecutor::new(
1001 workspace.path().to_string_lossy().into_owned(),
1002 ));
1003 executor.register_dynamic_tool(Arc::new(SkillSideEffectTool {
1004 calls: Arc::clone(&calls),
1005 }));
1006 let tool = SkillTool::new(
1007 registry,
1008 llm,
1009 executor,
1010 AgentConfig {
1011 planning_mode: PlanningMode::Disabled,
1012 continuation_enabled: false,
1013 permission_checker: Some(Arc::new(RecordingBoundary {
1014 checked: Arc::clone(&checked),
1015 })),
1016 ..Default::default()
1017 },
1018 );
1019 let context = ToolContext::new(workspace.path().to_path_buf())
1020 .with_host_direct_policy(crate::tools::HostDirectPolicy::TrustedControlPlane);
1021
1022 let result = tool
1023 .execute(
1024 &serde_json::json!({"skill_name": "orchestrator-skill"}),
1025 &context,
1026 )
1027 .await
1028 .unwrap();
1029
1030 assert!(result.success, "{}", result.content);
1031 assert_eq!(result.content, "The boundary held.");
1032 assert_eq!(calls.load(Ordering::SeqCst), 0);
1033 let checked = checked.lock().unwrap();
1034 for expected in ["batch", "program", "side_effect"] {
1035 assert!(
1036 checked.iter().any(|tool| tool == expected),
1037 "{expected} must cross the parent permission boundary: {checked:?}"
1038 );
1039 }
1040 }
1041
1042 #[tokio::test]
1043 async fn skill_child_llm_call_uses_parent_session_budget_scope() {
1044 use crate::prompts::PlanningMode;
1045
1046 let guard = Arc::new(SkillBudgetGuard::default());
1047 let config = AgentConfig {
1048 planning_mode: PlanningMode::Disabled,
1049 continuation_enabled: false,
1050 budget_guard: Some(Arc::clone(&guard) as Arc<dyn BudgetGuard>),
1051 ..Default::default()
1052 };
1053 let tool = SkillTool::new(
1054 test_skill_registry(),
1055 Arc::new(MockLlmClient::new(vec![MockLlmClient::text_response(
1056 "skill completed",
1057 )])),
1058 Arc::new(ToolExecutor::new("/tmp".to_string())),
1059 config,
1060 );
1061 let ctx = ToolContext::new(PathBuf::from("/tmp")).with_session_id("parent-session");
1062
1063 let result = tool
1064 .execute(&serde_json::json!({"skill_name": "test-skill"}), &ctx)
1065 .await
1066 .unwrap();
1067
1068 assert!(result.success);
1069 assert_eq!(guard.checks.load(Ordering::SeqCst), 1);
1070 assert_eq!(guard.records.load(Ordering::SeqCst), 1);
1071 assert_eq!(
1072 guard.sessions.lock().unwrap().as_slice(),
1073 &["parent-session".to_string()]
1074 );
1075 }
1076
1077 #[tokio::test]
1078 async fn skill_child_llm_call_stops_on_parent_cancellation() {
1079 use crate::prompts::PlanningMode;
1080
1081 let started = Arc::new(Notify::new());
1082 let calls = Arc::new(AtomicUsize::new(0));
1083 let tool = SkillTool::new(
1084 test_skill_registry(),
1085 Arc::new(BlockingSkillClient {
1086 started: Arc::clone(&started),
1087 calls: Arc::clone(&calls),
1088 }),
1089 Arc::new(ToolExecutor::new("/tmp".to_string())),
1090 AgentConfig {
1091 planning_mode: PlanningMode::Disabled,
1092 continuation_enabled: false,
1093 ..Default::default()
1094 },
1095 );
1096 let cancellation = CancellationToken::new();
1097 let ctx = ToolContext::new(PathBuf::from("/tmp"))
1098 .with_session_id("parent-session")
1099 .with_cancellation(cancellation.clone());
1100 let started_wait = started.notified();
1101 let run = tokio::spawn(async move {
1102 tool.execute(&serde_json::json!({"skill_name": "test-skill"}), &ctx)
1103 .await
1104 });
1105
1106 tokio::time::timeout(Duration::from_secs(1), started_wait)
1107 .await
1108 .expect("skill provider call should start");
1109 cancellation.cancel();
1110 let error = tokio::time::timeout(Duration::from_secs(1), run)
1111 .await
1112 .expect("parent cancellation must stop the skill child")
1113 .expect("skill join should succeed")
1114 .expect_err("cancelled skill must not return success");
1115
1116 assert!(error.to_string().contains("cancelled"));
1117 assert_eq!(calls.load(Ordering::SeqCst), 1);
1118 }
1119
1120 #[tokio::test]
1121 async fn test_skill_tool_execute_errors_for_unknown_skill() {
1122 let llm = Arc::new(MockLlmClient::new(vec![MockLlmClient::text_response(
1123 "unused",
1124 )]));
1125 let executor = Arc::new(ToolExecutor::new("/tmp".to_string()));
1126 let tool = SkillTool::new(
1127 Arc::new(SkillRegistry::new()),
1128 llm,
1129 executor,
1130 AgentConfig::default(),
1131 );
1132
1133 let err = tool
1134 .execute(
1135 &serde_json::json!({"skill_name": "missing-skill"}),
1136 &ToolContext::new(PathBuf::from("/tmp")),
1137 )
1138 .await
1139 .unwrap_err();
1140
1141 assert!(err.to_string().contains("Skill 'missing-skill' not found"));
1142 }
1143}