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