Skip to main content

koda_core/
skill_scope.rs

1//! Skill-scoped tool filtering.
2//!
3//! When a skill with `allowed_tools` is activated, only those tools
4//! (plus meta-tools like `ActivateSkill`, `ListSkills`, `ListAgents`,
5//! `InvokeAgent`) are sent to the LLM. This is the "hard enforcement"
6//! counterpart to the prompt hint in `activate_skill()`.
7//!
8//! ## How it works
9//!
10//! 1. The inference loop creates a `SkillToolScope` (initially empty).
11//! 2. After each tool dispatch round, if an `ActivateSkill` call was made,
12//!    the loop calls `update_from_tool_calls()` with the tool call names
13//!    and args.
14//! 3. `SkillToolScope` inspects the skill registry to check if the
15//!    activated skill has `allowed_tools`.
16//! 4. On the next iteration, `filter_tool_defs()` returns only the
17//!    in-scope tools.
18//!
19//! ## Meta-tools
20//!
21//! These tools are always available regardless of scope, so the model
22//! can switch skills or delegate:
23//!
24//! - `ActivateSkill`, `ListSkills`
25//! - `ListAgents`, `InvokeAgent`
26//! - `AskUser`
27//!
28//! ## Lifecycle
29//!
30//! - Activating a skill with `allowed_tools` → scope is set
31//! - Activating a skill without `allowed_tools` → scope is cleared
32//! - No `ActivateSkill` call → scope unchanged
33
34use crate::providers::ToolDefinition;
35use crate::skills::SkillRegistry;
36
37/// Tools that are always available regardless of skill scope.
38///
39/// These let the model switch skills, delegate, or ask the user for help
40/// even when scoped to a restricted tool set.
41const META_TOOLS: &[&str] = &[
42    "ActivateSkill",
43    "ListSkills",
44    "ListAgents",
45    "InvokeAgent",
46    "AskUser",
47];
48
49/// Tracks the active skill's tool scope during an inference loop.
50///
51/// Created once per `inference_loop` invocation. Updated after each
52/// tool dispatch round.
53#[derive(Debug, Default)]
54pub struct SkillToolScope {
55    /// Tool names allowed by the active skill, or `None` for unrestricted.
56    allowed: Option<Vec<String>>,
57}
58
59impl SkillToolScope {
60    /// Create a new unrestricted scope.
61    pub fn new() -> Self {
62        Self { allowed: None }
63    }
64
65    /// Check tool calls for `ActivateSkill` and update the scope accordingly.
66    ///
67    /// Call this after each tool dispatch round with the names and args
68    /// of all tool calls from that round.
69    pub fn update_from_tool_calls(
70        &mut self,
71        tool_calls: &[(String, serde_json::Value)],
72        registry: &SkillRegistry,
73    ) {
74        for (name, args) in tool_calls {
75            if name == "ActivateSkill"
76                && let Some(skill_name) = args.get("skill_name").and_then(|v| v.as_str())
77                && let Some(skill) = registry.get(skill_name)
78            {
79                if skill.meta.allowed_tools.is_empty() {
80                    self.allowed = None;
81                } else {
82                    self.allowed = Some(skill.meta.allowed_tools.clone());
83                }
84            }
85        }
86    }
87
88    /// Whether the scope is currently restricting tools.
89    pub fn is_active(&self) -> bool {
90        self.allowed.is_some()
91    }
92
93    /// Filter tool definitions to only include in-scope tools.
94    ///
95    /// Returns the full set (cloned) if no scope is active. Otherwise returns
96    /// only tools whose names match `allowed_tools` or are meta-tools.
97    pub fn filter_tool_defs(&self, tool_defs: &[ToolDefinition]) -> Vec<ToolDefinition> {
98        match &self.allowed {
99            None => tool_defs.to_vec(),
100            Some(allowed) => tool_defs
101                .iter()
102                .filter(|td| allowed.contains(&td.name) || META_TOOLS.contains(&td.name.as_str()))
103                .cloned()
104                .collect(),
105        }
106    }
107
108    /// Check if a tool call is allowed under the current scope.
109    ///
110    /// Returns `None` if allowed, or `Some(error_message)` if blocked.
111    pub fn check_tool_call(&self, tool_name: &str) -> Option<String> {
112        match &self.allowed {
113            None => None,
114            Some(allowed) => {
115                if allowed.iter().any(|t| t == tool_name) || META_TOOLS.contains(&tool_name) {
116                    None
117                } else {
118                    Some(format!(
119                        "Tool '{tool_name}' is not allowed by the active skill scope. \
120                         Allowed tools: {}. \
121                         Use ActivateSkill to switch to a different skill.",
122                        allowed.join(", ")
123                    ))
124                }
125            }
126        }
127    }
128}
129
130#[cfg(test)]
131mod tests {
132    use super::*;
133    use crate::skills::{Skill, SkillMeta, SkillSource};
134
135    fn make_registry() -> SkillRegistry {
136        let mut registry = SkillRegistry::default();
137        // Scoped skill
138        registry.skills.insert(
139            "scoped".to_string(),
140            Skill {
141                meta: SkillMeta {
142                    name: "scoped".to_string(),
143                    description: "Scoped".to_string(),
144                    tags: vec![],
145                    when_to_use: None,
146                    allowed_tools: vec!["Read".to_string(), "Grep".to_string()],
147                    user_invocable: true,
148                    argument_hint: None,
149                    source: SkillSource::BuiltIn,
150                },
151                content: "scoped content".to_string(),
152            },
153        );
154        // Unscoped skill
155        registry.add_builtin("unscoped", "Unscoped", None, "unscoped content");
156        registry
157    }
158
159    fn tool_defs() -> Vec<ToolDefinition> {
160        [
161            "Read",
162            "Grep",
163            "Write",
164            "Bash",
165            "ActivateSkill",
166            "ListSkills",
167        ]
168        .iter()
169        .map(|n| ToolDefinition {
170            name: n.to_string(),
171            description: format!("{n} tool"),
172            parameters: serde_json::json!({}),
173        })
174        .collect()
175    }
176
177    #[test]
178    fn test_no_scope_passes_all_tools() {
179        let scope = SkillToolScope::new();
180        let defs = tool_defs();
181        let filtered = scope.filter_tool_defs(&defs);
182        assert_eq!(filtered.len(), defs.len());
183    }
184
185    #[test]
186    fn test_activate_scoped_skill_filters_tools() {
187        let registry = make_registry();
188        let mut scope = SkillToolScope::new();
189        let calls = vec![(
190            "ActivateSkill".to_string(),
191            serde_json::json!({"skill_name": "scoped"}),
192        )];
193        scope.update_from_tool_calls(&calls, &registry);
194
195        assert!(scope.is_active());
196
197        let defs = tool_defs();
198        let filtered = scope.filter_tool_defs(&defs);
199        let names: Vec<&str> = filtered.iter().map(|d| d.name.as_str()).collect();
200        assert!(names.contains(&"Read"));
201        assert!(names.contains(&"Grep"));
202        assert!(names.contains(&"ActivateSkill")); // meta-tool always present
203        assert!(names.contains(&"ListSkills")); // meta-tool always present
204        assert!(!names.contains(&"Write")); // not in allowed_tools
205        assert!(!names.contains(&"Bash")); // not in allowed_tools
206    }
207
208    #[test]
209    fn test_activate_unscoped_skill_clears_scope() {
210        let registry = make_registry();
211        let mut scope = SkillToolScope::new();
212
213        // First activate scoped
214        scope.update_from_tool_calls(
215            &[(
216                "ActivateSkill".to_string(),
217                serde_json::json!({"skill_name": "scoped"}),
218            )],
219            &registry,
220        );
221        assert!(scope.is_active());
222
223        // Then activate unscoped — scope should clear
224        scope.update_from_tool_calls(
225            &[(
226                "ActivateSkill".to_string(),
227                serde_json::json!({"skill_name": "unscoped"}),
228            )],
229            &registry,
230        );
231        assert!(!scope.is_active());
232    }
233
234    #[test]
235    fn test_check_tool_call_allowed() {
236        let registry = make_registry();
237        let mut scope = SkillToolScope::new();
238        scope.update_from_tool_calls(
239            &[(
240                "ActivateSkill".to_string(),
241                serde_json::json!({"skill_name": "scoped"}),
242            )],
243            &registry,
244        );
245
246        assert!(scope.check_tool_call("Read").is_none());
247        assert!(scope.check_tool_call("Grep").is_none());
248        assert!(scope.check_tool_call("ActivateSkill").is_none()); // meta
249        assert!(scope.check_tool_call("AskUser").is_none()); // meta
250
251        let err = scope.check_tool_call("Write");
252        assert!(err.is_some());
253        assert!(err.unwrap().contains("not allowed"));
254    }
255
256    #[test]
257    fn test_no_scope_allows_everything() {
258        let scope = SkillToolScope::new();
259        assert!(scope.check_tool_call("Write").is_none());
260        assert!(scope.check_tool_call("Bash").is_none());
261        assert!(scope.check_tool_call("anything").is_none());
262    }
263
264    #[test]
265    fn test_unknown_skill_preserves_scope() {
266        let registry = make_registry();
267        let mut scope = SkillToolScope::new();
268
269        // Activate scoped first
270        scope.update_from_tool_calls(
271            &[(
272                "ActivateSkill".to_string(),
273                serde_json::json!({"skill_name": "scoped"}),
274            )],
275            &registry,
276        );
277        assert!(scope.is_active());
278
279        // Try activating non-existent skill — scope should not change
280        scope.update_from_tool_calls(
281            &[(
282                "ActivateSkill".to_string(),
283                serde_json::json!({"skill_name": "nope"}),
284            )],
285            &registry,
286        );
287        assert!(scope.is_active());
288    }
289
290    #[test]
291    fn test_non_activate_calls_ignored() {
292        let registry = make_registry();
293        let mut scope = SkillToolScope::new();
294        scope.update_from_tool_calls(
295            &[(
296                "Read".to_string(),
297                serde_json::json!({"file_path": "foo.rs"}),
298            )],
299            &registry,
300        );
301        assert!(!scope.is_active());
302    }
303}