Skip to main content

agentic_tools_registry/
lib.rs

1//! Unified tool registry aggregating all agentic-tools domain registries.
2//!
3//! This crate provides a single entry point for building a `ToolRegistry` containing
4//! all available tools from the various domain crates (coding_agent_tools, pr_comments,
5//! linear_tools, gpt5_reasoner, thoughts_mcp_tools, web_retrieval).
6//!
7//! # Example
8//!
9//! ```ignore
10//! use agentic_tools_registry::{AgenticTools, AgenticToolsConfig};
11//!
12//! // Build registry with all tools
13//! let registry = AgenticTools::new(AgenticToolsConfig::default());
14//! assert!(registry.len() >= 19);
15//!
16//! // Build registry with allowlist
17//! let config = AgenticToolsConfig {
18//!     allowlist: Some(["cli_ls", "cli_grep"].into_iter().map(String::from).collect()),
19//!     ..Default::default()
20//! };
21//! let filtered = AgenticTools::new(config);
22//! assert_eq!(filtered.len(), 2);
23//! ```
24
25#[cfg(not(unix))]
26compile_error!(
27    "agentic-tools-registry only supports Unix-like platforms (Linux/macOS). Windows is not supported."
28);
29
30use agentic_config::types::AnthropicServiceConfig;
31use agentic_config::types::CliToolsConfig;
32use agentic_config::types::ExaServiceConfig;
33use agentic_config::types::ReasoningConfig;
34use agentic_config::types::SubagentsConfig;
35use agentic_config::types::WebRetrievalConfig;
36use agentic_tools_core::ToolRegistry;
37use serde::Deserialize;
38use serde::Serialize;
39use std::collections::HashSet;
40use std::sync::Arc;
41use tracing::warn;
42
43/// Configuration for building the unified registry.
44#[derive(Clone, Debug, Default, Serialize, Deserialize)]
45pub struct AgenticToolsConfig {
46    /// Optional allowlist of tool names (case-insensitive).
47    /// Empty or None = enable all tools.
48    #[serde(default)]
49    pub allowlist: Option<HashSet<String>>,
50
51    /// Tool-specific config for coding-agent-tools subagents.
52    #[serde(default)]
53    pub subagents: SubagentsConfig,
54
55    /// Tool-specific config for CLI tools (limits, ignore patterns).
56    #[serde(default)]
57    pub cli_tools: CliToolsConfig,
58
59    /// Tool-specific config for gpt5-reasoner.
60    #[serde(default)]
61    pub reasoning: ReasoningConfig,
62
63    /// Tool-specific config for web retrieval tools.
64    #[serde(default)]
65    pub web_retrieval: WebRetrievalConfig,
66
67    /// Anthropic service configuration for web summarization.
68    #[serde(default)]
69    pub anthropic: AnthropicServiceConfig,
70
71    /// Exa service configuration for web search.
72    #[serde(default)]
73    pub exa: ExaServiceConfig,
74
75    /// Reserved for future use (e.g., schema strictness, patches).
76    #[serde(default)]
77    pub extras: serde_json::Value,
78}
79
80/// Unified AgenticTools entrypoint.
81pub struct AgenticTools;
82
83// Tool name constants for each domain
84const CODING_NAMES: &[&str] = &[
85    "cli_ls",
86    "ask_agent",
87    "cli_grep",
88    "cli_glob",
89    "cli_just_search",
90    "cli_just_execute",
91];
92
93const PR_COMMENTS_NAMES: &[&str] = &["gh_get_comments", "gh_add_comment_reply", "gh_get_prs"];
94
95const LINEAR_NAMES: &[&str] = &[
96    "linear_search_issues",
97    "linear_read_issue",
98    "linear_create_issue",
99    "linear_add_comment",
100    "linear_get_issue_comments",
101    "linear_archive_issue",
102    "linear_update_issue",
103    "linear_set_relation",
104    "linear_get_metadata",
105];
106
107const GPT5_NAMES: &[&str] = &["ask_reasoning_model"];
108
109const THOUGHTS_NAMES: &[&str] = &[
110    "thoughts_write_document",
111    "thoughts_list_documents",
112    "thoughts_list_references",
113    "thoughts_get_repo_refs",
114    "thoughts_add_reference",
115    "thoughts_get_template",
116];
117
118const WEB_NAMES: &[&str] = &["web_fetch", "web_search"];
119
120const REVIEW_NAMES: &[&str] = &["review_diff_snapshot", "review_diff_page", "review_run"];
121
122impl AgenticTools {
123    /// Build the unified ToolRegistry using domain registries.
124    ///
125    /// Lazy domain gating: When an allowlist is provided, only build domains
126    /// whose tools intersect the allowlist.
127    #[allow(clippy::new_ret_no_self)]
128    pub fn new(config: AgenticToolsConfig) -> ToolRegistry {
129        let allow = normalize_allowlist(config.allowlist);
130
131        // Helper: decide if a domain should be built
132        let domain_wanted = |names: &[&str]| match &allow {
133            None => true,
134            Some(set) => names.iter().any(|n| set.contains(&n.to_lowercase())),
135        };
136
137        // Accumulate selected domain registries
138        let mut regs = Vec::new();
139
140        // coding_agent_tools (6 tools)
141        if domain_wanted(CODING_NAMES) {
142            regs.push(coding_agent_tools::build_registry(
143                config.subagents.clone(),
144                config.cli_tools.clone(),
145            ));
146        }
147
148        // pr_comments (3 tools)
149        if domain_wanted(PR_COMMENTS_NAMES) {
150            // TODO(2): Centralize ambient git repo detection + overrides across tool registries
151            // (avoid per-domain fallbacks like this).
152            let tool = match pr_comments::PrComments::new() {
153                Ok(t) => t,
154                Err(e) => {
155                    warn!(
156                        "pr_comments: ambient repo detection failed ({}); tools will return a clear error until repo context is available",
157                        e
158                    );
159                    pr_comments::PrComments::disabled(format!("{:#}", e))
160                }
161            };
162            regs.push(pr_comments::build_registry(Arc::new(tool)));
163        }
164
165        // linear_tools (9 tools)
166        if domain_wanted(LINEAR_NAMES) {
167            let linear = Arc::new(linear_tools::LinearTools::new());
168            regs.push(linear_tools::build_registry(linear));
169        }
170
171        // gpt5_reasoner (1 tool)
172        if domain_wanted(GPT5_NAMES) {
173            regs.push(gpt5_reasoner::build_registry(config.reasoning.clone()));
174        }
175
176        // thoughts-mcp-tools (6 tools)
177        if domain_wanted(THOUGHTS_NAMES) {
178            regs.push(thoughts_mcp_tools::build_registry());
179        }
180
181        // web-retrieval (2 tools)
182        if domain_wanted(WEB_NAMES) {
183            let web = Arc::new(web_retrieval::WebTools::with_config(
184                config.web_retrieval.clone(),
185                &config.exa,
186                config.anthropic.clone(),
187            ));
188            regs.push(web_retrieval::build_registry(web));
189        }
190
191        // review_tools (3 tools)
192        if domain_wanted(REVIEW_NAMES) {
193            let svc = Arc::new(review_tools::ReviewTools::new());
194            regs.push(review_tools::build_registry(svc));
195        }
196
197        let merged = ToolRegistry::merge_all(regs);
198
199        // Final allowlist filtering at registry level (authoritative)
200        if let Some(set) = allow {
201            let names: Vec<&str> = set.iter().map(|s| s.as_str()).collect();
202            // Warn about unknown tool names in allowlist
203            for name in &names {
204                if !merged.contains(name) {
205                    warn!("Unknown tool in allowlist: {}", name);
206                }
207            }
208            merged.subset(names)
209        } else {
210            merged
211        }
212    }
213
214    /// Get the total count of available tools when no allowlist is applied.
215    pub fn total_tool_count() -> usize {
216        CODING_NAMES.len()
217            + PR_COMMENTS_NAMES.len()
218            + LINEAR_NAMES.len()
219            + GPT5_NAMES.len()
220            + THOUGHTS_NAMES.len()
221            + WEB_NAMES.len()
222            + REVIEW_NAMES.len()
223    }
224}
225
226/// Normalize allowlist: lowercase, trim, filter empty strings.
227/// Returns None if the resulting set is empty (empty allowlist = all tools).
228fn normalize_allowlist(allowlist: Option<HashSet<String>>) -> Option<HashSet<String>> {
229    allowlist.and_then(|s| {
230        let normalized: HashSet<String> = s
231            .into_iter()
232            .map(|n| n.trim().to_lowercase())
233            .filter(|n| !n.is_empty())
234            .collect();
235        if normalized.is_empty() {
236            None // Empty allowlist = enable all tools
237        } else {
238            Some(normalized)
239        }
240    })
241}
242
243#[cfg(test)]
244mod tests {
245    use super::*;
246
247    #[test]
248    fn total_tool_count_is_30() {
249        assert_eq!(AgenticTools::total_tool_count(), 30);
250    }
251
252    #[test]
253    fn normalize_allowlist_lowercases() {
254        let mut set = HashSet::new();
255        set.insert("CLI_LS".to_string());
256        set.insert("Ask_Reasoning_Model".to_string());
257        let normalized = normalize_allowlist(Some(set)).unwrap();
258        assert!(normalized.contains("cli_ls"));
259        assert!(normalized.contains("ask_reasoning_model"));
260        assert!(!normalized.contains("CLI_LS"));
261    }
262
263    #[test]
264    fn normalize_allowlist_filters_empty() {
265        let mut set = HashSet::new();
266        set.insert("".to_string());
267        set.insert("   ".to_string());
268        set.insert("cli_ls".to_string());
269        let normalized = normalize_allowlist(Some(set)).unwrap();
270        assert_eq!(normalized.len(), 1);
271        assert!(normalized.contains("cli_ls"));
272    }
273
274    #[test]
275    fn normalize_allowlist_none_returns_none() {
276        assert!(normalize_allowlist(None).is_none());
277    }
278
279    // Integration tests for AgenticTools::new
280    // Note: These tests actually build the full registries, which may have
281    // side effects (e.g., pr_comments tries git detection, linear reads env var).
282    // The fallbacks ensure they don't fail in test environments.
283
284    #[test]
285    fn allowlist_none_builds_all_tools() {
286        let reg = AgenticTools::new(AgenticToolsConfig::default());
287        let names = reg.list_names();
288
289        // Should have all 27 tools
290        assert!(
291            names.len() >= 27,
292            "expected at least 27 tools, got {}",
293            names.len()
294        );
295
296        // Check some known tools from each domain
297        assert!(
298            reg.contains("cli_ls"),
299            "missing cli_ls from coding_agent_tools"
300        );
301        assert!(
302            reg.contains("gh_get_comments"),
303            "missing gh_get_comments from pr_comments"
304        );
305        assert!(
306            reg.contains("linear_search_issues"),
307            "missing linear_search_issues from linear_tools"
308        );
309        assert!(
310            reg.contains("ask_reasoning_model"),
311            "missing ask_reasoning_model from gpt5_reasoner"
312        );
313        assert!(
314            reg.contains("thoughts_add_reference"),
315            "missing thoughts_add_reference from thoughts_mcp_tools"
316        );
317        assert!(
318            reg.contains("thoughts_get_repo_refs"),
319            "missing thoughts_get_repo_refs from thoughts_mcp_tools"
320        );
321        assert!(
322            reg.contains("web_fetch"),
323            "missing web_fetch from web_retrieval"
324        );
325        assert!(
326            reg.contains("web_search"),
327            "missing web_search from web_retrieval"
328        );
329    }
330
331    #[test]
332    fn allowlist_filters_to_specific_tools() {
333        let mut set = HashSet::new();
334        set.insert("cli_ls".to_string());
335        set.insert("ask_reasoning_model".to_string());
336        let config = AgenticToolsConfig {
337            allowlist: Some(set),
338            ..Default::default()
339        };
340
341        let reg = AgenticTools::new(config);
342        let names = reg.list_names();
343
344        assert_eq!(names.len(), 2);
345        assert!(reg.contains("cli_ls"));
346        assert!(reg.contains("ask_reasoning_model"));
347        assert!(!reg.contains("cli_grep"));
348    }
349
350    #[test]
351    fn allowlist_is_case_insensitive() {
352        let mut set = HashSet::new();
353        set.insert("CLI_LS".to_string());
354        set.insert("ASK_REASONING_MODEL".to_string());
355        let config = AgenticToolsConfig {
356            allowlist: Some(set),
357            ..Default::default()
358        };
359
360        let reg = AgenticTools::new(config);
361
362        // Should find tools despite uppercase allowlist
363        assert!(reg.contains("cli_ls"));
364        assert!(reg.contains("ask_reasoning_model"));
365    }
366
367    #[test]
368    fn empty_allowlist_enables_all_tools() {
369        let config = AgenticToolsConfig {
370            allowlist: Some(HashSet::new()),
371            ..Default::default()
372        };
373
374        let reg = AgenticTools::new(config);
375
376        // Empty allowlist normalizes to None, enabling all tools
377        assert!(reg.len() >= 27);
378    }
379
380    #[test]
381    fn allowlist_web_search_only() {
382        let mut set = HashSet::new();
383        set.insert("web_search".to_string());
384        let config = AgenticToolsConfig {
385            allowlist: Some(set),
386            ..Default::default()
387        };
388
389        let reg = AgenticTools::new(config);
390        assert_eq!(reg.len(), 1);
391        assert!(reg.contains("web_search"));
392        assert!(!reg.contains("web_fetch"));
393    }
394
395    #[test]
396    fn allowlist_single_linear_update_issue() {
397        let mut set = HashSet::new();
398        set.insert("linear_update_issue".to_string());
399        let config = AgenticToolsConfig {
400            allowlist: Some(set),
401            ..Default::default()
402        };
403
404        let reg = AgenticTools::new(config);
405
406        assert_eq!(reg.len(), 1, "expected exactly 1 tool");
407        assert!(
408            reg.contains("linear_update_issue"),
409            "linear_update_issue must be present after single-tool allowlist"
410        );
411        assert!(!reg.contains("linear_search_issues"));
412        assert!(!reg.contains("linear_read_issue"));
413    }
414
415    #[test]
416    fn unknown_allowlist_names_are_ignored() {
417        let mut set = HashSet::new();
418        set.insert("cli_ls".to_string());
419        set.insert("nonexistent_tool".to_string());
420        let config = AgenticToolsConfig {
421            allowlist: Some(set),
422            ..Default::default()
423        };
424
425        let reg = AgenticTools::new(config);
426
427        // Should only have "cli_ls", ignoring "nonexistent_tool"
428        assert_eq!(reg.len(), 1);
429        assert!(reg.contains("cli_ls"));
430    }
431
432    #[test]
433    fn builds_with_non_default_tool_configs() {
434        // Verify that AgenticTools::new() builds successfully with non-default
435        // SubagentsConfig and ReasoningConfig values
436        let config = AgenticToolsConfig {
437            allowlist: None,
438            subagents: SubagentsConfig {
439                locator_model: "custom-haiku".into(),
440                analyzer_model: "custom-sonnet".into(),
441            },
442            reasoning: ReasoningConfig {
443                optimizer_model: "anthropic/custom-optimizer".into(),
444                executor_model: "openai/custom-executor".into(),
445                reasoning_effort: Some("high".into()),
446                api_base_url: None,
447                token_limit: None,
448            },
449            ..Default::default()
450        };
451
452        let reg = AgenticTools::new(config);
453
454        // Should build successfully with all tools
455        assert!(
456            reg.len() >= 23,
457            "expected at least 23 tools, got {}",
458            reg.len()
459        );
460
461        // Verify tools from domains that use the configs are present
462        assert!(
463            reg.contains("ask_agent"),
464            "missing ask_agent (uses subagents config)"
465        );
466        assert!(
467            reg.contains("ask_reasoning_model"),
468            "missing ask_reasoning_model (uses reasoning config)"
469        );
470    }
471}