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 capability_subtask = ctx
351 .capability_context()
352 .map(|context| context.admit_subtask(format!("skill-{}", uuid::Uuid::new_v4()), false))
353 .transpose()?;
354 let cancellation = capability_subtask.as_ref().map_or_else(
355 || ctx.cancellation_token(),
356 crate::capability::AgentCapabilitySubtask::cancellation,
357 );
358
359 let mut agent_loop = AgentLoop::new(
362 self.llm_client.clone(),
363 self.tool_executor.clone(),
364 ctx.clone(),
365 skill_config,
366 );
367 if let Some(subtask) = capability_subtask.as_ref() {
368 agent_loop = agent_loop.with_capability_runtime(subtask.runtime());
369 }
370
371 let prompt = args
373 .prompt
374 .unwrap_or_else(|| format!("Execute the '{}' skill", skill.name));
375
376 let execution = agent_loop
381 .execute_with_session(
382 &[],
383 &prompt,
384 ctx.session_id.as_deref(),
385 None,
386 Some(&cancellation),
387 )
388 .await;
389 let cancelled = cancellation.is_cancelled();
390 let close = close_skill_capability_subtask(capability_subtask.as_ref()).await;
391 let result = match (execution, close) {
392 (Ok(result), Ok(())) => result,
393 (Ok(_), Err(close_error)) => return Err(close_error),
394 (Err(error), Ok(())) => return Err(error),
395 (Err(error), Err(close_error)) => {
396 tracing::warn!(
397 error = %close_error,
398 "Capability Skill Subtask close also failed after execution failure"
399 );
400 return Err(error);
401 }
402 };
403 if cancelled {
404 anyhow::bail!("Skill '{}' cancelled by caller", skill.name);
405 }
406
407 Ok(ToolOutput {
409 content: result.text,
410 success: true,
411 metadata: Some(serde_json::json!({
412 "skill_name": skill.name,
413 "tool_calls": result.tool_calls_count,
414 "usage": result.usage,
415 })),
416 images: Vec::new(),
417 error_kind: None,
418 })
419 }
420}
421
422async fn close_skill_capability_subtask(
423 subtask: Option<&crate::capability::AgentCapabilitySubtask>,
424) -> Result<()> {
425 let Some(subtask) = subtask else {
426 return Ok(());
427 };
428 let report = subtask.close().await?;
429 if !report.is_clean() {
430 anyhow::bail!(
431 "Capability Skill Subtask close was incomplete (tasks failed: {}, tasks timed out: {}, child scopes failed: {}, child scopes timed out: {}, effects failed: {}, effects timed out: {})",
432 report.tasks_failed,
433 report.tasks_timed_out,
434 report.child_scopes_failed,
435 report.child_scopes_timed_out,
436 report.effects_failed,
437 report.effects_timed_out,
438 );
439 }
440 Ok(())
441}
442
443#[cfg(test)]
444mod tests {
445 use super::*;
446 use crate::budget::{BudgetDecision, BudgetGuard};
447 use crate::llm::{
448 ContentBlock, LlmClient, LlmResponse, Message, StreamEvent, TokenUsage, ToolDefinition,
449 };
450 use crate::skills::SkillKind;
451 use crate::tools::ToolContext;
452 use anyhow::Result;
453 use async_trait::async_trait;
454 use std::path::PathBuf;
455 use std::sync::atomic::{AtomicUsize, Ordering};
456 use std::sync::Mutex;
457 use std::time::Duration;
458 use tokio::sync::{mpsc, Notify};
459 use tokio_util::sync::CancellationToken;
460
461 struct MockLlmClient {
462 responses: Mutex<Vec<LlmResponse>>,
463 }
464
465 impl MockLlmClient {
466 fn new(responses: Vec<LlmResponse>) -> Self {
467 Self {
468 responses: Mutex::new(responses),
469 }
470 }
471
472 fn text_response(text: &str) -> LlmResponse {
473 LlmResponse {
474 message: Message {
475 role: "assistant".to_string(),
476 content: vec![ContentBlock::Text {
477 text: text.to_string(),
478 }],
479 reasoning_content: None,
480 },
481 usage: TokenUsage {
482 prompt_tokens: 10,
483 completion_tokens: 5,
484 total_tokens: 15,
485 cache_read_tokens: None,
486 cache_write_tokens: None,
487 },
488 stop_reason: Some("end_turn".to_string()),
489 token_logprobs: Vec::new(),
490 meta: None,
491 }
492 }
493
494 fn tool_call_response(id: &str, name: &str, input: serde_json::Value) -> LlmResponse {
495 LlmResponse {
496 message: Message {
497 role: "assistant".to_string(),
498 content: vec![ContentBlock::ToolUse {
499 id: id.to_string(),
500 name: name.to_string(),
501 input,
502 }],
503 reasoning_content: None,
504 },
505 usage: TokenUsage {
506 prompt_tokens: 10,
507 completion_tokens: 5,
508 total_tokens: 15,
509 cache_read_tokens: None,
510 cache_write_tokens: None,
511 },
512 stop_reason: Some("tool_use".to_string()),
513 token_logprobs: Vec::new(),
514 meta: None,
515 }
516 }
517 }
518
519 #[async_trait]
520 impl LlmClient for MockLlmClient {
521 async fn complete(
522 &self,
523 _messages: &[Message],
524 _system: Option<&str>,
525 _tools: &[ToolDefinition],
526 ) -> Result<LlmResponse> {
527 let mut responses = self.responses.lock().unwrap();
528 if responses.is_empty() {
529 anyhow::bail!("No more mock responses available");
530 }
531 Ok(responses.remove(0))
532 }
533
534 async fn complete_streaming(
535 &self,
536 _messages: &[Message],
537 _system: Option<&str>,
538 _tools: &[ToolDefinition],
539 _cancel_token: tokio_util::sync::CancellationToken,
540 ) -> Result<mpsc::Receiver<StreamEvent>> {
541 anyhow::bail!("streaming not used in SkillTool tests")
542 }
543 }
544
545 #[derive(Default)]
546 struct SkillBudgetGuard {
547 checks: AtomicUsize,
548 records: AtomicUsize,
549 sessions: Mutex<Vec<String>>,
550 }
551
552 #[async_trait]
553 impl BudgetGuard for SkillBudgetGuard {
554 async fn check_before_llm(
555 &self,
556 session_id: &str,
557 _estimated_prompt_tokens: usize,
558 ) -> BudgetDecision {
559 self.checks.fetch_add(1, Ordering::SeqCst);
560 self.sessions.lock().unwrap().push(session_id.to_string());
561 BudgetDecision::Allow
562 }
563
564 async fn record_after_llm(&self, _session_id: &str, _usage: &TokenUsage) {
565 self.records.fetch_add(1, Ordering::SeqCst);
566 }
567 }
568
569 struct BlockingSkillClient {
570 started: Arc<Notify>,
571 calls: Arc<AtomicUsize>,
572 }
573
574 struct SkillSideEffectTool {
575 calls: Arc<AtomicUsize>,
576 }
577
578 struct SkillScopeProbeTool {
579 log: Arc<Mutex<Vec<String>>>,
580 }
581
582 struct SkillScopeProbeEffect {
583 scope_id: String,
584 log: Arc<Mutex<Vec<String>>>,
585 }
586
587 #[async_trait]
588 impl crate::capability::CapabilityEffect for SkillScopeProbeEffect {
589 fn name(&self) -> &str {
590 "test.skill.scope.effect"
591 }
592
593 async fn close(
594 self: Box<Self>,
595 ) -> std::result::Result<(), crate::capability::CapabilityEffectError> {
596 self.log
597 .lock()
598 .unwrap()
599 .push(format!("closed:{}", self.scope_id));
600 Ok(())
601 }
602 }
603
604 #[async_trait]
605 impl Tool for SkillScopeProbeTool {
606 fn name(&self) -> &str {
607 "skill_scope_probe"
608 }
609
610 fn description(&self) -> &str {
611 "Records the capability Turn owned by a nested Skill Agent"
612 }
613
614 fn parameters(&self) -> serde_json::Value {
615 serde_json::json!({"type": "object", "additionalProperties": false})
616 }
617
618 fn capabilities(&self, _args: &serde_json::Value) -> crate::tools::ToolCapabilities {
619 crate::tools::ToolCapabilities::parallel_safe_read(1)
620 }
621
622 async fn execute(
623 &self,
624 _args: &serde_json::Value,
625 ctx: &ToolContext,
626 ) -> Result<ToolOutput> {
627 let scope_id = ctx
628 .capability_scope_id()
629 .ok_or_else(|| anyhow!("nested Skill Tool has no capability Turn"))?
630 .to_owned();
631 self.log
632 .lock()
633 .unwrap()
634 .push(format!("registered:{scope_id}"));
635 ctx.register_capability_effect(SkillScopeProbeEffect {
636 scope_id,
637 log: Arc::clone(&self.log),
638 })?;
639 Ok(ToolOutput::success("registered"))
640 }
641 }
642
643 #[async_trait]
644 impl Tool for SkillSideEffectTool {
645 fn name(&self) -> &str {
646 "side_effect"
647 }
648
649 fn description(&self) -> &str {
650 "Records a test-only side effect"
651 }
652
653 fn parameters(&self) -> serde_json::Value {
654 serde_json::json!({"type": "object", "additionalProperties": false})
655 }
656
657 async fn execute(
658 &self,
659 _args: &serde_json::Value,
660 _ctx: &ToolContext,
661 ) -> Result<ToolOutput> {
662 self.calls.fetch_add(1, Ordering::SeqCst);
663 Ok(ToolOutput::success("unexpected-side-effect"))
664 }
665 }
666
667 #[async_trait]
668 impl LlmClient for BlockingSkillClient {
669 async fn complete(
670 &self,
671 _messages: &[Message],
672 _system: Option<&str>,
673 _tools: &[ToolDefinition],
674 ) -> Result<LlmResponse> {
675 self.calls.fetch_add(1, Ordering::SeqCst);
676 self.started.notify_one();
677 std::future::pending::<Result<LlmResponse>>().await
678 }
679
680 async fn complete_streaming(
681 &self,
682 _messages: &[Message],
683 _system: Option<&str>,
684 _tools: &[ToolDefinition],
685 _cancel_token: CancellationToken,
686 ) -> Result<mpsc::Receiver<StreamEvent>> {
687 anyhow::bail!("streaming not used in SkillTool cancellation tests")
688 }
689 }
690
691 fn test_skill_registry() -> Arc<SkillRegistry> {
692 let registry = Arc::new(SkillRegistry::new());
693 registry.register_unchecked(Arc::new(Skill {
694 name: "test-skill".to_string(),
695 description: "Run a focused skill".to_string(),
696 allowed_tools: None,
697 disable_model_invocation: false,
698 kind: SkillKind::Instruction,
699 content: "Reply with the skill result.".to_string(),
700 tags: vec!["focus".to_string()],
701 version: None,
702 }));
703 registry
704 }
705
706 #[test]
707 fn test_skill_permission_policy() {
708 let skill = Skill {
709 name: "test-skill".to_string(),
710 description: "Test".to_string(),
711 allowed_tools: Some("read(*), grep(*)".to_string()),
712 disable_model_invocation: false,
713 kind: SkillKind::Instruction,
714 content: String::new(),
715 tags: Vec::new(),
716 version: None,
717 };
718
719 let policy = SkillTool::create_skill_permission_policy(&skill);
720
721 assert_eq!(
723 policy.check("read", &serde_json::json!({})),
724 PermissionDecision::Allow
725 );
726 assert_eq!(
727 policy.check(
728 "search",
729 &serde_json::json!({"mode": "grep", "query": "TODO"}),
730 ),
731 PermissionDecision::Allow
732 );
733
734 assert_eq!(
736 policy.check("write", &serde_json::json!({})),
737 PermissionDecision::Deny
738 );
739 }
740
741 #[test]
742 fn test_skill_permission_policy_denies_when_unspecified() {
743 let skill = Skill {
744 name: "test-skill".to_string(),
745 description: "Test".to_string(),
746 allowed_tools: None,
747 disable_model_invocation: false,
748 kind: SkillKind::Instruction,
749 content: String::new(),
750 tags: Vec::new(),
751 version: None,
752 };
753
754 let policy = SkillTool::create_skill_permission_policy(&skill);
755
756 assert_eq!(
757 policy.check("bash", &serde_json::json!({"command": "python --version"})),
758 PermissionDecision::Deny
759 );
760 assert_eq!(
761 policy.check("read", &serde_json::json!({"file_path": "SKILL.md"})),
762 PermissionDecision::Deny
763 );
764 }
765
766 #[test]
767 fn test_skill_permission_policy_accepts_legacy_allowed_tools() {
768 let skill = Skill {
769 name: "test-skill".to_string(),
770 description: "Test".to_string(),
771 allowed_tools: Some("Read Write Edit Bash".to_string()),
772 disable_model_invocation: false,
773 kind: SkillKind::Instruction,
774 content: String::new(),
775 tags: Vec::new(),
776 version: None,
777 };
778
779 let policy = SkillTool::create_skill_permission_policy(&skill);
780
781 assert_eq!(
782 policy.check("bash", &serde_json::json!({"command": "python --version"})),
783 PermissionDecision::Allow
784 );
785 assert_eq!(
786 policy.check("search", &serde_json::json!({"mode": "grep", "query": "x"}),),
787 PermissionDecision::Deny
788 );
789 }
790
791 #[test]
792 fn test_skill_permission_policy_accepts_wildcard_allowed_tools() {
793 let skill = Skill {
794 name: "test-skill".to_string(),
795 description: "Test".to_string(),
796 allowed_tools: Some("*".to_string()),
797 disable_model_invocation: false,
798 kind: SkillKind::Instruction,
799 content: String::new(),
800 tags: Vec::new(),
801 version: None,
802 };
803
804 let policy = SkillTool::create_skill_permission_policy(&skill);
805
806 assert_eq!(
807 policy.check("bash", &serde_json::json!({"command": "python --version"})),
808 PermissionDecision::Allow
809 );
810 assert_eq!(
811 policy.check("parallel_task", &serde_json::json!({"tasks": []})),
812 PermissionDecision::Allow
813 );
814 }
815
816 #[test]
817 fn test_skill_args_accepts_documented_shape() {
818 let args =
819 SkillArgs::from_tool_args(&serde_json::json!({"skill_name": "code-review"})).unwrap();
820 assert_eq!(args.skill_name, "code-review");
821 assert_eq!(args.prompt, None);
822 }
823
824 #[test]
825 fn test_skill_args_accepts_common_aliases_and_wrappers() {
826 let camel =
827 SkillArgs::from_tool_args(&serde_json::json!({"skillName": "code-review"})).unwrap();
828 assert_eq!(camel.skill_name, "code-review");
829
830 let name = SkillArgs::from_tool_args(&serde_json::json!({
831 "name": "code-review",
832 "query": "review this patch"
833 }))
834 .unwrap();
835 assert_eq!(name.skill_name, "code-review");
836 assert_eq!(name.prompt.as_deref(), Some("review this patch"));
837
838 let nested = SkillArgs::from_tool_args(&serde_json::json!({
839 "input": {
840 "skill_name": "code-review",
841 "prompt": "review this patch"
842 }
843 }))
844 .unwrap();
845 assert_eq!(nested.skill_name, "code-review");
846 assert_eq!(nested.prompt.as_deref(), Some("review this patch"));
847
848 let direct = SkillArgs::from_tool_args(&serde_json::json!("code-review")).unwrap();
849 assert_eq!(direct.skill_name, "code-review");
850 }
851
852 #[test]
853 fn test_skill_args_missing_skill_name_errors() {
854 let err =
855 SkillArgs::from_tool_args(&serde_json::json!({"prompt": "do something"})).unwrap_err();
856 assert!(err.to_string().contains("missing field 'skill_name'"));
857 }
858
859 #[test]
860 fn test_search_skills_args_accepts_string_and_object() {
861 let direct = SearchSkillsArgs::from_tool_args(&serde_json::json!("review code")).unwrap();
862 assert_eq!(direct.query, "review code");
863 assert_eq!(direct.limit, None);
864
865 let object =
866 SearchSkillsArgs::from_tool_args(&serde_json::json!({"query": "review", "limit": 2}))
867 .unwrap();
868 assert_eq!(object.query, "review");
869 assert_eq!(object.limit, Some(2));
870 }
871
872 #[tokio::test]
873 async fn test_search_skills_tool_returns_matching_skills() {
874 let registry = Arc::new(SkillRegistry::new());
875 registry.register_unchecked(Arc::new(Skill {
876 name: "code-review".to_string(),
877 description: "Review code changes".to_string(),
878 allowed_tools: Some("read(*), grep(*)".to_string()),
879 disable_model_invocation: false,
880 kind: SkillKind::Instruction,
881 content: "Review instructions".to_string(),
882 tags: vec!["review".to_string()],
883 version: None,
884 }));
885
886 let tool = SearchSkillsTool::new(registry);
887 let result = tool
888 .execute(
889 &serde_json::json!({"query": "review"}),
890 &ToolContext::new(PathBuf::from("/tmp")),
891 )
892 .await
893 .unwrap();
894
895 assert!(result.success);
896 assert!(result.content.contains("code-review"));
897 assert_eq!(result.metadata.unwrap()["skills"][0]["name"], "code-review");
898 }
899
900 #[tokio::test]
901 async fn test_search_skills_tool_clamps_limit_and_excludes_personas() {
902 let registry = Arc::new(SkillRegistry::new());
903 for index in 0..25 {
904 registry.register_unchecked(Arc::new(Skill {
905 name: format!("review-{index:02}"),
906 description: "Review code changes".to_string(),
907 allowed_tools: Some("read(*)".to_string()),
908 disable_model_invocation: false,
909 kind: SkillKind::Instruction,
910 content: "Review instructions".to_string(),
911 tags: vec!["review".to_string()],
912 version: None,
913 }));
914 }
915 registry.register_unchecked(Arc::new(Skill {
916 name: "review-persona".to_string(),
917 description: "Review persona".to_string(),
918 allowed_tools: None,
919 disable_model_invocation: false,
920 kind: SkillKind::Persona,
921 content: "Persona instructions".to_string(),
922 tags: vec!["review".to_string()],
923 version: None,
924 }));
925
926 let tool = SearchSkillsTool::new(registry);
927 let result = tool
928 .execute(
929 &serde_json::json!({"query": "review", "limit": 100}),
930 &ToolContext::new(PathBuf::from("/tmp")),
931 )
932 .await
933 .unwrap();
934
935 let metadata = result.metadata.unwrap();
936 let skills = metadata["skills"].as_array().unwrap();
937 assert_eq!(skills.len(), 20);
938 assert!(skills.iter().all(|skill| skill["kind"] == "instruction"));
939 }
940
941 #[test]
942 fn test_skill_tool_schema_enforces_canonical_shape() {
943 let registry = Arc::new(SkillRegistry::new());
944 let llm = Arc::new(MockLlmClient::new(vec![]));
945 let executor = Arc::new(ToolExecutor::new("/tmp".to_string()));
946 let tool = SkillTool::new(registry, llm, executor, AgentConfig::default());
947
948 let params = tool.parameters();
949 assert_eq!(params["type"], "object");
950 assert_eq!(params["additionalProperties"], serde_json::json!(false));
951 assert_eq!(params["required"], serde_json::json!(["skill_name"]));
952
953 let examples = params["examples"].as_array().unwrap();
954 assert_eq!(examples[0]["skill_name"], "code-review");
955 assert!(examples[0].get("name").is_none());
956 assert!(examples[0].get("skillName").is_none());
957 }
958
959 #[tokio::test]
960 async fn test_skill_tool_execute_runs_skill_and_returns_metadata() {
961 use crate::prompts::PlanningMode;
962
963 let registry = Arc::new(SkillRegistry::new());
964 registry.register_unchecked(Arc::new(Skill {
965 name: "test-skill".to_string(),
966 description: "Run a focused skill".to_string(),
967 allowed_tools: None,
968 disable_model_invocation: false,
969 kind: SkillKind::Instruction,
970 content: "Reply with the skill result.".to_string(),
971 tags: vec!["focus".to_string()],
972 version: None,
973 }));
974
975 let llm = Arc::new(MockLlmClient::new(vec![MockLlmClient::text_response(
976 "skill completed",
977 )]));
978 let executor = Arc::new(ToolExecutor::new("/tmp".to_string()));
979 let config = AgentConfig {
981 planning_mode: PlanningMode::Disabled,
982 continuation_enabled: false,
983 ..Default::default()
984 };
985 let tool = SkillTool::new(registry, llm, executor, config);
986
987 let result = tool
988 .execute(
989 &serde_json::json!({
990 "skill_name": "test-skill",
991 "prompt": "verify the skill result"
992 }),
993 &ToolContext::new(PathBuf::from("/tmp")),
994 )
995 .await
996 .unwrap();
997
998 assert!(result.success);
999 assert_eq!(result.content, "skill completed");
1000 let metadata = result.metadata.unwrap();
1001 assert_eq!(metadata["skill_name"], "test-skill");
1002 assert_eq!(metadata["tool_calls"], 0);
1003 }
1004
1005 #[tokio::test]
1006 async fn skill_child_agent_owns_recursive_subtask_and_turn_scopes() {
1007 use crate::prompts::PlanningMode;
1008
1009 let workspace = tempfile::tempdir().unwrap();
1010 let registry = Arc::new(SkillRegistry::new());
1011 registry.register_unchecked(Arc::new(Skill {
1012 name: "scoped-skill".to_string(),
1013 description: "Exercise nested scope ownership".to_string(),
1014 allowed_tools: Some("skill_scope_probe".to_string()),
1015 disable_model_invocation: false,
1016 kind: SkillKind::Instruction,
1017 content: "Call skill_scope_probe once, then finish.".to_string(),
1018 tags: Vec::new(),
1019 version: None,
1020 }));
1021 let log = Arc::new(Mutex::new(Vec::new()));
1022 let executor = Arc::new(ToolExecutor::new(
1023 workspace.path().to_string_lossy().into_owned(),
1024 ));
1025 executor.register_dynamic_tool(Arc::new(SkillScopeProbeTool {
1026 log: Arc::clone(&log),
1027 }));
1028 let tool = SkillTool::new(
1029 registry,
1030 Arc::new(MockLlmClient::new(vec![
1031 MockLlmClient::tool_call_response(
1032 "skill-probe",
1033 "skill_scope_probe",
1034 serde_json::json!({}),
1035 ),
1036 MockLlmClient::text_response("skill complete"),
1037 ])),
1038 executor,
1039 AgentConfig {
1040 planning_mode: PlanningMode::Disabled,
1041 continuation_enabled: false,
1042 ..Default::default()
1043 },
1044 );
1045 let set = crate::capability::CapabilitySet::empty().unwrap();
1046 let ceiling = crate::capability::CapabilityCeiling::all(
1047 &set,
1048 crate::capability::WorkspaceCapabilityCeiling::all(),
1049 crate::capability::GovernanceCapabilityCeiling::none_required(),
1050 crate::capability::CapabilityExecutionCeiling::new(
1051 4,
1052 2,
1053 Some(1_000),
1054 Some(1_000),
1055 Some(5_000),
1056 )
1057 .unwrap(),
1058 )
1059 .unwrap();
1060 let session =
1061 crate::capability::CapabilityScope::<crate::capability::Session>::new_session(
1062 "session-1",
1063 set,
1064 ceiling.clone(),
1065 )
1066 .unwrap();
1067 let run = session.admit_run("run-1", ceiling).unwrap();
1068 let runtime = crate::capability::AgentCapabilityRuntime::from_run(&run);
1069 let parent_turn = runtime.begin_turn(1).unwrap();
1070 let capability_context = parent_turn.tool_context();
1071 let parent_scope_id = capability_context.foreground_scope_id().to_owned();
1072 let context = ToolContext::new(workspace.path().to_path_buf())
1073 .with_cancellation(parent_turn.cancellation())
1074 .with_capability_context(capability_context);
1075
1076 let result = tool
1077 .execute(&serde_json::json!({"skill_name": "scoped-skill"}), &context)
1078 .await
1079 .unwrap();
1080
1081 assert!(result.success, "{}", result.content);
1082 let entries = log.lock().unwrap().clone();
1083 assert_eq!(entries.len(), 2, "{entries:?}");
1084 let registered = entries[0].strip_prefix("registered:").unwrap();
1085 let closed = entries[1].strip_prefix("closed:").unwrap();
1086 assert_eq!(registered, closed);
1087 assert!(
1088 registered.starts_with(&format!("{parent_scope_id}/subtask/skill-")),
1089 "{registered}"
1090 );
1091 assert!(registered.contains("/turn/turn-1-1"), "{registered}");
1092
1093 parent_turn.close().await.unwrap();
1094 run.close().await.unwrap();
1095 session.close().await.unwrap();
1096 }
1097
1098 #[tokio::test]
1099 async fn skill_permissions_cannot_replace_the_parent_host_boundary() {
1100 use crate::prompts::PlanningMode;
1101
1102 let workspace = tempfile::tempdir().unwrap();
1103 let registry = Arc::new(SkillRegistry::new());
1104 registry.register_unchecked(Arc::new(Skill {
1105 name: "bash-skill".to_string(),
1106 description: "Attempt a shell call".to_string(),
1107 allowed_tools: Some("bash(*)".to_string()),
1108 disable_model_invocation: false,
1109 kind: SkillKind::Instruction,
1110 content: "Use Bash once.".to_string(),
1111 tags: Vec::new(),
1112 version: None,
1113 }));
1114 let llm = Arc::new(MockLlmClient::new(vec![
1115 MockLlmClient::tool_call_response(
1116 "bash-call",
1117 "bash",
1118 serde_json::json!({"command": "printf leaked > skill-boundary-leak"}),
1119 ),
1120 MockLlmClient::text_response("The host boundary rejected the call."),
1121 ]));
1122 let parent_policy = PermissionPolicy::new().deny("bash(*)");
1123 let config = AgentConfig {
1124 planning_mode: PlanningMode::Disabled,
1125 continuation_enabled: false,
1126 permission_checker: Some(Arc::new(parent_policy.clone())),
1127 permission_policy: Some(parent_policy),
1128 ..Default::default()
1129 };
1130 let tool = SkillTool::new(
1131 registry,
1132 llm,
1133 Arc::new(ToolExecutor::new(
1134 workspace.path().to_string_lossy().into_owned(),
1135 )),
1136 config,
1137 );
1138
1139 let result = tool
1140 .execute(
1141 &serde_json::json!({"skill_name": "bash-skill"}),
1142 &ToolContext::new(workspace.path().to_path_buf()),
1143 )
1144 .await
1145 .unwrap();
1146
1147 assert!(result.success, "{}", result.content);
1148 assert!(
1149 !workspace.path().join("skill-boundary-leak").exists(),
1150 "a skill-local allow-list must not bypass the parent host boundary"
1151 );
1152 }
1153
1154 #[tokio::test]
1155 async fn host_direct_context_does_not_leak_into_skill_model_orchestrators() {
1156 use crate::prompts::PlanningMode;
1157
1158 struct RecordingBoundary {
1159 checked: Arc<Mutex<Vec<String>>>,
1160 }
1161
1162 impl crate::permissions::PermissionChecker for RecordingBoundary {
1163 fn check(&self, tool_name: &str, _args: &serde_json::Value) -> PermissionDecision {
1164 self.checked.lock().unwrap().push(tool_name.to_string());
1165 if tool_name == "side_effect" {
1166 PermissionDecision::Deny
1167 } else {
1168 PermissionDecision::Allow
1169 }
1170 }
1171 }
1172
1173 let workspace = tempfile::tempdir().unwrap();
1174 let calls = Arc::new(AtomicUsize::new(0));
1175 let checked = Arc::new(Mutex::new(Vec::new()));
1176 let registry = Arc::new(SkillRegistry::new());
1177 registry.register_unchecked(Arc::new(Skill {
1178 name: "orchestrator-skill".to_string(),
1179 description: "Exercise governed orchestrators".to_string(),
1180 allowed_tools: Some("batch(*), program(*), side_effect(*)".to_string()),
1181 disable_model_invocation: false,
1182 kind: SkillKind::Instruction,
1183 content: "Run the requested orchestration.".to_string(),
1184 tags: Vec::new(),
1185 version: None,
1186 }));
1187 let llm = Arc::new(MockLlmClient::new(vec![
1188 MockLlmClient::tool_call_response(
1189 "model-batch",
1190 "batch",
1191 serde_json::json!({
1192 "invocations": [{
1193 "tool": "program",
1194 "args": {
1195 "type": "script",
1196 "language": "javascript",
1197 "source": "async function run(ctx) { return await ctx.tool('side_effect', {}); }",
1198 "allowed_tools": ["side_effect"]
1199 }
1200 }]
1201 }),
1202 ),
1203 MockLlmClient::text_response("The boundary held."),
1204 ]));
1205 let executor = Arc::new(ToolExecutor::new(
1206 workspace.path().to_string_lossy().into_owned(),
1207 ));
1208 executor.register_dynamic_tool(Arc::new(SkillSideEffectTool {
1209 calls: Arc::clone(&calls),
1210 }));
1211 let tool = SkillTool::new(
1212 registry,
1213 llm,
1214 executor,
1215 AgentConfig {
1216 planning_mode: PlanningMode::Disabled,
1217 continuation_enabled: false,
1218 permission_checker: Some(Arc::new(RecordingBoundary {
1219 checked: Arc::clone(&checked),
1220 })),
1221 ..Default::default()
1222 },
1223 );
1224 let context = ToolContext::new(workspace.path().to_path_buf())
1225 .with_host_direct_policy(crate::tools::HostDirectPolicy::TrustedControlPlane);
1226
1227 let result = tool
1228 .execute(
1229 &serde_json::json!({"skill_name": "orchestrator-skill"}),
1230 &context,
1231 )
1232 .await
1233 .unwrap();
1234
1235 assert!(result.success, "{}", result.content);
1236 assert_eq!(result.content, "The boundary held.");
1237 assert_eq!(calls.load(Ordering::SeqCst), 0);
1238 let checked = checked.lock().unwrap();
1239 for expected in ["batch", "program", "side_effect"] {
1240 assert!(
1241 checked.iter().any(|tool| tool == expected),
1242 "{expected} must cross the parent permission boundary: {checked:?}"
1243 );
1244 }
1245 }
1246
1247 #[tokio::test]
1248 async fn skill_child_llm_call_uses_parent_session_budget_scope() {
1249 use crate::prompts::PlanningMode;
1250
1251 let guard = Arc::new(SkillBudgetGuard::default());
1252 let config = AgentConfig {
1253 planning_mode: PlanningMode::Disabled,
1254 continuation_enabled: false,
1255 budget_guard: Some(Arc::clone(&guard) as Arc<dyn BudgetGuard>),
1256 ..Default::default()
1257 };
1258 let tool = SkillTool::new(
1259 test_skill_registry(),
1260 Arc::new(MockLlmClient::new(vec![MockLlmClient::text_response(
1261 "skill completed",
1262 )])),
1263 Arc::new(ToolExecutor::new("/tmp".to_string())),
1264 config,
1265 );
1266 let ctx = ToolContext::new(PathBuf::from("/tmp")).with_session_id("parent-session");
1267
1268 let result = tool
1269 .execute(&serde_json::json!({"skill_name": "test-skill"}), &ctx)
1270 .await
1271 .unwrap();
1272
1273 assert!(result.success);
1274 assert_eq!(guard.checks.load(Ordering::SeqCst), 1);
1275 assert_eq!(guard.records.load(Ordering::SeqCst), 1);
1276 assert_eq!(
1277 guard.sessions.lock().unwrap().as_slice(),
1278 &["parent-session".to_string()]
1279 );
1280 }
1281
1282 #[tokio::test]
1283 async fn skill_child_llm_call_stops_on_parent_cancellation() {
1284 use crate::prompts::PlanningMode;
1285
1286 let started = Arc::new(Notify::new());
1287 let calls = Arc::new(AtomicUsize::new(0));
1288 let tool = SkillTool::new(
1289 test_skill_registry(),
1290 Arc::new(BlockingSkillClient {
1291 started: Arc::clone(&started),
1292 calls: Arc::clone(&calls),
1293 }),
1294 Arc::new(ToolExecutor::new("/tmp".to_string())),
1295 AgentConfig {
1296 planning_mode: PlanningMode::Disabled,
1297 continuation_enabled: false,
1298 ..Default::default()
1299 },
1300 );
1301 let cancellation = CancellationToken::new();
1302 let ctx = ToolContext::new(PathBuf::from("/tmp"))
1303 .with_session_id("parent-session")
1304 .with_cancellation(cancellation.clone());
1305 let started_wait = started.notified();
1306 let run = tokio::spawn(async move {
1307 tool.execute(&serde_json::json!({"skill_name": "test-skill"}), &ctx)
1308 .await
1309 });
1310
1311 tokio::time::timeout(Duration::from_secs(1), started_wait)
1312 .await
1313 .expect("skill provider call should start");
1314 cancellation.cancel();
1315 let error = tokio::time::timeout(Duration::from_secs(1), run)
1316 .await
1317 .expect("parent cancellation must stop the skill child")
1318 .expect("skill join should succeed")
1319 .expect_err("cancelled skill must not return success");
1320
1321 assert!(error.to_string().contains("cancelled"));
1322 assert_eq!(calls.load(Ordering::SeqCst), 1);
1323 }
1324
1325 #[tokio::test]
1326 async fn test_skill_tool_execute_errors_for_unknown_skill() {
1327 let llm = Arc::new(MockLlmClient::new(vec![MockLlmClient::text_response(
1328 "unused",
1329 )]));
1330 let executor = Arc::new(ToolExecutor::new("/tmp".to_string()));
1331 let tool = SkillTool::new(
1332 Arc::new(SkillRegistry::new()),
1333 llm,
1334 executor,
1335 AgentConfig::default(),
1336 );
1337
1338 let err = tool
1339 .execute(
1340 &serde_json::json!({"skill_name": "missing-skill"}),
1341 &ToolContext::new(PathBuf::from("/tmp")),
1342 )
1343 .await
1344 .unwrap_err();
1345
1346 assert!(err.to_string().contains("Skill 'missing-skill' not found"));
1347 }
1348}