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
320 skill_config.permission_checker = Some(Arc::new(skill_permission_policy));
322 skill_config.enforce_active_skill_tool_restrictions = true;
323
324 let temp_registry = Arc::new(SkillRegistry::new());
326 temp_registry.register(skill.clone())?;
327 skill_config.skill_registry = Some(temp_registry);
328
329 skill_config.prompt_slots.role = Some(format!(
331 "You are executing the '{}' skill.\n\n{}\n\n{}",
332 skill.name, skill.description, skill.content
333 ));
334
335 let agent_loop = AgentLoop::new(
337 self.llm_client.clone(),
338 self.tool_executor.clone(),
339 ctx.clone(),
340 skill_config,
341 );
342
343 let prompt = args
345 .prompt
346 .unwrap_or_else(|| format!("Execute the '{}' skill", skill.name));
347
348 let cancellation = ctx.cancellation_token();
353 let result = agent_loop
354 .execute_with_session(
355 &[],
356 &prompt,
357 ctx.session_id.as_deref(),
358 None,
359 Some(&cancellation),
360 )
361 .await?;
362 if cancellation.is_cancelled() {
363 anyhow::bail!("Skill '{}' cancelled by caller", skill.name);
364 }
365
366 Ok(ToolOutput {
368 content: result.text,
369 success: true,
370 metadata: Some(serde_json::json!({
371 "skill_name": skill.name,
372 "tool_calls": result.tool_calls_count,
373 "usage": result.usage,
374 })),
375 images: Vec::new(),
376 error_kind: None,
377 })
378 }
379}
380
381#[cfg(test)]
382mod tests {
383 use super::*;
384 use crate::budget::{BudgetDecision, BudgetGuard};
385 use crate::llm::{
386 ContentBlock, LlmClient, LlmResponse, Message, StreamEvent, TokenUsage, ToolDefinition,
387 };
388 use crate::skills::SkillKind;
389 use crate::tools::ToolContext;
390 use anyhow::Result;
391 use async_trait::async_trait;
392 use std::path::PathBuf;
393 use std::sync::atomic::{AtomicUsize, Ordering};
394 use std::sync::Mutex;
395 use std::time::Duration;
396 use tokio::sync::{mpsc, Notify};
397 use tokio_util::sync::CancellationToken;
398
399 struct MockLlmClient {
400 responses: Mutex<Vec<LlmResponse>>,
401 }
402
403 impl MockLlmClient {
404 fn new(responses: Vec<LlmResponse>) -> Self {
405 Self {
406 responses: Mutex::new(responses),
407 }
408 }
409
410 fn text_response(text: &str) -> LlmResponse {
411 LlmResponse {
412 message: Message {
413 role: "assistant".to_string(),
414 content: vec![ContentBlock::Text {
415 text: text.to_string(),
416 }],
417 reasoning_content: None,
418 },
419 usage: TokenUsage {
420 prompt_tokens: 10,
421 completion_tokens: 5,
422 total_tokens: 15,
423 cache_read_tokens: None,
424 cache_write_tokens: None,
425 },
426 stop_reason: Some("end_turn".to_string()),
427 token_logprobs: Vec::new(),
428 meta: None,
429 }
430 }
431 }
432
433 #[async_trait]
434 impl LlmClient for MockLlmClient {
435 async fn complete(
436 &self,
437 _messages: &[Message],
438 _system: Option<&str>,
439 _tools: &[ToolDefinition],
440 ) -> Result<LlmResponse> {
441 let mut responses = self.responses.lock().unwrap();
442 if responses.is_empty() {
443 anyhow::bail!("No more mock responses available");
444 }
445 Ok(responses.remove(0))
446 }
447
448 async fn complete_streaming(
449 &self,
450 _messages: &[Message],
451 _system: Option<&str>,
452 _tools: &[ToolDefinition],
453 _cancel_token: tokio_util::sync::CancellationToken,
454 ) -> Result<mpsc::Receiver<StreamEvent>> {
455 anyhow::bail!("streaming not used in SkillTool tests")
456 }
457 }
458
459 #[derive(Default)]
460 struct SkillBudgetGuard {
461 checks: AtomicUsize,
462 records: AtomicUsize,
463 sessions: Mutex<Vec<String>>,
464 }
465
466 #[async_trait]
467 impl BudgetGuard for SkillBudgetGuard {
468 async fn check_before_llm(
469 &self,
470 session_id: &str,
471 _estimated_prompt_tokens: usize,
472 ) -> BudgetDecision {
473 self.checks.fetch_add(1, Ordering::SeqCst);
474 self.sessions.lock().unwrap().push(session_id.to_string());
475 BudgetDecision::Allow
476 }
477
478 async fn record_after_llm(&self, _session_id: &str, _usage: &TokenUsage) {
479 self.records.fetch_add(1, Ordering::SeqCst);
480 }
481 }
482
483 struct BlockingSkillClient {
484 started: Arc<Notify>,
485 calls: Arc<AtomicUsize>,
486 }
487
488 #[async_trait]
489 impl LlmClient for BlockingSkillClient {
490 async fn complete(
491 &self,
492 _messages: &[Message],
493 _system: Option<&str>,
494 _tools: &[ToolDefinition],
495 ) -> Result<LlmResponse> {
496 self.calls.fetch_add(1, Ordering::SeqCst);
497 self.started.notify_one();
498 std::future::pending::<Result<LlmResponse>>().await
499 }
500
501 async fn complete_streaming(
502 &self,
503 _messages: &[Message],
504 _system: Option<&str>,
505 _tools: &[ToolDefinition],
506 _cancel_token: CancellationToken,
507 ) -> Result<mpsc::Receiver<StreamEvent>> {
508 anyhow::bail!("streaming not used in SkillTool cancellation tests")
509 }
510 }
511
512 fn test_skill_registry() -> Arc<SkillRegistry> {
513 let registry = Arc::new(SkillRegistry::new());
514 registry.register_unchecked(Arc::new(Skill {
515 name: "test-skill".to_string(),
516 description: "Run a focused skill".to_string(),
517 allowed_tools: None,
518 disable_model_invocation: false,
519 kind: SkillKind::Instruction,
520 content: "Reply with the skill result.".to_string(),
521 tags: vec!["focus".to_string()],
522 version: None,
523 }));
524 registry
525 }
526
527 #[test]
528 fn test_skill_permission_policy() {
529 let skill = Skill {
530 name: "test-skill".to_string(),
531 description: "Test".to_string(),
532 allowed_tools: Some("read(*), grep(*)".to_string()),
533 disable_model_invocation: false,
534 kind: SkillKind::Instruction,
535 content: String::new(),
536 tags: Vec::new(),
537 version: None,
538 };
539
540 let policy = SkillTool::create_skill_permission_policy(&skill);
541
542 assert_eq!(
544 policy.check("read", &serde_json::json!({})),
545 PermissionDecision::Allow
546 );
547 assert_eq!(
548 policy.check("grep", &serde_json::json!({})),
549 PermissionDecision::Allow
550 );
551
552 assert_eq!(
554 policy.check("write", &serde_json::json!({})),
555 PermissionDecision::Deny
556 );
557 }
558
559 #[test]
560 fn test_skill_permission_policy_denies_when_unspecified() {
561 let skill = Skill {
562 name: "test-skill".to_string(),
563 description: "Test".to_string(),
564 allowed_tools: None,
565 disable_model_invocation: false,
566 kind: SkillKind::Instruction,
567 content: String::new(),
568 tags: Vec::new(),
569 version: None,
570 };
571
572 let policy = SkillTool::create_skill_permission_policy(&skill);
573
574 assert_eq!(
575 policy.check("bash", &serde_json::json!({"command": "python --version"})),
576 PermissionDecision::Deny
577 );
578 assert_eq!(
579 policy.check("read", &serde_json::json!({"file_path": "SKILL.md"})),
580 PermissionDecision::Deny
581 );
582 }
583
584 #[test]
585 fn test_skill_permission_policy_accepts_legacy_allowed_tools() {
586 let skill = Skill {
587 name: "test-skill".to_string(),
588 description: "Test".to_string(),
589 allowed_tools: Some("Read Write Edit Bash".to_string()),
590 disable_model_invocation: false,
591 kind: SkillKind::Instruction,
592 content: String::new(),
593 tags: Vec::new(),
594 version: None,
595 };
596
597 let policy = SkillTool::create_skill_permission_policy(&skill);
598
599 assert_eq!(
600 policy.check("bash", &serde_json::json!({"command": "python --version"})),
601 PermissionDecision::Allow
602 );
603 assert_eq!(
604 policy.check("grep", &serde_json::json!({"pattern": "x"})),
605 PermissionDecision::Deny
606 );
607 }
608
609 #[test]
610 fn test_skill_permission_policy_accepts_wildcard_allowed_tools() {
611 let skill = Skill {
612 name: "test-skill".to_string(),
613 description: "Test".to_string(),
614 allowed_tools: Some("*".to_string()),
615 disable_model_invocation: false,
616 kind: SkillKind::Instruction,
617 content: String::new(),
618 tags: Vec::new(),
619 version: None,
620 };
621
622 let policy = SkillTool::create_skill_permission_policy(&skill);
623
624 assert_eq!(
625 policy.check("bash", &serde_json::json!({"command": "python --version"})),
626 PermissionDecision::Allow
627 );
628 assert_eq!(
629 policy.check("parallel_task", &serde_json::json!({"tasks": []})),
630 PermissionDecision::Allow
631 );
632 }
633
634 #[test]
635 fn test_skill_args_accepts_documented_shape() {
636 let args =
637 SkillArgs::from_tool_args(&serde_json::json!({"skill_name": "code-review"})).unwrap();
638 assert_eq!(args.skill_name, "code-review");
639 assert_eq!(args.prompt, None);
640 }
641
642 #[test]
643 fn test_skill_args_accepts_common_aliases_and_wrappers() {
644 let camel =
645 SkillArgs::from_tool_args(&serde_json::json!({"skillName": "code-review"})).unwrap();
646 assert_eq!(camel.skill_name, "code-review");
647
648 let name = SkillArgs::from_tool_args(&serde_json::json!({
649 "name": "code-review",
650 "query": "review this patch"
651 }))
652 .unwrap();
653 assert_eq!(name.skill_name, "code-review");
654 assert_eq!(name.prompt.as_deref(), Some("review this patch"));
655
656 let nested = SkillArgs::from_tool_args(&serde_json::json!({
657 "input": {
658 "skill_name": "code-review",
659 "prompt": "review this patch"
660 }
661 }))
662 .unwrap();
663 assert_eq!(nested.skill_name, "code-review");
664 assert_eq!(nested.prompt.as_deref(), Some("review this patch"));
665
666 let direct = SkillArgs::from_tool_args(&serde_json::json!("code-review")).unwrap();
667 assert_eq!(direct.skill_name, "code-review");
668 }
669
670 #[test]
671 fn test_skill_args_missing_skill_name_errors() {
672 let err =
673 SkillArgs::from_tool_args(&serde_json::json!({"prompt": "do something"})).unwrap_err();
674 assert!(err.to_string().contains("missing field 'skill_name'"));
675 }
676
677 #[test]
678 fn test_search_skills_args_accepts_string_and_object() {
679 let direct = SearchSkillsArgs::from_tool_args(&serde_json::json!("review code")).unwrap();
680 assert_eq!(direct.query, "review code");
681 assert_eq!(direct.limit, None);
682
683 let object =
684 SearchSkillsArgs::from_tool_args(&serde_json::json!({"query": "review", "limit": 2}))
685 .unwrap();
686 assert_eq!(object.query, "review");
687 assert_eq!(object.limit, Some(2));
688 }
689
690 #[tokio::test]
691 async fn test_search_skills_tool_returns_matching_skills() {
692 let registry = Arc::new(SkillRegistry::new());
693 registry.register_unchecked(Arc::new(Skill {
694 name: "code-review".to_string(),
695 description: "Review code changes".to_string(),
696 allowed_tools: Some("read(*), grep(*)".to_string()),
697 disable_model_invocation: false,
698 kind: SkillKind::Instruction,
699 content: "Review instructions".to_string(),
700 tags: vec!["review".to_string()],
701 version: None,
702 }));
703
704 let tool = SearchSkillsTool::new(registry);
705 let result = tool
706 .execute(
707 &serde_json::json!({"query": "review"}),
708 &ToolContext::new(PathBuf::from("/tmp")),
709 )
710 .await
711 .unwrap();
712
713 assert!(result.success);
714 assert!(result.content.contains("code-review"));
715 assert_eq!(result.metadata.unwrap()["skills"][0]["name"], "code-review");
716 }
717
718 #[tokio::test]
719 async fn test_search_skills_tool_clamps_limit_and_excludes_personas() {
720 let registry = Arc::new(SkillRegistry::new());
721 for index in 0..25 {
722 registry.register_unchecked(Arc::new(Skill {
723 name: format!("review-{index:02}"),
724 description: "Review code changes".to_string(),
725 allowed_tools: Some("read(*)".to_string()),
726 disable_model_invocation: false,
727 kind: SkillKind::Instruction,
728 content: "Review instructions".to_string(),
729 tags: vec!["review".to_string()],
730 version: None,
731 }));
732 }
733 registry.register_unchecked(Arc::new(Skill {
734 name: "review-persona".to_string(),
735 description: "Review persona".to_string(),
736 allowed_tools: None,
737 disable_model_invocation: false,
738 kind: SkillKind::Persona,
739 content: "Persona instructions".to_string(),
740 tags: vec!["review".to_string()],
741 version: None,
742 }));
743
744 let tool = SearchSkillsTool::new(registry);
745 let result = tool
746 .execute(
747 &serde_json::json!({"query": "review", "limit": 100}),
748 &ToolContext::new(PathBuf::from("/tmp")),
749 )
750 .await
751 .unwrap();
752
753 let metadata = result.metadata.unwrap();
754 let skills = metadata["skills"].as_array().unwrap();
755 assert_eq!(skills.len(), 20);
756 assert!(skills.iter().all(|skill| skill["kind"] == "instruction"));
757 }
758
759 #[test]
760 fn test_skill_tool_schema_enforces_canonical_shape() {
761 let registry = Arc::new(SkillRegistry::new());
762 let llm = Arc::new(MockLlmClient::new(vec![]));
763 let executor = Arc::new(ToolExecutor::new("/tmp".to_string()));
764 let tool = SkillTool::new(registry, llm, executor, AgentConfig::default());
765
766 let params = tool.parameters();
767 assert_eq!(params["type"], "object");
768 assert_eq!(params["additionalProperties"], serde_json::json!(false));
769 assert_eq!(params["required"], serde_json::json!(["skill_name"]));
770
771 let examples = params["examples"].as_array().unwrap();
772 assert_eq!(examples[0]["skill_name"], "code-review");
773 assert!(examples[0].get("name").is_none());
774 assert!(examples[0].get("skillName").is_none());
775 }
776
777 #[tokio::test]
778 async fn test_skill_tool_execute_runs_skill_and_returns_metadata() {
779 use crate::prompts::PlanningMode;
780
781 let registry = Arc::new(SkillRegistry::new());
782 registry.register_unchecked(Arc::new(Skill {
783 name: "test-skill".to_string(),
784 description: "Run a focused skill".to_string(),
785 allowed_tools: None,
786 disable_model_invocation: false,
787 kind: SkillKind::Instruction,
788 content: "Reply with the skill result.".to_string(),
789 tags: vec!["focus".to_string()],
790 version: None,
791 }));
792
793 let llm = Arc::new(MockLlmClient::new(vec![MockLlmClient::text_response(
794 "skill completed",
795 )]));
796 let executor = Arc::new(ToolExecutor::new("/tmp".to_string()));
797 let config = AgentConfig {
799 planning_mode: PlanningMode::Disabled,
800 continuation_enabled: false,
801 ..Default::default()
802 };
803 let tool = SkillTool::new(registry, llm, executor, config);
804
805 let result = tool
806 .execute(
807 &serde_json::json!({
808 "skill_name": "test-skill",
809 "prompt": "verify the skill result"
810 }),
811 &ToolContext::new(PathBuf::from("/tmp")),
812 )
813 .await
814 .unwrap();
815
816 assert!(result.success);
817 assert_eq!(result.content, "skill completed");
818 let metadata = result.metadata.unwrap();
819 assert_eq!(metadata["skill_name"], "test-skill");
820 assert_eq!(metadata["tool_calls"], 0);
821 }
822
823 #[tokio::test]
824 async fn skill_child_llm_call_uses_parent_session_budget_scope() {
825 use crate::prompts::PlanningMode;
826
827 let guard = Arc::new(SkillBudgetGuard::default());
828 let config = AgentConfig {
829 planning_mode: PlanningMode::Disabled,
830 continuation_enabled: false,
831 budget_guard: Some(Arc::clone(&guard) as Arc<dyn BudgetGuard>),
832 ..Default::default()
833 };
834 let tool = SkillTool::new(
835 test_skill_registry(),
836 Arc::new(MockLlmClient::new(vec![MockLlmClient::text_response(
837 "skill completed",
838 )])),
839 Arc::new(ToolExecutor::new("/tmp".to_string())),
840 config,
841 );
842 let ctx = ToolContext::new(PathBuf::from("/tmp")).with_session_id("parent-session");
843
844 let result = tool
845 .execute(&serde_json::json!({"skill_name": "test-skill"}), &ctx)
846 .await
847 .unwrap();
848
849 assert!(result.success);
850 assert_eq!(guard.checks.load(Ordering::SeqCst), 1);
851 assert_eq!(guard.records.load(Ordering::SeqCst), 1);
852 assert_eq!(
853 guard.sessions.lock().unwrap().as_slice(),
854 &["parent-session".to_string()]
855 );
856 }
857
858 #[tokio::test]
859 async fn skill_child_llm_call_stops_on_parent_cancellation() {
860 use crate::prompts::PlanningMode;
861
862 let started = Arc::new(Notify::new());
863 let calls = Arc::new(AtomicUsize::new(0));
864 let tool = SkillTool::new(
865 test_skill_registry(),
866 Arc::new(BlockingSkillClient {
867 started: Arc::clone(&started),
868 calls: Arc::clone(&calls),
869 }),
870 Arc::new(ToolExecutor::new("/tmp".to_string())),
871 AgentConfig {
872 planning_mode: PlanningMode::Disabled,
873 continuation_enabled: false,
874 ..Default::default()
875 },
876 );
877 let cancellation = CancellationToken::new();
878 let ctx = ToolContext::new(PathBuf::from("/tmp"))
879 .with_session_id("parent-session")
880 .with_cancellation(cancellation.clone());
881 let started_wait = started.notified();
882 let run = tokio::spawn(async move {
883 tool.execute(&serde_json::json!({"skill_name": "test-skill"}), &ctx)
884 .await
885 });
886
887 tokio::time::timeout(Duration::from_secs(1), started_wait)
888 .await
889 .expect("skill provider call should start");
890 cancellation.cancel();
891 let error = tokio::time::timeout(Duration::from_secs(1), run)
892 .await
893 .expect("parent cancellation must stop the skill child")
894 .expect("skill join should succeed")
895 .expect_err("cancelled skill must not return success");
896
897 assert!(error.to_string().contains("cancelled"));
898 assert_eq!(calls.load(Ordering::SeqCst), 1);
899 }
900
901 #[tokio::test]
902 async fn test_skill_tool_execute_errors_for_unknown_skill() {
903 let llm = Arc::new(MockLlmClient::new(vec![MockLlmClient::text_response(
904 "unused",
905 )]));
906 let executor = Arc::new(ToolExecutor::new("/tmp".to_string()));
907 let tool = SkillTool::new(
908 Arc::new(SkillRegistry::new()),
909 llm,
910 executor,
911 AgentConfig::default(),
912 );
913
914 let err = tool
915 .execute(
916 &serde_json::json!({"skill_name": "missing-skill"}),
917 &ToolContext::new(PathBuf::from("/tmp")),
918 )
919 .await
920 .unwrap_err();
921
922 assert!(err.to_string().contains("Skill 'missing-skill' not found"));
923 }
924}