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