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 if skill.disable_model_invocation {
350 anyhow::bail!(
351 "Skill '{}' disables model invocation and cannot be invoked via the Skill tool",
352 skill.name
353 );
354 }
355
356 let skill_permission_policy = Self::create_skill_permission_policy(&skill);
358
359 let mut skill_config = self.base_config.clone();
361 if ctx.has_run_governance() {
362 skill_config.permission_checker = ctx.run_permission_checker();
363 skill_config.confirmation_manager = ctx.run_confirmation_manager();
364 }
365
366 let parent_checker = skill_config.permission_checker.take();
371 let parent_policy = skill_config.permission_policy.clone();
372 skill_config.permission_checker = Some(crate::child_run::compose_permission_checker(
373 Arc::new(skill_permission_policy.clone()),
374 Some(skill_permission_policy.clone()),
375 parent_checker,
376 parent_policy,
377 ));
378 skill_config.permission_policy = Some(skill_permission_policy);
379 skill_config.enforce_active_skill_tool_restrictions = true;
380
381 let temp_registry = Arc::new(SkillRegistry::new());
383 temp_registry.register(skill.clone())?;
384 skill_config.skill_registry = Some(temp_registry);
385
386 skill_config.prompt_slots.role = Some(format!(
388 "You are executing the '{}' skill.\n\n{}\n\n{}",
389 skill.name, skill.description, skill.content
390 ));
391
392 let capability_subtask = ctx
393 .capability_context()
394 .map(|context| context.admit_subtask(format!("skill-{}", uuid::Uuid::new_v4()), false))
395 .transpose()?;
396 let cancellation = capability_subtask.as_ref().map_or_else(
397 || ctx.cancellation_token(),
398 crate::capability::AgentCapabilitySubtask::cancellation,
399 );
400
401 let tool_executor = self
404 .tool_executor
405 .resolve()
406 .ok_or_else(|| anyhow!("Skill tool executor is closed"))?;
407 let mut agent_loop = AgentLoop::new(
408 self.llm_client.clone(),
409 tool_executor,
410 ctx.clone(),
411 skill_config,
412 );
413 if let Some(subtask) = capability_subtask.as_ref() {
414 agent_loop = agent_loop.with_capability_runtime(subtask.runtime());
415 }
416
417 let prompt = args
419 .prompt
420 .unwrap_or_else(|| format!("Execute the '{}' skill", skill.name));
421
422 let watch = crate::porcelain::Watch::start(&ctx.workspace).await;
427 let execution = agent_loop
428 .execute_with_session(
429 &[],
430 &prompt,
431 ctx.session_id.as_deref(),
432 None,
433 Some(&cancellation),
434 )
435 .await;
436 let cancelled = cancellation.is_cancelled();
437 let close = close_skill_capability_subtask(capability_subtask.as_ref()).await;
438 let changed = watch.finish(&ctx.workspace).await;
439 let result = match (execution, close) {
440 (Ok(result), Ok(())) => result,
441 (Ok(_), Err(close_error)) => {
442 return skill_effect_or_error(&skill.name, 0, &close_error.to_string(), &changed);
443 }
444 (Err(error), Ok(())) => {
445 return skill_effect_or_error(&skill.name, 0, &error.to_string(), &changed);
446 }
447 (Err(error), Err(close_error)) => {
448 tracing::warn!(
449 error = %close_error,
450 "Capability Skill Subtask close also failed after execution failure"
451 );
452 return skill_effect_or_error(&skill.name, 0, &error.to_string(), &changed);
453 }
454 };
455 if cancelled {
456 return skill_effect_or_error(
457 &skill.name,
458 result.tool_calls_count,
459 &format!("Skill '{}' cancelled by caller", skill.name),
460 &changed,
461 );
462 }
463
464 Ok(skill_output(
467 &skill.name,
468 result.tool_calls_count,
469 result.text,
470 Some(result.usage),
471 &changed,
472 ))
473 }
474}
475
476fn skill_output(
477 skill_name: &str,
478 tool_calls: usize,
479 content: String,
480 usage: Option<crate::llm::TokenUsage>,
481 changed: &[String],
482) -> ToolOutput {
483 let mut metadata = serde_json::json!({
484 "skill_name": skill_name,
485 "tool_calls": tool_calls,
486 });
487 if let Some(usage) = usage {
488 if let Some(object) = metadata.as_object_mut() {
489 object.insert("usage".to_string(), serde_json::json!(usage));
490 }
491 }
492 let mut metadata = Some(metadata);
493 crate::porcelain::attach(&mut metadata, changed);
494 ToolOutput {
495 content,
496 success: true,
497 metadata,
498 images: Vec::new(),
499 trust: crate::tools::ToolResultTrustV1::WorkspaceData,
500 error_kind: None,
501 }
502}
503
504fn skill_effect_or_error(
505 skill_name: &str,
506 tool_calls: usize,
507 error: &str,
508 changed: &[String],
509) -> Result<ToolOutput> {
510 if changed.is_empty() {
511 anyhow::bail!("{error}");
512 }
513 Ok(skill_output(
514 skill_name,
515 tool_calls,
516 error.to_string(),
517 None,
518 changed,
519 ))
520}
521
522async fn close_skill_capability_subtask(
523 subtask: Option<&crate::capability::AgentCapabilitySubtask>,
524) -> Result<()> {
525 let Some(subtask) = subtask else {
526 return Ok(());
527 };
528 let report = subtask.close().await?;
529 if !report.is_clean() {
530 anyhow::bail!(
531 "Capability Skill Subtask close was incomplete (tasks failed: {}, tasks timed out: {}, child scopes failed: {}, child scopes timed out: {}, effects failed: {}, effects timed out: {})",
532 report.tasks_failed,
533 report.tasks_timed_out,
534 report.child_scopes_failed,
535 report.child_scopes_timed_out,
536 report.effects_failed,
537 report.effects_timed_out,
538 );
539 }
540 Ok(())
541}
542
543#[cfg(test)]
544mod tests {
545 use super::*;
546 use crate::budget::{BudgetDecision, BudgetGuard};
547 use crate::llm::{
548 ContentBlock, LlmClient, LlmResponse, Message, StreamEvent, TokenUsage, ToolDefinition,
549 };
550 use crate::skills::SkillKind;
551 use crate::tools::ToolContext;
552 use anyhow::Result;
553 use async_trait::async_trait;
554 use std::sync::atomic::{AtomicUsize, Ordering};
555 use std::sync::Mutex;
556 use std::time::Duration;
557 use tokio::sync::{mpsc, Notify};
558 use tokio_util::sync::CancellationToken;
559
560 struct MockLlmClient {
561 responses: Mutex<Vec<LlmResponse>>,
562 }
563
564 impl MockLlmClient {
565 fn new(responses: Vec<LlmResponse>) -> Self {
566 Self {
567 responses: Mutex::new(responses),
568 }
569 }
570
571 fn text_response(text: &str) -> LlmResponse {
572 LlmResponse {
573 message: Message {
574 role: "assistant".to_string(),
575 content: vec![ContentBlock::Text {
576 text: text.to_string(),
577 }],
578 reasoning_content: None,
579 transcript_text: None,
580 transcript_visibility: Default::default(),
581 },
582 usage: TokenUsage {
583 prompt_tokens: 10,
584 completion_tokens: 5,
585 total_tokens: 15,
586 cache_read_tokens: None,
587 cache_write_tokens: None,
588 },
589 stop_reason: Some("end_turn".to_string()),
590 token_logprobs: Vec::new(),
591 meta: None,
592 }
593 }
594
595 fn tool_call_response(id: &str, name: &str, input: serde_json::Value) -> LlmResponse {
596 LlmResponse {
597 message: Message {
598 role: "assistant".to_string(),
599 content: vec![ContentBlock::ToolUse {
600 id: id.to_string(),
601 name: name.to_string(),
602 input,
603 }],
604 reasoning_content: None,
605 transcript_text: None,
606 transcript_visibility: Default::default(),
607 },
608 usage: TokenUsage {
609 prompt_tokens: 10,
610 completion_tokens: 5,
611 total_tokens: 15,
612 cache_read_tokens: None,
613 cache_write_tokens: None,
614 },
615 stop_reason: Some("tool_use".to_string()),
616 token_logprobs: Vec::new(),
617 meta: None,
618 }
619 }
620 }
621
622 #[async_trait]
623 impl LlmClient for MockLlmClient {
624 async fn complete(
625 &self,
626 _messages: &[Message],
627 _system: Option<&str>,
628 _tools: &[ToolDefinition],
629 ) -> Result<LlmResponse> {
630 let mut responses = self.responses.lock().unwrap();
631 if responses.is_empty() {
632 anyhow::bail!("No more mock responses available");
633 }
634 Ok(responses.remove(0))
635 }
636
637 async fn complete_streaming(
638 &self,
639 _messages: &[Message],
640 _system: Option<&str>,
641 _tools: &[ToolDefinition],
642 _cancel_token: tokio_util::sync::CancellationToken,
643 ) -> Result<mpsc::Receiver<StreamEvent>> {
644 anyhow::bail!("streaming not used in SkillTool tests")
645 }
646 }
647
648 #[test]
649 fn skill_tool_does_not_retain_its_executor() {
650 let executor = Arc::new(ToolExecutor::new("skill-cycle-test".to_string()));
651 let lifetime = Arc::downgrade(&executor);
652 let _tool = SkillTool::new_registry_bound(
653 Arc::new(SkillRegistry::new()),
654 Arc::new(MockLlmClient::new(Vec::new())),
655 executor.clone(),
656 AgentConfig::default(),
657 );
658
659 drop(executor);
660
661 assert!(lifetime.upgrade().is_none());
662 }
663
664 #[derive(Default)]
665 struct SkillBudgetGuard {
666 checks: AtomicUsize,
667 records: AtomicUsize,
668 sessions: Mutex<Vec<String>>,
669 }
670
671 #[async_trait]
672 impl BudgetGuard for SkillBudgetGuard {
673 async fn check_before_llm(
674 &self,
675 session_id: &str,
676 _estimated_prompt_tokens: usize,
677 ) -> BudgetDecision {
678 self.checks.fetch_add(1, Ordering::SeqCst);
679 self.sessions.lock().unwrap().push(session_id.to_string());
680 BudgetDecision::Allow
681 }
682
683 async fn record_after_llm(&self, _session_id: &str, _usage: &TokenUsage) {
684 self.records.fetch_add(1, Ordering::SeqCst);
685 }
686 }
687
688 struct BlockingSkillClient {
689 started: Arc<Notify>,
690 calls: Arc<AtomicUsize>,
691 }
692
693 struct SkillSideEffectTool {
694 calls: Arc<AtomicUsize>,
695 }
696
697 struct SkillScopeProbeTool {
698 log: Arc<Mutex<Vec<String>>>,
699 }
700
701 struct SkillScopeProbeEffect {
702 scope_id: String,
703 log: Arc<Mutex<Vec<String>>>,
704 }
705
706 #[async_trait]
707 impl crate::capability::CapabilityEffect for SkillScopeProbeEffect {
708 fn name(&self) -> &str {
709 "test.skill.scope.effect"
710 }
711
712 async fn close(
713 self: Box<Self>,
714 ) -> std::result::Result<(), crate::capability::CapabilityEffectError> {
715 self.log
716 .lock()
717 .unwrap()
718 .push(format!("closed:{}", self.scope_id));
719 Ok(())
720 }
721 }
722
723 #[async_trait]
724 impl Tool for SkillScopeProbeTool {
725 fn name(&self) -> &str {
726 "skill_scope_probe"
727 }
728
729 fn description(&self) -> &str {
730 "Records the capability Turn owned by a nested Skill Agent"
731 }
732
733 fn parameters(&self) -> serde_json::Value {
734 serde_json::json!({"type": "object", "additionalProperties": false})
735 }
736
737 fn capabilities(&self, _args: &serde_json::Value) -> crate::tools::ToolCapabilities {
738 crate::tools::ToolCapabilities::parallel_safe_read(1)
739 }
740
741 async fn execute(
742 &self,
743 _args: &serde_json::Value,
744 ctx: &ToolContext,
745 ) -> Result<ToolOutput> {
746 let scope_id = ctx
747 .capability_scope_id()
748 .ok_or_else(|| anyhow!("nested Skill Tool has no capability Turn"))?
749 .to_owned();
750 self.log
751 .lock()
752 .unwrap()
753 .push(format!("registered:{scope_id}"));
754 ctx.register_capability_effect(SkillScopeProbeEffect {
755 scope_id,
756 log: Arc::clone(&self.log),
757 })?;
758 Ok(ToolOutput::success("registered"))
759 }
760 }
761
762 #[async_trait]
763 impl Tool for SkillSideEffectTool {
764 fn name(&self) -> &str {
765 "side_effect"
766 }
767
768 fn description(&self) -> &str {
769 "Records a test-only side effect"
770 }
771
772 fn parameters(&self) -> serde_json::Value {
773 serde_json::json!({"type": "object", "additionalProperties": false})
774 }
775
776 async fn execute(
777 &self,
778 _args: &serde_json::Value,
779 _ctx: &ToolContext,
780 ) -> Result<ToolOutput> {
781 self.calls.fetch_add(1, Ordering::SeqCst);
782 Ok(ToolOutput::success("unexpected-side-effect"))
783 }
784 }
785
786 #[async_trait]
787 impl LlmClient for BlockingSkillClient {
788 async fn complete(
789 &self,
790 _messages: &[Message],
791 _system: Option<&str>,
792 _tools: &[ToolDefinition],
793 ) -> Result<LlmResponse> {
794 self.calls.fetch_add(1, Ordering::SeqCst);
795 self.started.notify_one();
796 std::future::pending::<Result<LlmResponse>>().await
797 }
798
799 async fn complete_streaming(
800 &self,
801 _messages: &[Message],
802 _system: Option<&str>,
803 _tools: &[ToolDefinition],
804 _cancel_token: CancellationToken,
805 ) -> Result<mpsc::Receiver<StreamEvent>> {
806 anyhow::bail!("streaming not used in SkillTool cancellation tests")
807 }
808 }
809
810 fn test_skill_registry() -> Arc<SkillRegistry> {
811 let registry = Arc::new(SkillRegistry::new());
812 registry.register_unchecked(Arc::new(Skill {
813 name: "test-skill".to_string(),
814 description: "Run a focused skill".to_string(),
815 allowed_tools: None,
816 disable_model_invocation: false,
817 kind: SkillKind::Instruction,
818 content: "Reply with the skill result.".to_string(),
819 tags: vec!["focus".to_string()],
820 version: None,
821 }));
822 registry
823 }
824
825 #[test]
826 fn test_skill_permission_policy() {
827 let skill = Skill {
828 name: "test-skill".to_string(),
829 description: "Test".to_string(),
830 allowed_tools: Some("read(*), grep(*)".to_string()),
831 disable_model_invocation: false,
832 kind: SkillKind::Instruction,
833 content: String::new(),
834 tags: Vec::new(),
835 version: None,
836 };
837
838 let policy = SkillTool::create_skill_permission_policy(&skill);
839
840 assert_eq!(
842 policy.check("read", &serde_json::json!({})),
843 PermissionDecision::Allow
844 );
845 assert_eq!(
846 policy.check(
847 "search",
848 &serde_json::json!({"mode": "grep", "query": "TODO"}),
849 ),
850 PermissionDecision::Allow
851 );
852
853 assert_eq!(
855 policy.check("write", &serde_json::json!({})),
856 PermissionDecision::Deny
857 );
858 }
859
860 #[test]
861 fn test_skill_permission_policy_denies_when_unspecified() {
862 let skill = Skill {
863 name: "test-skill".to_string(),
864 description: "Test".to_string(),
865 allowed_tools: None,
866 disable_model_invocation: false,
867 kind: SkillKind::Instruction,
868 content: String::new(),
869 tags: Vec::new(),
870 version: None,
871 };
872
873 let policy = SkillTool::create_skill_permission_policy(&skill);
874
875 assert_eq!(
876 policy.check("bash", &serde_json::json!({"command": "python --version"})),
877 PermissionDecision::Deny
878 );
879 assert_eq!(
880 policy.check("read", &serde_json::json!({"file_path": "SKILL.md"})),
881 PermissionDecision::Deny
882 );
883 }
884
885 #[test]
886 fn test_skill_permission_policy_accepts_legacy_allowed_tools() {
887 let skill = Skill {
888 name: "test-skill".to_string(),
889 description: "Test".to_string(),
890 allowed_tools: Some("Read Write Edit Bash".to_string()),
891 disable_model_invocation: false,
892 kind: SkillKind::Instruction,
893 content: String::new(),
894 tags: Vec::new(),
895 version: None,
896 };
897
898 let policy = SkillTool::create_skill_permission_policy(&skill);
899
900 assert_eq!(
901 policy.check("bash", &serde_json::json!({"command": "python --version"})),
902 PermissionDecision::Allow
903 );
904 assert_eq!(
905 policy.check("search", &serde_json::json!({"mode": "grep", "query": "x"}),),
906 PermissionDecision::Deny
907 );
908 }
909
910 #[test]
911 fn test_skill_permission_policy_accepts_wildcard_allowed_tools() {
912 let skill = Skill {
913 name: "test-skill".to_string(),
914 description: "Test".to_string(),
915 allowed_tools: Some("*".to_string()),
916 disable_model_invocation: false,
917 kind: SkillKind::Instruction,
918 content: String::new(),
919 tags: Vec::new(),
920 version: None,
921 };
922
923 let policy = SkillTool::create_skill_permission_policy(&skill);
924
925 assert_eq!(
926 policy.check("bash", &serde_json::json!({"command": "python --version"})),
927 PermissionDecision::Allow
928 );
929 assert_eq!(
930 policy.check("parallel_task", &serde_json::json!({"tasks": []})),
931 PermissionDecision::Allow
932 );
933 }
934
935 #[test]
936 fn test_skill_args_accepts_documented_shape() {
937 let args =
938 SkillArgs::from_tool_args(&serde_json::json!({"skill_name": "code-review"})).unwrap();
939 assert_eq!(args.skill_name, "code-review");
940 assert_eq!(args.prompt, None);
941 }
942
943 #[test]
944 fn test_skill_args_accepts_common_aliases_and_wrappers() {
945 let camel =
946 SkillArgs::from_tool_args(&serde_json::json!({"skillName": "code-review"})).unwrap();
947 assert_eq!(camel.skill_name, "code-review");
948
949 let name = SkillArgs::from_tool_args(&serde_json::json!({
950 "name": "code-review",
951 "query": "review this patch"
952 }))
953 .unwrap();
954 assert_eq!(name.skill_name, "code-review");
955 assert_eq!(name.prompt.as_deref(), Some("review this patch"));
956
957 let nested = SkillArgs::from_tool_args(&serde_json::json!({
958 "input": {
959 "skill_name": "code-review",
960 "prompt": "review this patch"
961 }
962 }))
963 .unwrap();
964 assert_eq!(nested.skill_name, "code-review");
965 assert_eq!(nested.prompt.as_deref(), Some("review this patch"));
966
967 let direct = SkillArgs::from_tool_args(&serde_json::json!("code-review")).unwrap();
968 assert_eq!(direct.skill_name, "code-review");
969 }
970
971 #[test]
972 fn test_skill_args_missing_skill_name_errors() {
973 let err =
974 SkillArgs::from_tool_args(&serde_json::json!({"prompt": "do something"})).unwrap_err();
975 assert!(err.to_string().contains("missing field 'skill_name'"));
976 }
977
978 #[test]
979 fn test_search_skills_args_accepts_string_and_object() {
980 let direct = SearchSkillsArgs::from_tool_args(&serde_json::json!("review code")).unwrap();
981 assert_eq!(direct.query, "review code");
982 assert_eq!(direct.limit, None);
983
984 let object =
985 SearchSkillsArgs::from_tool_args(&serde_json::json!({"query": "review", "limit": 2}))
986 .unwrap();
987 assert_eq!(object.query, "review");
988 assert_eq!(object.limit, Some(2));
989 }
990
991 #[tokio::test]
992 async fn test_search_skills_tool_returns_matching_skills() {
993 let hermetic_root = crate::test_support::hermetic_workspace();
994 let registry = Arc::new(SkillRegistry::new());
995 registry.register_unchecked(Arc::new(Skill {
996 name: "code-review".to_string(),
997 description: "Review code changes".to_string(),
998 allowed_tools: Some("read(*), grep(*)".to_string()),
999 disable_model_invocation: false,
1000 kind: SkillKind::Instruction,
1001 content: "Review instructions".to_string(),
1002 tags: vec!["review".to_string()],
1003 version: None,
1004 }));
1005
1006 let tool = SearchSkillsTool::new(registry);
1007 let result = tool
1008 .execute(
1009 &serde_json::json!({"query": "review"}),
1010 &ToolContext::new(hermetic_root.clone()),
1011 )
1012 .await
1013 .unwrap();
1014
1015 assert!(result.success);
1016 assert!(result.content.contains("code-review"));
1017 assert_eq!(result.metadata.unwrap()["skills"][0]["name"], "code-review");
1018 }
1019
1020 #[tokio::test]
1021 async fn test_search_skills_tool_clamps_limit_and_excludes_personas() {
1022 let hermetic_root = crate::test_support::hermetic_workspace();
1023 let registry = Arc::new(SkillRegistry::new());
1024 for index in 0..25 {
1025 registry.register_unchecked(Arc::new(Skill {
1026 name: format!("review-{index:02}"),
1027 description: "Review code changes".to_string(),
1028 allowed_tools: Some("read(*)".to_string()),
1029 disable_model_invocation: false,
1030 kind: SkillKind::Instruction,
1031 content: "Review instructions".to_string(),
1032 tags: vec!["review".to_string()],
1033 version: None,
1034 }));
1035 }
1036 registry.register_unchecked(Arc::new(Skill {
1037 name: "review-persona".to_string(),
1038 description: "Review persona".to_string(),
1039 allowed_tools: None,
1040 disable_model_invocation: false,
1041 kind: SkillKind::Persona,
1042 content: "Persona instructions".to_string(),
1043 tags: vec!["review".to_string()],
1044 version: None,
1045 }));
1046
1047 let tool = SearchSkillsTool::new(registry);
1048 let result = tool
1049 .execute(
1050 &serde_json::json!({"query": "review", "limit": 100}),
1051 &ToolContext::new(hermetic_root.clone()),
1052 )
1053 .await
1054 .unwrap();
1055
1056 let metadata = result.metadata.unwrap();
1057 let skills = metadata["skills"].as_array().unwrap();
1058 assert_eq!(skills.len(), 20);
1059 assert!(skills.iter().all(|skill| skill["kind"] == "instruction"));
1060 }
1061
1062 #[test]
1063 fn test_skill_tool_schema_enforces_canonical_shape() {
1064 let hermetic_root = crate::test_support::hermetic_workspace();
1065 let registry = Arc::new(SkillRegistry::new());
1066 let llm = Arc::new(MockLlmClient::new(vec![]));
1067 let executor = Arc::new(ToolExecutor::new(hermetic_root.display().to_string()));
1068 let tool = SkillTool::new(registry, llm, executor, AgentConfig::default());
1069
1070 let params = tool.parameters();
1071 assert_eq!(params["type"], "object");
1072 assert_eq!(params["additionalProperties"], serde_json::json!(false));
1073 assert_eq!(params["required"], serde_json::json!(["skill_name"]));
1074
1075 let examples = params["examples"].as_array().unwrap();
1076 assert_eq!(examples[0]["skill_name"], "code-review");
1077 assert!(examples[0].get("name").is_none());
1078 assert!(examples[0].get("skillName").is_none());
1079 }
1080
1081 #[tokio::test]
1082 async fn test_skill_tool_execute_runs_skill_and_returns_metadata() {
1083 let hermetic_root = crate::test_support::hermetic_workspace();
1084 use crate::prompts::PlanningMode;
1085
1086 let registry = Arc::new(SkillRegistry::new());
1087 registry.register_unchecked(Arc::new(Skill {
1088 name: "test-skill".to_string(),
1089 description: "Run a focused skill".to_string(),
1090 allowed_tools: None,
1091 disable_model_invocation: false,
1092 kind: SkillKind::Instruction,
1093 content: "Reply with the skill result.".to_string(),
1094 tags: vec!["focus".to_string()],
1095 version: None,
1096 }));
1097
1098 let llm = Arc::new(MockLlmClient::new(vec![MockLlmClient::text_response(
1099 "skill completed",
1100 )]));
1101 let executor = Arc::new(ToolExecutor::new(hermetic_root.display().to_string()));
1102 let config = AgentConfig {
1104 planning_mode: PlanningMode::Disabled,
1105 continuation_enabled: false,
1106 ..Default::default()
1107 };
1108 let tool = SkillTool::new(registry, llm, executor, config);
1109
1110 let result = tool
1111 .execute(
1112 &serde_json::json!({
1113 "skill_name": "test-skill",
1114 "prompt": "verify the skill result"
1115 }),
1116 &ToolContext::new(hermetic_root.clone()),
1117 )
1118 .await
1119 .unwrap();
1120
1121 assert!(result.success);
1122 assert_eq!(result.content, "skill completed");
1123 let metadata = result.metadata.unwrap();
1124 assert_eq!(metadata["skill_name"], "test-skill");
1125 assert_eq!(metadata["tool_calls"], 0);
1126 }
1127
1128 #[tokio::test]
1129 async fn review_skill_invocation_returns_fences_instead_of_not_found() {
1130 let hermetic_root = crate::test_support::hermetic_workspace();
1131 use crate::prompts::PlanningMode;
1132
1133 let registry = Arc::new(SkillRegistry::new());
1134 registry.register_host(Arc::new(Skill {
1135 name: "review".to_string(),
1136 description: "Single-document review".to_string(),
1137 allowed_tools: None,
1138 disable_model_invocation: false,
1139 kind: SkillKind::Instruction,
1140 content: "Emit fences for the injected batch only.".to_string(),
1141 tags: vec![],
1142 version: None,
1143 }));
1144
1145 let fence = "```a3s-review-finding\n{\"id\":\"f1\",\"title\":\"核对\",\"evidenceQuote\":\"交付前必须核对证据\",\"summary\":\"缺少核对\",\"guidance\":\"写明核对人\"}\n```";
1146 let llm = Arc::new(MockLlmClient::new(vec![MockLlmClient::text_response(
1147 fence,
1148 )]));
1149 let executor = Arc::new(ToolExecutor::new(hermetic_root.display().to_string()));
1150 let config = AgentConfig {
1151 planning_mode: PlanningMode::Disabled,
1152 continuation_enabled: false,
1153 ..Default::default()
1154 };
1155 let tool = SkillTool::new(registry, llm, executor, config);
1156 let result = tool
1157 .execute(
1158 &serde_json::json!({
1159 "skill_name": "review",
1160 "prompt": "审查本批注入正文"
1161 }),
1162 &ToolContext::new(hermetic_root.clone()),
1163 )
1164 .await
1165 .unwrap();
1166
1167 assert!(result.success);
1168 assert!(result.content.contains("a3s-review-finding"));
1169 assert!(!result.content.contains("not found"));
1170 }
1171
1172 #[tokio::test]
1173 async fn skill_workspace_write_is_a_parent_mutation() {
1174 use crate::prompts::PlanningMode;
1175
1176 let workspace = tempfile::tempdir().unwrap();
1177 let registry = Arc::new(SkillRegistry::new());
1178 registry.register_unchecked(Arc::new(Skill {
1179 name: "writer-skill".to_string(),
1180 description: "Write a file then narrate".to_string(),
1181 allowed_tools: Some("skill_workspace_writer".to_string()),
1182 disable_model_invocation: false,
1183 kind: SkillKind::Instruction,
1184 content: "Write the file, then finish.".to_string(),
1185 tags: Vec::new(),
1186 version: None,
1187 }));
1188 let executor = Arc::new(ToolExecutor::new(
1189 workspace.path().to_string_lossy().into_owned(),
1190 ));
1191 executor.register_dynamic_tool(Arc::new(SkillWorkspaceWriter));
1192 let tool = SkillTool::new(
1193 registry,
1194 Arc::new(MockLlmClient::new(vec![
1195 MockLlmClient::tool_call_response(
1196 "write-call",
1197 "skill_workspace_writer",
1198 serde_json::json!({}),
1199 ),
1200 MockLlmClient::text_response("done"),
1201 ])),
1202 executor,
1203 AgentConfig {
1204 planning_mode: PlanningMode::Disabled,
1205 continuation_enabled: false,
1206 ..Default::default()
1207 },
1208 );
1209 let result = tool
1210 .execute(
1211 &serde_json::json!({"skill_name": "writer-skill"}),
1212 &ToolContext::new(workspace.path().to_path_buf()).with_session_id("skill-write"),
1213 )
1214 .await
1215 .unwrap();
1216
1217 assert!(
1218 workspace.path().join("guest.txt").is_file(),
1219 "skill child did not write"
1220 );
1221 let metadata = result.metadata.expect("skill metadata");
1222 let paths = metadata["changed_paths"].as_array().expect("changed_paths");
1223 assert!(
1224 paths.iter().any(|path| path == "guest.txt"),
1225 "skill hid a workspace write: {metadata}"
1226 );
1227 let mut ledger = crate::harness_loop::MutationLedger::default();
1228 ledger.observe_tool("skill", 0, Some(&metadata));
1229 assert!(
1230 !ledger.is_empty(),
1231 "parent completion gate did not see the skill write"
1232 );
1233 }
1234
1235 struct SkillWorkspaceWriter;
1236
1237 #[async_trait]
1238 impl Tool for SkillWorkspaceWriter {
1239 fn name(&self) -> &str {
1240 "skill_workspace_writer"
1241 }
1242
1243 fn description(&self) -> &str {
1244 "Writes a workspace file without publishing a mutation path"
1245 }
1246
1247 fn parameters(&self) -> serde_json::Value {
1248 serde_json::json!({"type": "object", "additionalProperties": false})
1249 }
1250
1251 fn capabilities(&self, _args: &serde_json::Value) -> crate::tools::ToolCapabilities {
1252 crate::tools::ToolCapabilities::parallel_safe_read(1)
1253 }
1254
1255 async fn execute(
1256 &self,
1257 _args: &serde_json::Value,
1258 ctx: &ToolContext,
1259 ) -> Result<ToolOutput> {
1260 std::fs::write(ctx.workspace.join("guest.txt"), "hello\n")?;
1261 Ok(ToolOutput::success("wrote"))
1262 }
1263 }
1264
1265 #[tokio::test]
1266 async fn skill_child_agent_owns_recursive_subtask_and_turn_scopes() {
1267 use crate::prompts::PlanningMode;
1268
1269 let workspace = tempfile::tempdir().unwrap();
1270 let registry = Arc::new(SkillRegistry::new());
1271 registry.register_unchecked(Arc::new(Skill {
1272 name: "scoped-skill".to_string(),
1273 description: "Exercise nested scope ownership".to_string(),
1274 allowed_tools: Some("skill_scope_probe".to_string()),
1275 disable_model_invocation: false,
1276 kind: SkillKind::Instruction,
1277 content: "Call skill_scope_probe once, then finish.".to_string(),
1278 tags: Vec::new(),
1279 version: None,
1280 }));
1281 let log = Arc::new(Mutex::new(Vec::new()));
1282 let executor = Arc::new(ToolExecutor::new(
1283 workspace.path().to_string_lossy().into_owned(),
1284 ));
1285 executor.register_dynamic_tool(Arc::new(SkillScopeProbeTool {
1286 log: Arc::clone(&log),
1287 }));
1288 let tool = SkillTool::new(
1289 registry,
1290 Arc::new(MockLlmClient::new(vec![
1291 MockLlmClient::tool_call_response(
1292 "skill-probe",
1293 "skill_scope_probe",
1294 serde_json::json!({}),
1295 ),
1296 MockLlmClient::text_response("skill complete"),
1297 ])),
1298 executor,
1299 AgentConfig {
1300 planning_mode: PlanningMode::Disabled,
1301 continuation_enabled: false,
1302 ..Default::default()
1303 },
1304 );
1305 let set = crate::capability::CapabilitySet::empty().unwrap();
1306 let ceiling = crate::capability::CapabilityCeiling::all(
1307 &set,
1308 crate::capability::WorkspaceCapabilityCeiling::all(),
1309 crate::capability::GovernanceCapabilityCeiling::none_required(),
1310 crate::capability::CapabilityExecutionCeiling::new(
1311 4,
1312 2,
1313 Some(1_000),
1314 Some(1_000),
1315 Some(5_000),
1316 )
1317 .unwrap(),
1318 )
1319 .unwrap();
1320 let session =
1321 crate::capability::CapabilityScope::<crate::capability::Session>::new_session(
1322 "session-1",
1323 set,
1324 ceiling.clone(),
1325 )
1326 .unwrap();
1327 let run = session.admit_run("run-1", ceiling).unwrap();
1328 let runtime = crate::capability::AgentCapabilityRuntime::from_run(&run);
1329 let parent_turn = runtime.begin_turn(1).unwrap();
1330 let capability_context = parent_turn.tool_context();
1331 let parent_scope_id = capability_context.foreground_scope_id().to_owned();
1332 let context = ToolContext::new(workspace.path().to_path_buf())
1333 .with_cancellation(parent_turn.cancellation())
1334 .with_capability_context(capability_context);
1335
1336 let result = tool
1337 .execute(&serde_json::json!({"skill_name": "scoped-skill"}), &context)
1338 .await
1339 .unwrap();
1340
1341 assert!(result.success, "{}", result.content);
1342 let entries = log.lock().unwrap().clone();
1343 assert_eq!(entries.len(), 2, "{entries:?}");
1344 let registered = entries[0].strip_prefix("registered:").unwrap();
1345 let closed = entries[1].strip_prefix("closed:").unwrap();
1346 assert_eq!(registered, closed);
1347 assert!(
1348 registered.starts_with(&format!("{parent_scope_id}/subtask/skill-")),
1349 "{registered}"
1350 );
1351 assert!(registered.contains("/turn/turn-1-1"), "{registered}");
1352
1353 parent_turn.close().await.unwrap();
1354 run.close().await.unwrap();
1355 session.close().await.unwrap();
1356 }
1357
1358 #[tokio::test]
1359 async fn skill_permissions_cannot_replace_the_parent_host_boundary() {
1360 use crate::prompts::PlanningMode;
1361
1362 let workspace = tempfile::tempdir().unwrap();
1363 let registry = Arc::new(SkillRegistry::new());
1364 registry.register_unchecked(Arc::new(Skill {
1365 name: "bash-skill".to_string(),
1366 description: "Attempt a shell call".to_string(),
1367 allowed_tools: Some("bash(*)".to_string()),
1368 disable_model_invocation: false,
1369 kind: SkillKind::Instruction,
1370 content: "Use Bash once.".to_string(),
1371 tags: Vec::new(),
1372 version: None,
1373 }));
1374 let llm = Arc::new(MockLlmClient::new(vec![
1375 MockLlmClient::tool_call_response(
1376 "bash-call",
1377 "bash",
1378 serde_json::json!({"command": "printf leaked > skill-boundary-leak"}),
1379 ),
1380 MockLlmClient::text_response("The host boundary rejected the call."),
1381 ]));
1382 let parent_policy = PermissionPolicy::new().deny("bash(*)");
1383 let config = AgentConfig {
1384 planning_mode: PlanningMode::Disabled,
1385 continuation_enabled: false,
1386 permission_checker: Some(Arc::new(parent_policy.clone())),
1387 permission_policy: Some(parent_policy),
1388 ..Default::default()
1389 };
1390 let tool = SkillTool::new(
1391 registry,
1392 llm,
1393 Arc::new(ToolExecutor::new(
1394 workspace.path().to_string_lossy().into_owned(),
1395 )),
1396 config,
1397 );
1398
1399 let result = tool
1400 .execute(
1401 &serde_json::json!({"skill_name": "bash-skill"}),
1402 &ToolContext::new(workspace.path().to_path_buf()),
1403 )
1404 .await
1405 .unwrap();
1406
1407 assert!(result.success, "{}", result.content);
1408 assert!(
1409 !workspace.path().join("skill-boundary-leak").exists(),
1410 "a skill-local allow-list must not bypass the parent host boundary"
1411 );
1412 }
1413
1414 #[tokio::test]
1415 async fn host_direct_context_does_not_leak_into_skill_model_orchestrators() {
1416 use crate::prompts::PlanningMode;
1417
1418 struct RecordingBoundary {
1419 checked: Arc<Mutex<Vec<String>>>,
1420 }
1421
1422 impl crate::permissions::PermissionChecker for RecordingBoundary {
1423 fn check(&self, tool_name: &str, _args: &serde_json::Value) -> PermissionDecision {
1424 self.checked.lock().unwrap().push(tool_name.to_string());
1425 if tool_name == "side_effect" {
1426 PermissionDecision::Deny
1427 } else {
1428 PermissionDecision::Allow
1429 }
1430 }
1431 }
1432
1433 let workspace = tempfile::tempdir().unwrap();
1434 let calls = Arc::new(AtomicUsize::new(0));
1435 let checked = Arc::new(Mutex::new(Vec::new()));
1436 let registry = Arc::new(SkillRegistry::new());
1437 registry.register_unchecked(Arc::new(Skill {
1438 name: "orchestrator-skill".to_string(),
1439 description: "Exercise governed orchestrators".to_string(),
1440 allowed_tools: Some("batch(*), program(*), side_effect(*)".to_string()),
1441 disable_model_invocation: false,
1442 kind: SkillKind::Instruction,
1443 content: "Run the requested orchestration.".to_string(),
1444 tags: Vec::new(),
1445 version: None,
1446 }));
1447 let llm = Arc::new(MockLlmClient::new(vec![
1448 MockLlmClient::tool_call_response(
1449 "model-batch",
1450 "batch",
1451 serde_json::json!({
1452 "invocations": [{
1453 "tool": "program",
1454 "args": {
1455 "type": "script",
1456 "language": "javascript",
1457 "source": "async function run(ctx) { return await ctx.tool('side_effect', {}); }",
1458 "allowed_tools": ["side_effect"]
1459 }
1460 }]
1461 }),
1462 ),
1463 MockLlmClient::text_response("The boundary held."),
1464 ]));
1465 let executor = Arc::new(ToolExecutor::new(
1466 workspace.path().to_string_lossy().into_owned(),
1467 ));
1468 executor.register_dynamic_tool(Arc::new(SkillSideEffectTool {
1469 calls: Arc::clone(&calls),
1470 }));
1471 let tool = SkillTool::new(
1472 registry,
1473 llm,
1474 executor,
1475 AgentConfig {
1476 planning_mode: PlanningMode::Disabled,
1477 continuation_enabled: false,
1478 permission_checker: Some(Arc::new(RecordingBoundary {
1479 checked: Arc::clone(&checked),
1480 })),
1481 ..Default::default()
1482 },
1483 );
1484 let context = ToolContext::new(workspace.path().to_path_buf())
1485 .with_host_direct_policy(crate::tools::HostDirectPolicy::TrustedControlPlane);
1486
1487 let result = tool
1488 .execute(
1489 &serde_json::json!({"skill_name": "orchestrator-skill"}),
1490 &context,
1491 )
1492 .await
1493 .unwrap();
1494
1495 assert!(result.success, "{}", result.content);
1496 assert_eq!(result.content, "The boundary held.");
1497 assert_eq!(calls.load(Ordering::SeqCst), 0);
1498 let checked = checked.lock().unwrap();
1499 for expected in ["batch", "program", "side_effect"] {
1500 assert!(
1501 checked.iter().any(|tool| tool == expected),
1502 "{expected} must cross the parent permission boundary: {checked:?}"
1503 );
1504 }
1505 }
1506
1507 #[tokio::test]
1508 async fn skill_child_llm_call_uses_parent_session_budget_scope() {
1509 let hermetic_root = crate::test_support::hermetic_workspace();
1510 use crate::prompts::PlanningMode;
1511
1512 let guard = Arc::new(SkillBudgetGuard::default());
1513 let config = AgentConfig {
1514 planning_mode: PlanningMode::Disabled,
1515 continuation_enabled: false,
1516 budget_guard: Some(Arc::clone(&guard) as Arc<dyn BudgetGuard>),
1517 ..Default::default()
1518 };
1519 let tool = SkillTool::new(
1520 test_skill_registry(),
1521 Arc::new(MockLlmClient::new(vec![MockLlmClient::text_response(
1522 "skill completed",
1523 )])),
1524 Arc::new(ToolExecutor::new(hermetic_root.display().to_string())),
1525 config,
1526 );
1527 let ctx = ToolContext::new(hermetic_root.clone()).with_session_id("parent-session");
1528
1529 let result = tool
1530 .execute(&serde_json::json!({"skill_name": "test-skill"}), &ctx)
1531 .await
1532 .unwrap();
1533
1534 assert!(result.success);
1535 assert_eq!(guard.checks.load(Ordering::SeqCst), 1);
1536 assert_eq!(guard.records.load(Ordering::SeqCst), 1);
1537 assert_eq!(
1538 guard.sessions.lock().unwrap().as_slice(),
1539 &["parent-session".to_string()]
1540 );
1541 }
1542
1543 #[tokio::test]
1544 async fn skill_child_llm_call_stops_on_parent_cancellation() {
1545 let hermetic_root = crate::test_support::hermetic_workspace();
1546 use crate::prompts::PlanningMode;
1547
1548 let started = Arc::new(Notify::new());
1549 let calls = Arc::new(AtomicUsize::new(0));
1550 let tool = SkillTool::new(
1551 test_skill_registry(),
1552 Arc::new(BlockingSkillClient {
1553 started: Arc::clone(&started),
1554 calls: Arc::clone(&calls),
1555 }),
1556 Arc::new(ToolExecutor::new(hermetic_root.display().to_string())),
1557 AgentConfig {
1558 planning_mode: PlanningMode::Disabled,
1559 continuation_enabled: false,
1560 ..Default::default()
1561 },
1562 );
1563 let cancellation = CancellationToken::new();
1564 let ctx = ToolContext::new(hermetic_root.clone())
1565 .with_session_id("parent-session")
1566 .with_cancellation(cancellation.clone());
1567 let started_wait = started.notified();
1568 let run = tokio::spawn(async move {
1569 tool.execute(&serde_json::json!({"skill_name": "test-skill"}), &ctx)
1570 .await
1571 });
1572
1573 tokio::time::timeout(Duration::from_secs(1), started_wait)
1574 .await
1575 .expect("skill provider call should start");
1576 cancellation.cancel();
1577 let error = tokio::time::timeout(Duration::from_secs(1), run)
1578 .await
1579 .expect("parent cancellation must stop the skill child")
1580 .expect("skill join should succeed")
1581 .expect_err("cancelled skill must not return success");
1582
1583 assert!(error.to_string().contains("cancelled"));
1584 assert_eq!(calls.load(Ordering::SeqCst), 1);
1585 }
1586
1587 #[tokio::test]
1588 async fn test_skill_tool_execute_errors_for_unknown_skill() {
1589 let hermetic_root = crate::test_support::hermetic_workspace();
1590 let llm = Arc::new(MockLlmClient::new(vec![MockLlmClient::text_response(
1591 "unused",
1592 )]));
1593 let executor = Arc::new(ToolExecutor::new(hermetic_root.display().to_string()));
1594 let tool = SkillTool::new(
1595 Arc::new(SkillRegistry::new()),
1596 llm,
1597 executor,
1598 AgentConfig::default(),
1599 );
1600
1601 let err = tool
1602 .execute(
1603 &serde_json::json!({"skill_name": "missing-skill"}),
1604 &ToolContext::new(hermetic_root.clone()),
1605 )
1606 .await
1607 .unwrap_err();
1608
1609 assert!(err.to_string().contains("Skill 'missing-skill' not found"));
1610 }
1611
1612 #[tokio::test]
1613 async fn skill_tool_rejects_disable_model_invocation_skills() {
1614 let hermetic_root = crate::test_support::hermetic_workspace();
1615 let registry = Arc::new(SkillRegistry::new());
1616 registry.register_unchecked(Arc::new(Skill {
1617 name: "host-only".to_string(),
1618 description: "Not for the model".to_string(),
1619 allowed_tools: Some("read(*)".to_string()),
1620 disable_model_invocation: true,
1621 kind: SkillKind::Instruction,
1622 content: "Secret host skill.".to_string(),
1623 tags: Vec::new(),
1624 version: None,
1625 }));
1626
1627 let llm = Arc::new(MockLlmClient::new(vec![MockLlmClient::text_response(
1628 "unused",
1629 )]));
1630 let executor = Arc::new(ToolExecutor::new(hermetic_root.display().to_string()));
1631 let tool = SkillTool::new(registry, llm, executor, AgentConfig::default());
1632
1633 let err = tool
1634 .execute(
1635 &serde_json::json!({"skill_name": "host-only"}),
1636 &ToolContext::new(hermetic_root.clone()),
1637 )
1638 .await
1639 .unwrap_err();
1640
1641 assert!(err.to_string().contains("disables model invocation"));
1642 }
1643}