Skip to main content

skilllite_agent/extensions/
registry.rs

1//! ExtensionRegistry: unified registry for agent tool extensions.
2//!
3//! Uses compile-time registration: add new tools by calling `register(tools())`.
4//! Pattern: `registry.register(builtin::file_ops::tools());` — no changes to agent_loop.
5
6use std::collections::{HashMap, HashSet};
7use std::path::Path;
8
9use super::builtin;
10use super::memory;
11use crate::llm::LlmClient;
12use crate::prompt;
13use crate::skills::{self, LoadedSkill};
14use crate::types::{EventSink, ToolDefinition, ToolResult};
15
16/// Executor for planning control tools (complete_task, update_task_plan).
17/// Implemented by the agent loop; passed to `registry.execute()` when available.
18pub trait PlanningControlExecutor {
19    fn execute(
20        &mut self,
21        tool_name: &str,
22        arguments: &str,
23        event_sink: &mut dyn EventSink,
24    ) -> ToolResult;
25}
26use skilllite_core::config::EmbeddingConfig;
27
28/// Context for memory vector search (embedding API).
29#[allow(dead_code)] // used when memory_vector feature is enabled
30pub struct MemoryVectorContext<'a> {
31    pub client: &'a LlmClient,
32    pub embed_config: &'a EmbeddingConfig,
33}
34
35/// Coarse-grained capabilities used to gate tools in different execution modes.
36#[derive(Debug, Clone, Copy, PartialEq, Eq)]
37pub enum ToolCapability {
38    FilesystemWrite,
39    MemoryWrite,
40    ProcessExec,
41    Preview,
42    Delegation,
43    SkillExecution,
44}
45
46/// Policy that decides which capabilities are allowed in the current mode.
47#[derive(Debug, Clone, Copy)]
48pub struct CapabilityPolicy {
49    allow_filesystem_write: bool,
50    allow_memory_write: bool,
51    allow_process_exec: bool,
52    allow_preview: bool,
53    allow_delegation: bool,
54    allow_skill_execution: bool,
55}
56
57impl Default for CapabilityPolicy {
58    fn default() -> Self {
59        Self::full_access()
60    }
61}
62
63impl CapabilityPolicy {
64    /// Allow the complete built-in tool surface.
65    pub const fn full_access() -> Self {
66        Self {
67            allow_filesystem_write: true,
68            allow_memory_write: true,
69            allow_process_exec: true,
70            allow_preview: true,
71            allow_delegation: true,
72            allow_skill_execution: true,
73        }
74    }
75
76    /// Restrict to inspection-oriented tools only.
77    pub const fn read_only() -> Self {
78        Self {
79            allow_filesystem_write: false,
80            allow_memory_write: false,
81            allow_process_exec: false,
82            allow_preview: false,
83            allow_delegation: false,
84            allow_skill_execution: false,
85        }
86    }
87
88    #[must_use]
89    pub fn with_filesystem_write(mut self, allow: bool) -> Self {
90        self.allow_filesystem_write = allow;
91        self
92    }
93
94    #[must_use]
95    pub fn with_memory_write(mut self, allow: bool) -> Self {
96        self.allow_memory_write = allow;
97        self
98    }
99
100    #[must_use]
101    pub fn with_process_exec(mut self, allow: bool) -> Self {
102        self.allow_process_exec = allow;
103        self
104    }
105
106    #[must_use]
107    pub fn with_preview(mut self, allow: bool) -> Self {
108        self.allow_preview = allow;
109        self
110    }
111
112    #[must_use]
113    pub fn with_delegation(mut self, allow: bool) -> Self {
114        self.allow_delegation = allow;
115        self
116    }
117
118    #[must_use]
119    pub fn with_skill_execution(mut self, allow: bool) -> Self {
120        self.allow_skill_execution = allow;
121        self
122    }
123
124    pub fn allows(&self, capabilities: &[ToolCapability]) -> bool {
125        capabilities.iter().all(|capability| match capability {
126            ToolCapability::FilesystemWrite => self.allow_filesystem_write,
127            ToolCapability::MemoryWrite => self.allow_memory_write,
128            ToolCapability::ProcessExec => self.allow_process_exec,
129            ToolCapability::Preview => self.allow_preview,
130            ToolCapability::Delegation => self.allow_delegation,
131            ToolCapability::SkillExecution => self.allow_skill_execution,
132        })
133    }
134}
135
136#[cfg(test)]
137mod tests {
138    use super::{CapabilityPolicy, ExtensionRegistry};
139
140    #[test]
141    fn read_only_policy_filters_mutating_tools() {
142        let registry = ExtensionRegistry::read_only(true, false, &[]);
143
144        assert!(registry.owns_tool("read_file"));
145        assert!(registry.owns_tool("memory_search"));
146        assert!(registry.owns_tool("complete_task"));
147        assert!(!registry.owns_tool("write_file"));
148        assert!(!registry.owns_tool("memory_write"));
149        assert!(!registry.owns_tool("run_command"));
150        assert!(!registry.owns_tool("preview_server"));
151    }
152
153    #[test]
154    fn full_registry_keeps_mutating_tools() {
155        let registry = ExtensionRegistry::new(true, false, &[]);
156
157        assert!(registry.owns_tool("write_file"));
158        assert!(registry.owns_tool("memory_write"));
159        assert!(registry.owns_tool("run_command"));
160        assert!(registry.owns_tool("preview_server"));
161    }
162
163    #[test]
164    fn custom_policy_can_allow_preview_without_other_writes() {
165        let registry = ExtensionRegistry::builder(true, false, &[])
166            .with_policy(CapabilityPolicy::read_only().with_preview(true))
167            .register(super::builtin::get_builtin_tools())
168            .register_memory_if(true)
169            .build();
170
171        assert!(registry.owns_tool("preview_server"));
172        assert!(!registry.owns_tool("write_file"));
173        assert!(!registry.owns_tool("memory_write"));
174        assert!(!registry.owns_tool("run_command"));
175    }
176
177    #[test]
178    fn planning_only_tools_excluded_when_task_planning_disabled() {
179        let registry = ExtensionRegistry::builder(true, false, &[])
180            .with_task_planning(false)
181            .register(super::builtin::get_builtin_tools())
182            .register_memory_if(true)
183            .build();
184
185        assert!(!registry.owns_tool("complete_task"));
186        assert!(!registry.owns_tool("update_task_plan"));
187        assert!(registry.owns_tool("read_file"));
188    }
189}
190
191/// Scope that controls when a tool is available.
192#[derive(Debug, Clone, Copy, PartialEq, Eq)]
193pub enum ToolScope {
194    /// Available in all modes (simple and planning).
195    AllModes,
196    /// Only available when task planning is enabled.
197    PlanningOnly,
198}
199
200/// Concrete execution target for a registered tool.
201#[derive(Debug, Clone)]
202pub enum ToolHandler {
203    BuiltinSync,
204    BuiltinAsync,
205    Memory,
206    Skill {
207        skill_name: String,
208    },
209    /// Control tools (complete_task, update_task_plan) executed via PlanningControlExecutor.
210    PlanningControl,
211}
212
213/// A tool registration that keeps definition, capability requirements, scope, and handler together.
214#[derive(Debug, Clone)]
215pub struct RegisteredTool {
216    pub definition: ToolDefinition,
217    pub capabilities: Vec<ToolCapability>,
218    pub handler: ToolHandler,
219    pub scope: ToolScope,
220}
221
222impl RegisteredTool {
223    pub fn new(
224        definition: ToolDefinition,
225        capabilities: Vec<ToolCapability>,
226        handler: ToolHandler,
227    ) -> Self {
228        Self {
229            definition,
230            capabilities,
231            handler,
232            scope: ToolScope::AllModes,
233        }
234    }
235
236    #[must_use]
237    pub fn with_scope(mut self, scope: ToolScope) -> Self {
238        self.scope = scope;
239        self
240    }
241
242    pub fn name(&self) -> &str {
243        &self.definition.function.name
244    }
245}
246
247/// Read-only view of the final tool surface after policy filtering.
248///
249/// This is the single source of truth for "what is actually callable right now"
250/// and should be consumed by planner / prompt / hint resolution code instead of
251/// re-deriving availability from static tables.
252#[derive(Debug, Clone, Default)]
253pub struct ToolAvailabilityView {
254    tool_names: HashSet<String>,
255    skill_names: HashSet<String>,
256}
257
258impl ToolAvailabilityView {
259    fn register(&mut self, tool: &RegisteredTool) {
260        self.tool_names.insert(tool.name().to_string());
261        if let ToolHandler::Skill { skill_name } = &tool.handler {
262            self.skill_names.insert(skill_name.clone());
263            self.skill_names.insert(skill_name.replace('-', "_"));
264        }
265    }
266
267    pub fn has_tool(&self, name: &str) -> bool {
268        self.tool_names.contains(name)
269    }
270
271    pub fn has_any_tool(&self, names: &[&str]) -> bool {
272        names.iter().any(|name| self.has_tool(name))
273    }
274
275    pub fn has_skill_hint(&self, hint: &str) -> bool {
276        self.skill_names.contains(hint) || self.skill_names.contains(&hint.replace('-', "_"))
277    }
278
279    pub fn has_any_skills(&self) -> bool {
280        !self.skill_names.is_empty()
281    }
282
283    pub fn filter_callable_skills<'a>(&self, skills: &'a [LoadedSkill]) -> Vec<&'a LoadedSkill> {
284        skills
285            .iter()
286            .filter(|skill| {
287                self.has_skill_hint(&skill.name)
288                    || skill
289                        .tool_definitions
290                        .iter()
291                        .any(|td| self.has_tool(&td.function.name))
292            })
293            .collect()
294    }
295}
296
297/// Unified registry for agent tool extensions.
298///
299/// Tool sources are registered at construction. Pattern:
300/// ```ignore
301/// let registry = ExtensionRegistry::builder(enable_memory, enable_memory_vector, skills)
302///     .register(builtin::get_builtin_tools())
303///     .register_memory_if(enable_memory)
304///     .build();
305/// ```
306/// Adding a new tool module = add to builtin, or `.register(new_tools())`.
307#[derive(Debug)]
308pub struct ExtensionRegistry<'a> {
309    /// Cached tool definitions (from registered extensions + skills).
310    tool_definitions: Vec<ToolDefinition>,
311    /// Executable tools keyed by function name.
312    tools_by_name: HashMap<String, RegisteredTool>,
313    /// Final availability view after policy filtering and deduplication.
314    availability: ToolAvailabilityView,
315    /// Execution capability policy for this registry instance.
316    policy: CapabilityPolicy,
317    /// Whether memory tools are enabled.
318    pub enable_memory: bool,
319    /// Whether memory vector search is enabled.
320    pub enable_memory_vector: bool,
321    /// Loaded skills (for execution dispatch).
322    pub skills: &'a [LoadedSkill],
323}
324
325/// Builder for ExtensionRegistry with explicit tool registration.
326#[derive(Debug)]
327pub struct ExtensionRegistryBuilder<'a> {
328    registered_tools: Vec<RegisteredTool>,
329    policy: CapabilityPolicy,
330    enable_memory: bool,
331    enable_memory_vector: bool,
332    enable_task_planning: bool,
333    skills: &'a [LoadedSkill],
334}
335
336impl<'a> ExtensionRegistryBuilder<'a> {
337    /// Create a new builder. Call `register()` for each tool provider, then `build()`.
338    pub fn new(enable_memory: bool, enable_memory_vector: bool, skills: &'a [LoadedSkill]) -> Self {
339        Self {
340            registered_tools: Vec::new(),
341            policy: CapabilityPolicy::default(),
342            enable_memory,
343            enable_memory_vector,
344            enable_task_planning: true, // default: include planning tools for backward compat
345            skills,
346        }
347    }
348
349    /// Exclude PlanningOnly tools when false (simple mode).
350    #[must_use]
351    pub fn with_task_planning(mut self, enable: bool) -> Self {
352        self.enable_task_planning = enable;
353        self
354    }
355
356    /// Apply a capability policy before building the registry.
357    #[must_use]
358    pub fn with_policy(mut self, policy: CapabilityPolicy) -> Self {
359        self.policy = policy;
360        self
361    }
362
363    /// Register tools from an extension. Add one line per tool module.
364    #[must_use]
365    pub fn register(mut self, tools: impl IntoIterator<Item = RegisteredTool>) -> Self {
366        self.registered_tools.extend(tools);
367        self
368    }
369
370    /// Register memory tools if enable_memory is true.
371    #[must_use]
372    pub fn register_memory_if(mut self, enable: bool) -> Self {
373        if enable {
374            self.registered_tools.extend(memory::get_memory_tools());
375        }
376        self
377    }
378
379    /// Build the registry. Skills' tool definitions are added at build time.
380    /// 按 function.name 去重,避免重复声明导致 Gemini 等 API 报 Duplicate function declaration。
381    pub fn build(self) -> ExtensionRegistry<'a> {
382        let mut registered_tools = self.registered_tools;
383        for skill in self.skills {
384            for td in &skill.tool_definitions {
385                registered_tools.push(RegisteredTool::new(
386                    td.clone(),
387                    vec![ToolCapability::SkillExecution],
388                    ToolHandler::Skill {
389                        skill_name: skill.name.clone(),
390                    },
391                ));
392            }
393        }
394
395        let mut tool_definitions = Vec::new();
396        let mut tools_by_name = HashMap::new();
397        let mut availability = ToolAvailabilityView::default();
398        for registered in registered_tools {
399            if registered.scope == ToolScope::PlanningOnly && !self.enable_task_planning {
400                tracing::debug!(
401                    "Skip PlanningOnly tool (task planning disabled): {}",
402                    registered.name()
403                );
404                continue;
405            }
406            if !self.policy.allows(&registered.capabilities) {
407                tracing::debug!("Skip tool due to capability policy: {}", registered.name());
408                continue;
409            }
410            let tool_name = registered.name().to_string();
411            if tools_by_name.contains_key(&tool_name) {
412                tracing::debug!("Skip duplicate tool name: {}", tool_name);
413                continue;
414            }
415            tool_definitions.push(registered.definition.clone());
416            availability.register(&registered);
417            tools_by_name.insert(tool_name, registered);
418        }
419
420        ExtensionRegistry {
421            tool_definitions,
422            tools_by_name,
423            availability,
424            policy: self.policy,
425            enable_memory: self.enable_memory,
426            enable_memory_vector: self.enable_memory_vector,
427            skills: self.skills,
428        }
429    }
430}
431
432impl<'a> ExtensionRegistry<'a> {
433    /// Create a registry with default tool registration (builtin + memory + skills).
434    pub fn new(enable_memory: bool, enable_memory_vector: bool, skills: &'a [LoadedSkill]) -> Self {
435        Self::builder(enable_memory, enable_memory_vector, skills)
436            .with_policy(CapabilityPolicy::full_access())
437            .register(builtin::get_builtin_tools())
438            .register_memory_if(enable_memory)
439            .build()
440    }
441
442    /// Create a registry with explicit task-planning mode.
443    /// When `enable_task_planning` is false, PlanningOnly tools (complete_task, update_task_plan) are excluded.
444    pub fn with_task_planning(
445        enable_memory: bool,
446        enable_memory_vector: bool,
447        enable_task_planning: bool,
448        skills: &'a [LoadedSkill],
449    ) -> Self {
450        Self::builder(enable_memory, enable_memory_vector, skills)
451            .with_task_planning(enable_task_planning)
452            .with_policy(CapabilityPolicy::full_access())
453            .register(builtin::get_builtin_tools())
454            .register_memory_if(enable_memory)
455            .build()
456    }
457
458    /// Create a registry restricted to read-only tools.
459    pub fn read_only(
460        enable_memory: bool,
461        enable_memory_vector: bool,
462        skills: &'a [LoadedSkill],
463    ) -> Self {
464        Self::builder(enable_memory, enable_memory_vector, skills)
465            .with_policy(CapabilityPolicy::read_only())
466            .register(builtin::get_builtin_tools())
467            .register_memory_if(enable_memory)
468            .build()
469    }
470
471    /// Create a read-only registry with explicit task-planning mode.
472    pub fn read_only_with_task_planning(
473        enable_memory: bool,
474        enable_memory_vector: bool,
475        enable_task_planning: bool,
476        skills: &'a [LoadedSkill],
477    ) -> Self {
478        Self::builder(enable_memory, enable_memory_vector, skills)
479            .with_task_planning(enable_task_planning)
480            .with_policy(CapabilityPolicy::read_only())
481            .register(builtin::get_builtin_tools())
482            .register_memory_if(enable_memory)
483            .build()
484    }
485
486    /// Start building a registry with explicit registration.
487    pub fn builder(
488        enable_memory: bool,
489        enable_memory_vector: bool,
490        skills: &'a [LoadedSkill],
491    ) -> ExtensionRegistryBuilder<'a> {
492        ExtensionRegistryBuilder::new(enable_memory, enable_memory_vector, skills)
493    }
494
495    /// Collect all tool definitions (from registered extensions + skills).
496    pub fn all_tool_definitions(&self) -> Vec<ToolDefinition> {
497        self.tool_definitions.clone()
498    }
499
500    /// Final tool / skill availability after policy filtering.
501    pub fn availability(&self) -> &ToolAvailabilityView {
502        &self.availability
503    }
504
505    /// Check if any extension owns this tool name.
506    pub fn owns_tool(&self, name: &str) -> bool {
507        self.tools_by_name.contains_key(name)
508    }
509
510    /// Execute a tool by name. Dispatches to the appropriate extension.
511    /// `embed_ctx` is required for memory vector search when enable_memory_vector is true.
512    /// `planning_ctx` is required for PlanningControl tools (complete_task, update_task_plan).
513    pub async fn execute(
514        &self,
515        tool_name: &str,
516        arguments: &str,
517        workspace: &Path,
518        event_sink: &mut dyn EventSink,
519        embed_ctx: Option<&MemoryVectorContext<'_>>,
520        planning_ctx: Option<&mut dyn PlanningControlExecutor>,
521    ) -> ToolResult {
522        let Some(registered) = self.tools_by_name.get(tool_name) else {
523            return ToolResult {
524                tool_call_id: String::new(),
525                tool_name: tool_name.to_string(),
526                content: format!(
527                    "Tool '{}' is unavailable in the current execution mode",
528                    tool_name
529                ),
530                is_error: true,
531                counts_as_failure: true,
532            };
533        };
534
535        if !self.policy.allows(&registered.capabilities) {
536            return ToolResult {
537                tool_call_id: String::new(),
538                tool_name: tool_name.to_string(),
539                content: format!(
540                    "Tool '{}' is unavailable in the current execution mode",
541                    tool_name
542                ),
543                is_error: true,
544                counts_as_failure: true,
545            };
546        }
547
548        match &registered.handler {
549            ToolHandler::PlanningControl => {
550                if let Some(ctx) = planning_ctx {
551                    ctx.execute(tool_name, arguments, event_sink)
552                } else {
553                    ToolResult {
554                        tool_call_id: String::new(),
555                        tool_name: tool_name.to_string(),
556                        content: format!(
557                            "Tool '{}' requires task-planning mode and must be executed by the agent loop",
558                            tool_name
559                        ),
560                        is_error: true,
561                        counts_as_failure: true,
562                    }
563                }
564            }
565            ToolHandler::BuiltinSync => {
566                builtin::execute_builtin_tool(tool_name, arguments, workspace, Some(event_sink))
567            }
568            ToolHandler::BuiltinAsync => {
569                builtin::execute_async_builtin_tool(tool_name, arguments, workspace, event_sink)
570                    .await
571            }
572            ToolHandler::Memory => {
573                memory::execute_memory_tool(
574                    tool_name,
575                    arguments,
576                    workspace,
577                    "default",
578                    self.enable_memory_vector,
579                    embed_ctx,
580                )
581                .await
582            }
583            ToolHandler::Skill { skill_name } => {
584                if let Some(skill) = skills::find_skill_by_name(self.skills, skill_name) {
585                    skills::execute_skill(skill, tool_name, arguments, workspace, event_sink, None)
586                } else if let Some(skill) = skills::find_skill_by_tool_name(self.skills, tool_name)
587                {
588                    skills::execute_skill(skill, tool_name, arguments, workspace, event_sink, None)
589                } else if let Some(skill) = skills::find_skill_by_name(self.skills, tool_name) {
590                    // Reference-only skill (no entry_point / no scripts, just SKILL.md guidance)
591                    let docs = prompt::get_skill_full_docs(skill).unwrap_or_else(|| {
592                        format!(
593                            "Skill '{}' is reference-only (no executable entry point). Use its guidance to generate content yourself using write_output.",
594                            skill.name
595                        )
596                    });
597                    ToolResult {
598                        tool_call_id: String::new(),
599                        tool_name: tool_name.to_string(),
600                        content: format!(
601                            "Note: '{}' is a reference-only skill (no executable script). Its documentation is provided below — use these guidelines to generate the content yourself, then save with write_output and preview with preview_server.\n\n{}",
602                            skill.name, docs
603                        ),
604                        is_error: false,
605                        counts_as_failure: false,
606                    }
607                } else {
608                    ToolResult {
609                        tool_call_id: String::new(),
610                        tool_name: tool_name.to_string(),
611                        content: format!("Unknown skill tool: {}", tool_name),
612                        is_error: true,
613                        counts_as_failure: true,
614                    }
615                }
616            }
617        }
618    }
619}