1#[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#[derive(Clone, Debug, Default, Serialize, Deserialize)]
45pub struct AgenticToolsConfig {
46 #[serde(default)]
49 pub allowlist: Option<HashSet<String>>,
50
51 #[serde(default)]
53 pub subagents: SubagentsConfig,
54
55 #[serde(default)]
57 pub cli_tools: CliToolsConfig,
58
59 #[serde(default)]
61 pub reasoning: ReasoningConfig,
62
63 #[serde(default)]
65 pub web_retrieval: WebRetrievalConfig,
66
67 #[serde(default)]
69 pub anthropic: AnthropicServiceConfig,
70
71 #[serde(default)]
73 pub exa: ExaServiceConfig,
74
75 #[serde(default)]
77 pub extras: serde_json::Value,
78}
79
80pub struct AgenticTools;
82
83const 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 #[allow(clippy::new_ret_no_self)]
128 pub fn new(config: AgenticToolsConfig) -> ToolRegistry {
129 let allow = normalize_allowlist(config.allowlist);
130
131 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 let mut regs = Vec::new();
139
140 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 if domain_wanted(PR_COMMENTS_NAMES) {
150 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 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 if domain_wanted(GPT5_NAMES) {
173 regs.push(gpt5_reasoner::build_registry(config.reasoning.clone()));
174 }
175
176 if domain_wanted(THOUGHTS_NAMES) {
178 regs.push(thoughts_mcp_tools::build_registry());
179 }
180
181 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 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 if let Some(set) = allow {
201 let names: Vec<&str> = set.iter().map(|s| s.as_str()).collect();
202 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 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
226fn 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 } 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 #[test]
285 fn allowlist_none_builds_all_tools() {
286 let reg = AgenticTools::new(AgenticToolsConfig::default());
287 let names = reg.list_names();
288
289 assert!(
291 names.len() >= 27,
292 "expected at least 27 tools, got {}",
293 names.len()
294 );
295
296 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 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 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 assert_eq!(reg.len(), 1);
429 assert!(reg.contains("cli_ls"));
430 }
431
432 #[test]
433 fn builds_with_non_default_tool_configs() {
434 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 assert!(
456 reg.len() >= 23,
457 "expected at least 23 tools, got {}",
458 reg.len()
459 );
460
461 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}