Skip to main content

a3s_code_core/tools/
registry.rs

1//! Tool Registry
2//!
3//! Central registry for all tools (built-in and dynamic).
4//! Provides thread-safe registration, lookup, and execution.
5
6use super::artifacts::{ArtifactStore, ArtifactStoreLimits, ToolArtifact};
7use super::types::{Tool, ToolCapabilities, ToolContext, ToolOutput};
8use super::ToolResult;
9use super::{
10    merge_tool_output_artifact_metadata, tool_output_artifact, truncate_tool_output_with_artifact,
11    ToolOutputArtifact,
12};
13use crate::llm::ToolDefinition;
14use crate::trace::{InMemoryTraceSink, TraceEvent, TraceSink};
15use anyhow::Result;
16use std::collections::HashMap;
17use std::hash::{Hash, Hasher};
18use std::path::PathBuf;
19use std::sync::{Arc, RwLock};
20
21const MAX_TOOL_SCHEMA_BYTES: usize = 256 * 1024;
22const MAX_ARGUMENT_VALIDATION_ERRORS: usize = 8;
23const MAX_ARGUMENT_VALIDATION_MESSAGE_BYTES: usize = 4 * 1024;
24const MAX_INLINE_CHANGE_BYTES: usize = 64 * 1024;
25const CHANGE_SIDE_PREVIEW_BYTES: usize = 8 * 1024;
26const CHANGE_DIFF_PREVIEW_BYTES: usize = 32 * 1024;
27const MAX_DIFF_COMPUTE_BYTES: usize = 1024 * 1024;
28
29#[derive(Clone)]
30enum CachedArgumentValidator {
31    Valid(Arc<jsonschema::Validator>),
32    Invalid(String),
33}
34
35#[derive(Clone)]
36struct ArgumentValidatorCacheEntry {
37    schema_fingerprint: u64,
38    validator: CachedArgumentValidator,
39}
40
41/// Tool registry for managing all available tools
42pub struct ToolRegistry {
43    tools: RwLock<HashMap<String, Arc<dyn Tool>>>,
44    /// Names of builtin tools that cannot be overridden
45    builtins: RwLock<std::collections::HashSet<String>>,
46    context: RwLock<ToolContext>,
47    artifact_store: ArtifactStore,
48    trace_sink: RwLock<Arc<dyn TraceSink>>,
49    argument_validators: RwLock<HashMap<String, ArgumentValidatorCacheEntry>>,
50}
51
52impl ToolRegistry {
53    /// Create a new tool registry
54    pub fn new(workspace: PathBuf) -> Self {
55        Self::with_artifact_limits(workspace, ArtifactStoreLimits::default())
56    }
57
58    /// Create a new tool registry with custom artifact retention limits.
59    pub fn with_artifact_limits(workspace: PathBuf, artifact_limits: ArtifactStoreLimits) -> Self {
60        Self::with_artifact_limits_and_workspace_services(
61            workspace.clone(),
62            artifact_limits,
63            crate::workspace::WorkspaceServices::local(workspace),
64        )
65    }
66
67    /// Create a new tool registry with custom artifact limits and workspace backend.
68    pub fn with_artifact_limits_and_workspace_services(
69        workspace: PathBuf,
70        artifact_limits: ArtifactStoreLimits,
71        workspace_services: Arc<crate::workspace::WorkspaceServices>,
72    ) -> Self {
73        let context = ToolContext::new(workspace).with_workspace_services(workspace_services);
74        Self {
75            tools: RwLock::new(HashMap::new()),
76            builtins: RwLock::new(std::collections::HashSet::new()),
77            context: RwLock::new(context),
78            artifact_store: ArtifactStore::with_limits(artifact_limits),
79            trace_sink: RwLock::new(Arc::new(InMemoryTraceSink::default())),
80            argument_validators: RwLock::new(HashMap::new()),
81        }
82    }
83
84    /// Register a builtin tool (cannot be overridden by dynamic tools)
85    pub fn register_builtin(&self, tool: Arc<dyn Tool>) {
86        let name = tool.name().to_string();
87        let mut tools = self.tools.write().unwrap();
88        let mut builtins = self.builtins.write().unwrap();
89        tracing::debug!("Registering builtin tool: {}", name);
90        tools.insert(name.clone(), tool);
91        builtins.insert(name);
92    }
93
94    /// Register a tool
95    ///
96    /// If a tool with the same name already exists as a builtin, the registration
97    /// is rejected to prevent shadowing of core tools.
98    pub fn register(&self, tool: Arc<dyn Tool>) {
99        let name = tool.name().to_string();
100        // All operations that need both registry locks take `tools` first.
101        // This keeps the builtin check and insertion atomic with
102        // `register_builtin` and avoids lock-order inversion.
103        let mut tools = self.tools.write().unwrap();
104        let builtins = self.builtins.read().unwrap();
105        if builtins.contains(&name) {
106            tracing::warn!(
107                "Rejected registration of tool '{}': cannot shadow builtin",
108                name
109            );
110            return;
111        }
112        tracing::debug!("Registering tool: {}", name);
113        tools.insert(name, tool);
114    }
115
116    /// Register a dynamic tool and return the tool it shadowed.
117    ///
118    /// The lookup and replacement happen under one write lock so lifecycle
119    /// owners can later restore the exact prior registration without racing a
120    /// concurrent dynamic registration. The boolean is `false` when a builtin
121    /// owns the name and the dynamic registration was rejected.
122    pub(crate) fn register_with_shadow(
123        &self,
124        tool: Arc<dyn Tool>,
125    ) -> (bool, Option<Arc<dyn Tool>>) {
126        let name = tool.name().to_string();
127        let mut tools = self.tools.write().unwrap();
128        let builtins = self.builtins.read().unwrap();
129        if builtins.contains(&name) {
130            tracing::warn!(
131                "Rejected registration of tool '{}': cannot shadow builtin",
132                name
133            );
134            return (false, None);
135        }
136        tracing::debug!("Registering owned dynamic tool: {}", name);
137        (true, tools.insert(name, tool))
138    }
139
140    /// Restore a shadowed registration only while `expected` still owns the
141    /// name.
142    ///
143    /// This compare-and-replace prevents one lifecycle owner from deleting or
144    /// overwriting a tool installed later by another dynamic source.
145    pub(crate) fn restore_if_same(
146        &self,
147        name: &str,
148        expected: &Arc<dyn Tool>,
149        replacement: Option<Arc<dyn Tool>>,
150    ) -> bool {
151        let mut tools = self.tools.write().unwrap();
152        let Some(current) = tools.get(name) else {
153            return false;
154        };
155        if !Arc::ptr_eq(current, expected) {
156            return false;
157        }
158
159        match replacement {
160            Some(tool) => {
161                tools.insert(name.to_string(), tool);
162            }
163            None => {
164                tools.remove(name);
165            }
166        }
167        true
168    }
169
170    /// Register a dynamic tool only when no source currently owns its name.
171    pub(crate) fn register_if_absent(&self, tool: Arc<dyn Tool>) -> bool {
172        let name = tool.name().to_string();
173        let mut tools = self.tools.write().unwrap();
174        if tools.contains_key(&name) {
175            return false;
176        }
177        tracing::debug!("Registering previously absent dynamic tool: {}", name);
178        tools.insert(name, tool);
179        true
180    }
181
182    /// Unregister a tool by name
183    ///
184    /// Returns true if the tool was found and removed.
185    pub fn unregister(&self, name: &str) -> bool {
186        let mut tools = self.tools.write().unwrap();
187        let builtins = self.builtins.read().unwrap();
188        if builtins.contains(name) {
189            tracing::warn!(
190                "Rejected unregister of tool '{}': builtin tools cannot be removed through dynamic unregister",
191                name
192            );
193            return false;
194        }
195        tracing::debug!("Unregistering tool: {}", name);
196        tools.remove(name).is_some()
197    }
198
199    /// Unregister all tools whose names start with the given prefix.
200    pub fn unregister_by_prefix(&self, prefix: &str) {
201        let mut tools = self.tools.write().unwrap();
202        let builtins = self.builtins.read().unwrap();
203        tools.retain(|name, _| builtins.contains(name) || !name.starts_with(prefix));
204        tracing::debug!("Unregistered tools with prefix: {}", prefix);
205    }
206
207    /// Get a tool by name
208    pub fn get(&self, name: &str) -> Option<Arc<dyn Tool>> {
209        let tools = self.tools.read().unwrap();
210        tools.get(name).cloned()
211    }
212
213    pub(crate) fn capabilities(
214        &self,
215        name: &str,
216        args: &serde_json::Value,
217    ) -> Option<ToolCapabilities> {
218        self.get(name).map(|tool| tool.capabilities(args))
219    }
220
221    /// Check if a tool exists
222    pub fn contains(&self, name: &str) -> bool {
223        let tools = self.tools.read().unwrap();
224        tools.contains_key(name)
225    }
226
227    /// Get all tool definitions for LLM
228    pub fn definitions(&self) -> Vec<ToolDefinition> {
229        let tools = self.tools.read().unwrap();
230        let mut definitions = tools
231            .values()
232            .map(|tool| ToolDefinition {
233                name: tool.name().to_string(),
234                description: tool.description().to_string(),
235                parameters: tool.parameters(),
236            })
237            .collect::<Vec<_>>();
238        definitions.sort_by(|a, b| a.name.cmp(&b.name));
239        definitions
240    }
241
242    /// List all registered tool names
243    pub fn list(&self) -> Vec<String> {
244        let tools = self.tools.read().unwrap();
245        let mut names = tools.keys().cloned().collect::<Vec<_>>();
246        names.sort();
247        names
248    }
249
250    /// Validate model- or orchestrator-supplied arguments against the tool's
251    /// declared JSON Schema before permissions or execution side effects.
252    ///
253    /// Low-level standalone registry calls remain compatibility-oriented and
254    /// do not invoke this automatically. The governed agent/session gateway is
255    /// the enforcement boundary.
256    pub(crate) fn validate_arguments(
257        &self,
258        name: &str,
259        args: &serde_json::Value,
260    ) -> std::result::Result<(), String> {
261        let Some(tool) = self.get(name) else {
262            return Ok(());
263        };
264        let schema = tool.parameters();
265        let schema_bytes = serde_json::to_vec(&schema)
266            .map_err(|error| format!("tool parameter schema is not serializable: {error}"))?;
267        if schema_bytes.len() > MAX_TOOL_SCHEMA_BYTES {
268            return Err(format!(
269                "tool parameter schema exceeds the {} byte safety limit",
270                MAX_TOOL_SCHEMA_BYTES
271            ));
272        }
273        let mut hasher = std::collections::hash_map::DefaultHasher::new();
274        schema_bytes.hash(&mut hasher);
275        let schema_fingerprint = hasher.finish();
276        let cached = self
277            .argument_validators
278            .read()
279            .unwrap()
280            .get(name)
281            .filter(|entry| entry.schema_fingerprint == schema_fingerprint)
282            .cloned();
283        let validator = match cached.map(|entry| entry.validator) {
284            Some(CachedArgumentValidator::Valid(validator)) => validator,
285            Some(CachedArgumentValidator::Invalid(error)) => return Err(error),
286            None => {
287                let compiled = match jsonschema::draft202012::options().build(&schema) {
288                    Ok(validator) => CachedArgumentValidator::Valid(Arc::new(validator)),
289                    Err(error) => CachedArgumentValidator::Invalid(format!(
290                        "tool has an invalid parameter schema: {error}"
291                    )),
292                };
293                self.argument_validators.write().unwrap().insert(
294                    name.to_string(),
295                    ArgumentValidatorCacheEntry {
296                        schema_fingerprint,
297                        validator: compiled.clone(),
298                    },
299                );
300                match compiled {
301                    CachedArgumentValidator::Valid(validator) => validator,
302                    CachedArgumentValidator::Invalid(error) => return Err(error),
303                }
304            }
305        };
306        let mut errors = validator
307            .iter_errors(args)
308            .take(MAX_ARGUMENT_VALIDATION_ERRORS + 1)
309            .map(|error| {
310                let path = error.instance_path().to_string();
311                if path.is_empty() {
312                    format!("$: {error}")
313                } else {
314                    format!("{path}: {error}")
315                }
316            })
317            .collect::<Vec<_>>();
318        if errors.is_empty() {
319            return Ok(());
320        }
321
322        let omitted = errors.len() > MAX_ARGUMENT_VALIDATION_ERRORS;
323        errors.truncate(MAX_ARGUMENT_VALIDATION_ERRORS);
324        let mut message = errors.join("; ");
325        if omitted {
326            message.push_str("; additional validation errors omitted");
327        }
328        Err(crate::text::truncate_utf8(&message, MAX_ARGUMENT_VALIDATION_MESSAGE_BYTES).to_string())
329    }
330
331    /// Get the number of registered tools
332    pub fn len(&self) -> usize {
333        let tools = self.tools.read().unwrap();
334        tools.len()
335    }
336
337    /// Check if registry is empty
338    pub fn is_empty(&self) -> bool {
339        self.len() == 0
340    }
341
342    /// Get the tool context
343    pub fn context(&self) -> ToolContext {
344        self.context.read().unwrap().clone()
345    }
346
347    /// Return a clone of the registry's artifact store handle.
348    pub fn artifact_store(&self) -> ArtifactStore {
349        self.artifact_store.clone()
350    }
351
352    /// Get a stored tool artifact by URI.
353    pub fn get_artifact(&self, artifact_uri: &str) -> Option<ToolArtifact> {
354        self.artifact_store.get(artifact_uri)
355    }
356
357    /// Replace the trace sink used for compact tool/program execution events.
358    pub fn set_trace_sink(&self, sink: Arc<dyn TraceSink>) {
359        *self.trace_sink.write().unwrap() = sink;
360    }
361
362    /// Return the current trace sink.
363    pub fn trace_sink(&self) -> Arc<dyn TraceSink> {
364        Arc::clone(&self.trace_sink.read().unwrap())
365    }
366
367    /// Set the search configuration for the tool context
368    pub fn set_search_config(&self, config: crate::config::SearchConfig) {
369        let mut ctx = self.context.write().unwrap();
370        *ctx = ctx.clone().with_search_config(config);
371    }
372
373    /// Set a sandbox executor so that `bash` tool calls use the sandbox even
374    /// when executed without an explicit `ToolContext` (i.e., via `execute()`).
375    pub fn set_sandbox(&self, sandbox: std::sync::Arc<dyn crate::sandbox::BashSandbox>) {
376        let mut ctx = self.context.write().unwrap();
377        *ctx = ctx.clone().with_sandbox(sandbox);
378    }
379
380    /// Set environment overrides used by subprocess-backed tools when executed
381    /// without an explicit context.
382    pub fn set_command_env(&self, env: Arc<HashMap<String, String>>) {
383        let mut ctx = self.context.write().unwrap();
384        *ctx = ctx.clone().with_command_env(env);
385    }
386
387    /// Execute a tool by name using the registry's default context.
388    ///
389    /// This is the lowest-level standalone registry boundary. It does not run
390    /// agent/session permission, HITL, hook, budget, queue, timeout,
391    /// cancellation, or sanitization policy.
392    pub async fn execute(&self, name: &str, args: &serde_json::Value) -> Result<ToolResult> {
393        let ctx = self.context();
394        self.execute_with_context(name, args, &ctx).await
395    }
396
397    /// Execute a tool by name with an external caller-owned context.
398    ///
399    /// This remains a low-level ungoverned call; agent/session paths must use
400    /// their scoped invocation gateway instead.
401    pub async fn execute_with_context(
402        &self,
403        name: &str,
404        args: &serde_json::Value,
405        ctx: &ToolContext,
406    ) -> Result<ToolResult> {
407        let start = std::time::Instant::now();
408
409        let tool = self.get(name);
410
411        let result = match tool {
412            Some(tool) => {
413                let mut output = tool.execute(args, ctx).await?;
414                self.compact_change_metadata(name, &mut output.metadata);
415                let original_content = output.content.clone();
416                let truncated = truncate_tool_output_with_artifact(name, &output.content);
417                output.content = truncated.content;
418                if let Some(artifact) = truncated.artifact {
419                    self.store_tool_artifact(name, &original_content, &artifact);
420                    output.metadata = Some(merge_tool_output_artifact_metadata(
421                        output.metadata,
422                        &artifact,
423                    ));
424                }
425                Ok(ToolResult {
426                    name: name.to_string(),
427                    output: output.content,
428                    exit_code: if output.success { 0 } else { 1 },
429                    metadata: output.metadata,
430                    images: output.images,
431                    error_kind: output.error_kind,
432                })
433            }
434            None => Ok(ToolResult::error(name, format!("Unknown tool: {}", name))),
435        };
436
437        if let Ok(ref r) = result {
438            crate::telemetry::record_tool_result(r.exit_code, start.elapsed());
439            self.record_trace_event(name, r, start.elapsed());
440        }
441
442        result
443    }
444
445    /// Execute a tool and return raw output using the registry's default context
446    pub async fn execute_raw(
447        &self,
448        name: &str,
449        args: &serde_json::Value,
450    ) -> Result<Option<ToolOutput>> {
451        let ctx = self.context();
452        self.execute_raw_with_context(name, args, &ctx).await
453    }
454
455    /// Execute a tool and return raw output with an external context
456    pub async fn execute_raw_with_context(
457        &self,
458        name: &str,
459        args: &serde_json::Value,
460        ctx: &ToolContext,
461    ) -> Result<Option<ToolOutput>> {
462        let tool = self.get(name);
463
464        match tool {
465            Some(tool) => {
466                let mut output = tool.execute(args, ctx).await?;
467                self.compact_change_metadata(name, &mut output.metadata);
468                let original_content = output.content.clone();
469                let truncated = truncate_tool_output_with_artifact(name, &output.content);
470                output.content = truncated.content;
471                if let Some(artifact) = truncated.artifact {
472                    self.store_tool_artifact(name, &original_content, &artifact);
473                    output.metadata = Some(merge_tool_output_artifact_metadata(
474                        output.metadata,
475                        &artifact,
476                    ));
477                }
478                Ok(Some(output))
479            }
480            None => Ok(None),
481        }
482    }
483
484    fn store_tool_artifact(&self, tool_name: &str, content: &str, artifact: &ToolOutputArtifact) {
485        self.artifact_store.put(ToolArtifact {
486            artifact_id: artifact.artifact_id.clone(),
487            artifact_uri: artifact.artifact_uri.clone(),
488            tool_name: tool_name.to_string(),
489            content: content.to_string(),
490            original_bytes: artifact.original_bytes,
491            shown_bytes: artifact.shown_bytes,
492        });
493    }
494
495    fn compact_change_metadata(&self, tool_name: &str, metadata: &mut Option<serde_json::Value>) {
496        let Some(serde_json::Value::Object(object)) = metadata.as_mut() else {
497            return;
498        };
499        let before = object
500            .get("before")
501            .and_then(serde_json::Value::as_str)
502            .map(ToString::to_string);
503        let after = object
504            .get("after")
505            .and_then(serde_json::Value::as_str)
506            .map(ToString::to_string);
507        if before.is_none() && after.is_none() {
508            return;
509        }
510
511        let before_bytes = before.as_ref().map_or(0, String::len);
512        let after_bytes = after.as_ref().map_or(0, String::len);
513        let total_bytes = before_bytes.saturating_add(after_bytes);
514        let compacted = total_bytes > MAX_INLINE_CHANGE_BYTES;
515        let before_artifact = before.as_deref().and_then(|content| {
516            self.store_change_artifact(tool_name, "before", content, compacted)
517        });
518        let after_artifact = after
519            .as_deref()
520            .and_then(|content| self.store_change_artifact(tool_name, "after", content, compacted));
521
522        let unified_diff = if compacted && total_bytes <= MAX_DIFF_COMPUTE_BYTES {
523            let diff = similar::TextDiff::from_lines(
524                before.as_deref().unwrap_or_default(),
525                after.as_deref().unwrap_or_default(),
526            )
527            .unified_diff()
528            .context_radius(3)
529            .header("before", "after")
530            .to_string();
531            Some(bounded_head_tail(&diff, CHANGE_DIFF_PREVIEW_BYTES))
532        } else {
533            None
534        };
535
536        if compacted {
537            if let Some(content) = before.as_deref() {
538                object.insert(
539                    "before".to_string(),
540                    serde_json::Value::String(bounded_head_tail(
541                        content,
542                        CHANGE_SIDE_PREVIEW_BYTES,
543                    )),
544                );
545            }
546            if let Some(content) = after.as_deref() {
547                object.insert(
548                    "after".to_string(),
549                    serde_json::Value::String(bounded_head_tail(
550                        content,
551                        CHANGE_SIDE_PREVIEW_BYTES,
552                    )),
553                );
554            }
555        }
556
557        object.insert(
558            "change".to_string(),
559            serde_json::json!({
560                "compacted": compacted,
561                "before": before.as_deref().map(|content| serde_json::json!({
562                    "bytes": content.len(),
563                    "sha256": sha256::digest(content.as_bytes()),
564                    "artifact": before_artifact,
565                })),
566                "after": after.as_deref().map(|content| serde_json::json!({
567                    "bytes": content.len(),
568                    "sha256": sha256::digest(content.as_bytes()),
569                    "artifact": after_artifact,
570                })),
571                "unified_diff": unified_diff,
572                "diff_omitted": compacted && total_bytes > MAX_DIFF_COMPUTE_BYTES,
573            }),
574        );
575    }
576
577    fn store_change_artifact(
578        &self,
579        tool_name: &str,
580        side: &str,
581        content: &str,
582        store: bool,
583    ) -> Option<serde_json::Value> {
584        if !store || content.len() > self.artifact_store.limits().max_bytes {
585            return None;
586        }
587        let artifact = tool_output_artifact(&format!("{tool_name}-{side}"), content, 0);
588        self.store_tool_artifact(tool_name, content, &artifact);
589        Some(serde_json::json!({
590            "artifact_id": artifact.artifact_id,
591            "artifact_uri": artifact.artifact_uri,
592        }))
593    }
594
595    fn record_trace_event(&self, name: &str, result: &ToolResult, duration: std::time::Duration) {
596        let sink = self.trace_sink();
597        sink.record(TraceEvent::tool_execution(
598            name,
599            result.exit_code == 0,
600            result.exit_code,
601            duration,
602            result.output.len(),
603            result.metadata.as_ref(),
604        ));
605
606        if name == "program" {
607            sink.record(TraceEvent::program_execution(
608                name,
609                result.exit_code == 0,
610                result.exit_code,
611                duration,
612                result.output.len(),
613                result.metadata.as_ref(),
614            ));
615        }
616    }
617}
618
619fn bounded_head_tail(content: &str, max_bytes: usize) -> String {
620    if content.len() <= max_bytes {
621        return content.to_string();
622    }
623    let head_limit = max_bytes / 2;
624    let tail_limit = max_bytes.saturating_sub(head_limit);
625    let head = crate::text::truncate_utf8(content, head_limit);
626    let mut tail_start = content.len().saturating_sub(tail_limit);
627    while tail_start < content.len() && !content.is_char_boundary(tail_start) {
628        tail_start += 1;
629    }
630    format!(
631        "{}\n\n... [{} bytes omitted from middle] ...\n\n{}",
632        head,
633        content
634            .len()
635            .saturating_sub(head.len())
636            .saturating_sub(content.len().saturating_sub(tail_start)),
637        &content[tail_start..]
638    )
639}
640
641#[cfg(test)]
642#[path = "registry/tests.rs"]
643mod tests;