use crate::providers::ToolDefinition;
use crate::skills::SkillRegistry;
const META_TOOLS: &[&str] = &[
"ActivateSkill",
"ListSkills",
"ListAgents",
"InvokeAgent",
"AskUser",
];
#[derive(Debug, Default)]
pub struct SkillToolScope {
allowed: Option<Vec<String>>,
}
impl SkillToolScope {
pub fn new() -> Self {
Self { allowed: None }
}
pub fn update_from_tool_calls(
&mut self,
tool_calls: &[(String, serde_json::Value)],
registry: &SkillRegistry,
) {
for (name, args) in tool_calls {
if name == "ActivateSkill"
&& let Some(skill_name) = args.get("skill_name").and_then(|v| v.as_str())
&& let Some(skill) = registry.get(skill_name)
{
if skill.meta.allowed_tools.is_empty() {
self.allowed = None;
} else {
self.allowed = Some(skill.meta.allowed_tools.clone());
}
}
}
}
pub fn is_active(&self) -> bool {
self.allowed.is_some()
}
pub fn filter_tool_defs(&self, tool_defs: &[ToolDefinition]) -> Vec<ToolDefinition> {
match &self.allowed {
None => tool_defs.to_vec(),
Some(allowed) => tool_defs
.iter()
.filter(|td| allowed.contains(&td.name) || META_TOOLS.contains(&td.name.as_str()))
.cloned()
.collect(),
}
}
pub fn check_tool_call(&self, tool_name: &str) -> Option<String> {
match &self.allowed {
None => None,
Some(allowed) => {
if allowed.iter().any(|t| t == tool_name) || META_TOOLS.contains(&tool_name) {
None
} else {
Some(format!(
"Tool '{tool_name}' is not allowed by the active skill scope. \
Allowed tools: {}. \
Use ActivateSkill to switch to a different skill.",
allowed.join(", ")
))
}
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::skills::{Skill, SkillMeta, SkillSource};
fn make_registry() -> SkillRegistry {
let mut registry = SkillRegistry::default();
registry.skills.insert(
"scoped".to_string(),
Skill {
meta: SkillMeta {
name: "scoped".to_string(),
description: "Scoped".to_string(),
tags: vec![],
when_to_use: None,
allowed_tools: vec!["Read".to_string(), "Grep".to_string()],
user_invocable: true,
argument_hint: None,
source: SkillSource::BuiltIn,
},
content: "scoped content".to_string(),
},
);
registry.add_builtin("unscoped", "Unscoped", None, "unscoped content");
registry
}
fn tool_defs() -> Vec<ToolDefinition> {
[
"Read",
"Grep",
"Write",
"Bash",
"ActivateSkill",
"ListSkills",
]
.iter()
.map(|n| ToolDefinition {
name: n.to_string(),
description: format!("{n} tool"),
parameters: serde_json::json!({}),
})
.collect()
}
#[test]
fn test_no_scope_passes_all_tools() {
let scope = SkillToolScope::new();
let defs = tool_defs();
let filtered = scope.filter_tool_defs(&defs);
assert_eq!(filtered.len(), defs.len());
}
#[test]
fn test_activate_scoped_skill_filters_tools() {
let registry = make_registry();
let mut scope = SkillToolScope::new();
let calls = vec![(
"ActivateSkill".to_string(),
serde_json::json!({"skill_name": "scoped"}),
)];
scope.update_from_tool_calls(&calls, ®istry);
assert!(scope.is_active());
let defs = tool_defs();
let filtered = scope.filter_tool_defs(&defs);
let names: Vec<&str> = filtered.iter().map(|d| d.name.as_str()).collect();
assert!(names.contains(&"Read"));
assert!(names.contains(&"Grep"));
assert!(names.contains(&"ActivateSkill")); assert!(names.contains(&"ListSkills")); assert!(!names.contains(&"Write")); assert!(!names.contains(&"Bash")); }
#[test]
fn test_activate_unscoped_skill_clears_scope() {
let registry = make_registry();
let mut scope = SkillToolScope::new();
scope.update_from_tool_calls(
&[(
"ActivateSkill".to_string(),
serde_json::json!({"skill_name": "scoped"}),
)],
®istry,
);
assert!(scope.is_active());
scope.update_from_tool_calls(
&[(
"ActivateSkill".to_string(),
serde_json::json!({"skill_name": "unscoped"}),
)],
®istry,
);
assert!(!scope.is_active());
}
#[test]
fn test_check_tool_call_allowed() {
let registry = make_registry();
let mut scope = SkillToolScope::new();
scope.update_from_tool_calls(
&[(
"ActivateSkill".to_string(),
serde_json::json!({"skill_name": "scoped"}),
)],
®istry,
);
assert!(scope.check_tool_call("Read").is_none());
assert!(scope.check_tool_call("Grep").is_none());
assert!(scope.check_tool_call("ActivateSkill").is_none()); assert!(scope.check_tool_call("AskUser").is_none());
let err = scope.check_tool_call("Write");
assert!(err.is_some());
assert!(err.unwrap().contains("not allowed"));
}
#[test]
fn test_no_scope_allows_everything() {
let scope = SkillToolScope::new();
assert!(scope.check_tool_call("Write").is_none());
assert!(scope.check_tool_call("Bash").is_none());
assert!(scope.check_tool_call("anything").is_none());
}
#[test]
fn test_unknown_skill_preserves_scope() {
let registry = make_registry();
let mut scope = SkillToolScope::new();
scope.update_from_tool_calls(
&[(
"ActivateSkill".to_string(),
serde_json::json!({"skill_name": "scoped"}),
)],
®istry,
);
assert!(scope.is_active());
scope.update_from_tool_calls(
&[(
"ActivateSkill".to_string(),
serde_json::json!({"skill_name": "nope"}),
)],
®istry,
);
assert!(scope.is_active());
}
#[test]
fn test_non_activate_calls_ignored() {
let registry = make_registry();
let mut scope = SkillToolScope::new();
scope.update_from_tool_calls(
&[(
"Read".to_string(),
serde_json::json!({"file_path": "foo.rs"}),
)],
®istry,
);
assert!(!scope.is_active());
}
}