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, delegate, ask the user for help, or manage its
23//! own background work even when scoped to a restricted tool set:
24//!
25//! - `ActivateSkill`, `ListSkills`
26//! - `ListAgents`, `InvokeAgent`
27//! - `AskUser`
28//! - `ListBackgroundTasks`, `CancelTask`, `WaitTask` (#996 Phase G)
29//!
30//! ## Lifecycle
31//!
32//! - Activating a skill with `allowed_tools` → scope is set
33//! - Activating a skill without `allowed_tools` → scope is cleared
34//! - No `ActivateSkill` call → scope unchanged
35
36use crate::providers::ToolDefinition;
37use crate::skills::SkillRegistry;
38
39/// Tools that are always available regardless of skill scope.
40///
41/// These let the model switch skills, delegate, ask the user for
42/// help, or manage its own background work even when scoped to a
43/// restricted tool set.
44///
45/// The bg-task management tools (`ListBackgroundTasks`, `CancelTask`,
46/// `WaitTask`) are meta because background work outlives any single
47/// `ActivateSkill` boundary: a skill that scopes to e.g. `["Read",
48/// "Grep"]` would otherwise lose the ability to wait on or cancel a
49/// shell process the agent kicked off before activation. Excluding
50/// them would force callers to either (a) re-list the bg-task tools
51/// in every skill manifest, or (b) leak background work the agent
52/// can no longer manage — both worse than allowlisting them globally.
53const META_TOOLS: &[&str] = &[
54    "ActivateSkill",
55    "ListSkills",
56    "ListAgents",
57    "InvokeAgent",
58    "AskUser",
59    // #996 Phase G — bg-task management always-available so a
60    // skill-scoped agent can still see / wait / cancel its own work.
61    "ListBackgroundTasks",
62    "CancelTask",
63    "WaitTask",
64];
65
66/// Tracks the active skill's tool scope during an inference loop.
67///
68/// Created once per `inference_loop` invocation. Updated after each
69/// tool dispatch round.
70#[derive(Debug, Default)]
71pub struct SkillToolScope {
72    /// Tool names allowed by the active skill, or `None` for unrestricted.
73    allowed: Option<Vec<String>>,
74}
75
76impl SkillToolScope {
77    /// Create a new unrestricted scope.
78    pub fn new() -> Self {
79        Self { allowed: None }
80    }
81
82    /// Check tool calls for `ActivateSkill` and update the scope accordingly.
83    ///
84    /// Call this after each tool dispatch round with the names and args
85    /// of all tool calls from that round.
86    pub fn update_from_tool_calls(
87        &mut self,
88        tool_calls: &[(String, serde_json::Value)],
89        registry: &SkillRegistry,
90    ) {
91        for (name, args) in tool_calls {
92            if name == "ActivateSkill"
93                && let Some(skill_name) = args.get("skill_name").and_then(|v| v.as_str())
94                && let Some(skill) = registry.get(skill_name)
95            {
96                if skill.meta.allowed_tools.is_empty() {
97                    self.allowed = None;
98                } else {
99                    self.allowed = Some(skill.meta.allowed_tools.clone());
100                }
101            }
102        }
103    }
104
105    /// Whether the scope is currently restricting tools.
106    pub fn is_active(&self) -> bool {
107        self.allowed.is_some()
108    }
109
110    /// Filter tool definitions to only include in-scope tools.
111    ///
112    /// Returns the full set (cloned) if no scope is active. Otherwise returns
113    /// only tools whose names match `allowed_tools` or are meta-tools.
114    pub fn filter_tool_defs(&self, tool_defs: &[ToolDefinition]) -> Vec<ToolDefinition> {
115        match &self.allowed {
116            None => tool_defs.to_vec(),
117            Some(allowed) => tool_defs
118                .iter()
119                .filter(|td| allowed.contains(&td.name) || META_TOOLS.contains(&td.name.as_str()))
120                .cloned()
121                .collect(),
122        }
123    }
124
125    /// Check if a tool call is allowed under the current scope.
126    ///
127    /// Returns `None` if allowed, or `Some(error_message)` if blocked.
128    pub fn check_tool_call(&self, tool_name: &str) -> Option<String> {
129        match &self.allowed {
130            None => None,
131            Some(allowed) => {
132                if allowed.iter().any(|t| t == tool_name) || META_TOOLS.contains(&tool_name) {
133                    None
134                } else {
135                    Some(format!(
136                        "Tool '{tool_name}' is not allowed by the active skill scope. \
137                         Allowed tools: {}. \
138                         Use ActivateSkill to switch to a different skill.",
139                        allowed.join(", ")
140                    ))
141                }
142            }
143        }
144    }
145}
146
147#[cfg(test)]
148mod tests {
149    use super::*;
150    use crate::skills::{Skill, SkillMeta, SkillSource};
151
152    fn make_registry() -> SkillRegistry {
153        let mut registry = SkillRegistry::default();
154        // Scoped skill
155        registry.skills.insert(
156            "scoped".to_string(),
157            Skill {
158                meta: SkillMeta {
159                    name: "scoped".to_string(),
160                    description: "Scoped".to_string(),
161                    tags: vec![],
162                    when_to_use: None,
163                    allowed_tools: vec!["Read".to_string(), "Grep".to_string()],
164                    user_invocable: true,
165                    argument_hint: None,
166                    source: SkillSource::BuiltIn,
167                },
168                content: "scoped content".to_string(),
169            },
170        );
171        // Unscoped skill
172        registry.add_builtin("unscoped", "Unscoped", None, "unscoped content");
173        registry
174    }
175
176    fn tool_defs() -> Vec<ToolDefinition> {
177        [
178            "Read",
179            "Grep",
180            "Write",
181            "Bash",
182            "ActivateSkill",
183            "ListSkills",
184        ]
185        .iter()
186        .map(|n| ToolDefinition {
187            name: n.to_string(),
188            description: format!("{n} tool"),
189            parameters: serde_json::json!({}),
190        })
191        .collect()
192    }
193
194    #[test]
195    fn test_no_scope_passes_all_tools() {
196        let scope = SkillToolScope::new();
197        let defs = tool_defs();
198        let filtered = scope.filter_tool_defs(&defs);
199        assert_eq!(filtered.len(), defs.len());
200    }
201
202    #[test]
203    fn test_activate_scoped_skill_filters_tools() {
204        let registry = make_registry();
205        let mut scope = SkillToolScope::new();
206        let calls = vec![(
207            "ActivateSkill".to_string(),
208            serde_json::json!({"skill_name": "scoped"}),
209        )];
210        scope.update_from_tool_calls(&calls, &registry);
211
212        assert!(scope.is_active());
213
214        let defs = tool_defs();
215        let filtered = scope.filter_tool_defs(&defs);
216        let names: Vec<&str> = filtered.iter().map(|d| d.name.as_str()).collect();
217        assert!(names.contains(&"Read"));
218        assert!(names.contains(&"Grep"));
219        assert!(names.contains(&"ActivateSkill")); // meta-tool always present
220        assert!(names.contains(&"ListSkills")); // meta-tool always present
221        assert!(!names.contains(&"Write")); // not in allowed_tools
222        assert!(!names.contains(&"Bash")); // not in allowed_tools
223    }
224
225    #[test]
226    fn test_activate_unscoped_skill_clears_scope() {
227        let registry = make_registry();
228        let mut scope = SkillToolScope::new();
229
230        // First activate scoped
231        scope.update_from_tool_calls(
232            &[(
233                "ActivateSkill".to_string(),
234                serde_json::json!({"skill_name": "scoped"}),
235            )],
236            &registry,
237        );
238        assert!(scope.is_active());
239
240        // Then activate unscoped — scope should clear
241        scope.update_from_tool_calls(
242            &[(
243                "ActivateSkill".to_string(),
244                serde_json::json!({"skill_name": "unscoped"}),
245            )],
246            &registry,
247        );
248        assert!(!scope.is_active());
249    }
250
251    #[test]
252    fn test_check_tool_call_allowed() {
253        let registry = make_registry();
254        let mut scope = SkillToolScope::new();
255        scope.update_from_tool_calls(
256            &[(
257                "ActivateSkill".to_string(),
258                serde_json::json!({"skill_name": "scoped"}),
259            )],
260            &registry,
261        );
262
263        assert!(scope.check_tool_call("Read").is_none());
264        assert!(scope.check_tool_call("Grep").is_none());
265        assert!(scope.check_tool_call("ActivateSkill").is_none()); // meta
266        assert!(scope.check_tool_call("AskUser").is_none()); // meta
267        // #996 Phase G — the bg-task management trio is meta so a
268        // skill-scoped agent can still see / wait / cancel its own
269        // background work. Pin them all to prevent regression.
270        assert!(scope.check_tool_call("ListBackgroundTasks").is_none());
271        assert!(scope.check_tool_call("CancelTask").is_none());
272        assert!(scope.check_tool_call("WaitTask").is_none());
273
274        let err = scope.check_tool_call("Write");
275        assert!(err.is_some());
276        assert!(err.unwrap().contains("not allowed"));
277    }
278
279    #[test]
280    fn test_no_scope_allows_everything() {
281        let scope = SkillToolScope::new();
282        assert!(scope.check_tool_call("Write").is_none());
283        assert!(scope.check_tool_call("Bash").is_none());
284        assert!(scope.check_tool_call("anything").is_none());
285    }
286
287    #[test]
288    fn test_unknown_skill_preserves_scope() {
289        let registry = make_registry();
290        let mut scope = SkillToolScope::new();
291
292        // Activate scoped first
293        scope.update_from_tool_calls(
294            &[(
295                "ActivateSkill".to_string(),
296                serde_json::json!({"skill_name": "scoped"}),
297            )],
298            &registry,
299        );
300        assert!(scope.is_active());
301
302        // Try activating non-existent skill — scope should not change
303        scope.update_from_tool_calls(
304            &[(
305                "ActivateSkill".to_string(),
306                serde_json::json!({"skill_name": "nope"}),
307            )],
308            &registry,
309        );
310        assert!(scope.is_active());
311    }
312
313    #[test]
314    fn test_non_activate_calls_ignored() {
315        let registry = make_registry();
316        let mut scope = SkillToolScope::new();
317        scope.update_from_tool_calls(
318            &[(
319                "Read".to_string(),
320                serde_json::json!({"file_path": "foo.rs"}),
321            )],
322            &registry,
323        );
324        assert!(!scope.is_active());
325    }
326}