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    pub(crate) fn requires_confirmation(&self, name: &str, args: &serde_json::Value) -> bool {
222        self.get(name)
223            .is_some_and(|tool| tool.requires_confirmation(args))
224    }
225
226    /// Check if a tool exists
227    pub fn contains(&self, name: &str) -> bool {
228        let tools = self.tools.read().unwrap();
229        tools.contains_key(name)
230    }
231
232    /// Get all tool definitions for LLM
233    pub fn definitions(&self) -> Vec<ToolDefinition> {
234        let tools = self.tools.read().unwrap();
235        let mut definitions = tools
236            .values()
237            .map(|tool| tool.definition())
238            .collect::<Vec<_>>();
239        definitions.sort_by(|a, b| a.name.cmp(&b.name));
240        definitions
241    }
242
243    /// List all registered tool names
244    pub fn list(&self) -> Vec<String> {
245        let tools = self.tools.read().unwrap();
246        let mut names = tools.keys().cloned().collect::<Vec<_>>();
247        names.sort();
248        names
249    }
250
251    /// Validate model- or orchestrator-supplied arguments against the tool's
252    /// declared JSON Schema before permissions or execution side effects.
253    ///
254    /// Low-level standalone registry calls remain compatibility-oriented and
255    /// do not invoke this automatically. The governed agent/session gateway is
256    /// the enforcement boundary.
257    pub(crate) fn validate_arguments(
258        &self,
259        name: &str,
260        args: &serde_json::Value,
261    ) -> std::result::Result<(), String> {
262        let Some(tool) = self.get(name) else {
263            return Ok(());
264        };
265        let schema = tool.parameters();
266        let schema_bytes = serde_json::to_vec(&schema)
267            .map_err(|error| format!("tool parameter schema is not serializable: {error}"))?;
268        if schema_bytes.len() > MAX_TOOL_SCHEMA_BYTES {
269            return Err(format!(
270                "tool parameter schema exceeds the {} byte safety limit",
271                MAX_TOOL_SCHEMA_BYTES
272            ));
273        }
274        let mut hasher = std::collections::hash_map::DefaultHasher::new();
275        schema_bytes.hash(&mut hasher);
276        let schema_fingerprint = hasher.finish();
277        let cached = self
278            .argument_validators
279            .read()
280            .unwrap()
281            .get(name)
282            .filter(|entry| entry.schema_fingerprint == schema_fingerprint)
283            .cloned();
284        let validator = match cached.map(|entry| entry.validator) {
285            Some(CachedArgumentValidator::Valid(validator)) => validator,
286            Some(CachedArgumentValidator::Invalid(error)) => return Err(error),
287            None => {
288                let compiled = match jsonschema::draft202012::options().build(&schema) {
289                    Ok(validator) => CachedArgumentValidator::Valid(Arc::new(validator)),
290                    Err(error) => CachedArgumentValidator::Invalid(format!(
291                        "tool has an invalid parameter schema: {error}"
292                    )),
293                };
294                self.argument_validators.write().unwrap().insert(
295                    name.to_string(),
296                    ArgumentValidatorCacheEntry {
297                        schema_fingerprint,
298                        validator: compiled.clone(),
299                    },
300                );
301                match compiled {
302                    CachedArgumentValidator::Valid(validator) => validator,
303                    CachedArgumentValidator::Invalid(error) => return Err(error),
304                }
305            }
306        };
307        let mut errors = validator
308            .iter_errors(args)
309            .take(MAX_ARGUMENT_VALIDATION_ERRORS + 1)
310            .map(|error| {
311                let path = error.instance_path().to_string();
312                if path.is_empty() {
313                    format!("$: {error}")
314                } else {
315                    format!("{path}: {error}")
316                }
317            })
318            .collect::<Vec<_>>();
319        if errors.is_empty() {
320            return Ok(());
321        }
322
323        let omitted = errors.len() > MAX_ARGUMENT_VALIDATION_ERRORS;
324        errors.truncate(MAX_ARGUMENT_VALIDATION_ERRORS);
325        let mut message = errors.join("; ");
326        if omitted {
327            message.push_str("; additional validation errors omitted");
328        }
329        Err(crate::text::truncate_utf8(&message, MAX_ARGUMENT_VALIDATION_MESSAGE_BYTES).to_string())
330    }
331
332    /// Get the number of registered tools
333    pub fn len(&self) -> usize {
334        let tools = self.tools.read().unwrap();
335        tools.len()
336    }
337
338    /// Check if registry is empty
339    pub fn is_empty(&self) -> bool {
340        self.len() == 0
341    }
342
343    /// Get the tool context
344    pub fn context(&self) -> ToolContext {
345        self.context.read().unwrap().clone()
346    }
347
348    /// Return a clone of the registry's artifact store handle.
349    pub fn artifact_store(&self) -> ArtifactStore {
350        self.artifact_store.clone()
351    }
352
353    /// Get a stored tool artifact by URI.
354    pub fn get_artifact(&self, artifact_uri: &str) -> Option<ToolArtifact> {
355        self.artifact_store.get(artifact_uri)
356    }
357
358    /// Replace the trace sink used for compact tool/program execution events.
359    pub fn set_trace_sink(&self, sink: Arc<dyn TraceSink>) {
360        *self.trace_sink.write().unwrap() = sink;
361    }
362
363    /// Return the current trace sink.
364    pub fn trace_sink(&self) -> Arc<dyn TraceSink> {
365        Arc::clone(&self.trace_sink.read().unwrap())
366    }
367
368    /// Set the search configuration for the tool context
369    pub fn set_search_config(&self, config: crate::config::SearchConfig) {
370        let mut ctx = self.context.write().unwrap();
371        *ctx = ctx.clone().with_search_config(config);
372    }
373
374    /// Set a sandbox executor so that `bash` tool calls use the sandbox even
375    /// when executed without an explicit `ToolContext` (i.e., via `execute()`).
376    pub fn set_sandbox(&self, sandbox: std::sync::Arc<dyn crate::sandbox::BashSandbox>) {
377        let mut ctx = self.context.write().unwrap();
378        *ctx = ctx.clone().with_sandbox(sandbox);
379    }
380
381    /// Set environment overrides used by subprocess-backed tools when executed
382    /// without an explicit context.
383    pub fn set_command_env(&self, env: Arc<HashMap<String, String>>) {
384        let mut ctx = self.context.write().unwrap();
385        *ctx = ctx.clone().with_command_env(env);
386    }
387
388    /// Execute a tool by name using the registry's default context.
389    ///
390    /// This is the lowest-level standalone registry boundary. It does not run
391    /// agent/session permission, HITL, hook, budget, queue, timeout,
392    /// cancellation, or sanitization policy.
393    pub async fn execute(&self, name: &str, args: &serde_json::Value) -> Result<ToolResult> {
394        let ctx = self.context();
395        self.execute_with_context(name, args, &ctx).await
396    }
397
398    /// Execute a tool by name with an external caller-owned context.
399    ///
400    /// This remains a low-level ungoverned call; agent/session paths must use
401    /// their scoped invocation gateway instead.
402    pub async fn execute_with_context(
403        &self,
404        name: &str,
405        args: &serde_json::Value,
406        ctx: &ToolContext,
407    ) -> Result<ToolResult> {
408        let start = std::time::Instant::now();
409
410        let tool = self.get(name);
411
412        let result = match tool {
413            Some(tool) => {
414                let mut output = tool.execute(args, ctx).await?;
415                self.compact_change_metadata(name, &mut output.metadata);
416                let original_content = output.content.clone();
417                let truncated = truncate_tool_output_with_artifact(name, &output.content);
418                output.content = truncated.content;
419                if let Some(artifact) = truncated.artifact {
420                    self.store_tool_artifact(name, &original_content, &artifact);
421                    output.metadata = Some(merge_tool_output_artifact_metadata(
422                        output.metadata,
423                        &artifact,
424                    ));
425                }
426                Ok(ToolResult {
427                    name: name.to_string(),
428                    output: output.content,
429                    exit_code: if output.success { 0 } else { 1 },
430                    metadata: output.metadata,
431                    images: output.images,
432                    error_kind: output.error_kind,
433                })
434            }
435            None => Ok(ToolResult::error(name, format!("Unknown tool: {}", name))),
436        };
437
438        if let Ok(ref r) = result {
439            crate::telemetry::record_tool_result(r.exit_code, start.elapsed());
440            self.record_trace_event(name, r, start.elapsed());
441        }
442
443        result
444    }
445
446    /// Execute a tool and return raw output using the registry's default context
447    pub async fn execute_raw(
448        &self,
449        name: &str,
450        args: &serde_json::Value,
451    ) -> Result<Option<ToolOutput>> {
452        let ctx = self.context();
453        self.execute_raw_with_context(name, args, &ctx).await
454    }
455
456    /// Execute a tool and return raw output with an external context
457    pub async fn execute_raw_with_context(
458        &self,
459        name: &str,
460        args: &serde_json::Value,
461        ctx: &ToolContext,
462    ) -> Result<Option<ToolOutput>> {
463        let tool = self.get(name);
464
465        match tool {
466            Some(tool) => {
467                let mut output = tool.execute(args, ctx).await?;
468                self.compact_change_metadata(name, &mut output.metadata);
469                let original_content = output.content.clone();
470                let truncated = truncate_tool_output_with_artifact(name, &output.content);
471                output.content = truncated.content;
472                if let Some(artifact) = truncated.artifact {
473                    self.store_tool_artifact(name, &original_content, &artifact);
474                    output.metadata = Some(merge_tool_output_artifact_metadata(
475                        output.metadata,
476                        &artifact,
477                    ));
478                }
479                Ok(Some(output))
480            }
481            None => Ok(None),
482        }
483    }
484
485    fn store_tool_artifact(&self, tool_name: &str, content: &str, artifact: &ToolOutputArtifact) {
486        self.artifact_store.put(ToolArtifact {
487            artifact_id: artifact.artifact_id.clone(),
488            artifact_uri: artifact.artifact_uri.clone(),
489            tool_name: tool_name.to_string(),
490            content: content.to_string(),
491            original_bytes: artifact.original_bytes,
492            shown_bytes: artifact.shown_bytes,
493        });
494    }
495
496    fn compact_change_metadata(&self, tool_name: &str, metadata: &mut Option<serde_json::Value>) {
497        let Some(serde_json::Value::Object(object)) = metadata.as_mut() else {
498            return;
499        };
500        let before = object
501            .get("before")
502            .and_then(serde_json::Value::as_str)
503            .map(ToString::to_string);
504        let after = object
505            .get("after")
506            .and_then(serde_json::Value::as_str)
507            .map(ToString::to_string);
508        if before.is_none() && after.is_none() {
509            return;
510        }
511
512        let before_bytes = before.as_ref().map_or(0, String::len);
513        let after_bytes = after.as_ref().map_or(0, String::len);
514        let total_bytes = before_bytes.saturating_add(after_bytes);
515        let compacted = total_bytes > MAX_INLINE_CHANGE_BYTES;
516        let before_artifact = before.as_deref().and_then(|content| {
517            self.store_change_artifact(tool_name, "before", content, compacted)
518        });
519        let after_artifact = after
520            .as_deref()
521            .and_then(|content| self.store_change_artifact(tool_name, "after", content, compacted));
522
523        let unified_diff = if compacted && total_bytes <= MAX_DIFF_COMPUTE_BYTES {
524            let diff = similar::TextDiff::from_lines(
525                before.as_deref().unwrap_or_default(),
526                after.as_deref().unwrap_or_default(),
527            )
528            .unified_diff()
529            .context_radius(3)
530            .header("before", "after")
531            .to_string();
532            Some(bounded_head_tail(&diff, CHANGE_DIFF_PREVIEW_BYTES))
533        } else {
534            None
535        };
536
537        if compacted {
538            if let Some(content) = before.as_deref() {
539                object.insert(
540                    "before".to_string(),
541                    serde_json::Value::String(bounded_head_tail(
542                        content,
543                        CHANGE_SIDE_PREVIEW_BYTES,
544                    )),
545                );
546            }
547            if let Some(content) = after.as_deref() {
548                object.insert(
549                    "after".to_string(),
550                    serde_json::Value::String(bounded_head_tail(
551                        content,
552                        CHANGE_SIDE_PREVIEW_BYTES,
553                    )),
554                );
555            }
556        }
557
558        object.insert(
559            "change".to_string(),
560            serde_json::json!({
561                "compacted": compacted,
562                "before": before.as_deref().map(|content| serde_json::json!({
563                    "bytes": content.len(),
564                    "sha256": sha256::digest(content.as_bytes()),
565                    "artifact": before_artifact,
566                })),
567                "after": after.as_deref().map(|content| serde_json::json!({
568                    "bytes": content.len(),
569                    "sha256": sha256::digest(content.as_bytes()),
570                    "artifact": after_artifact,
571                })),
572                "unified_diff": unified_diff,
573                "diff_omitted": compacted && total_bytes > MAX_DIFF_COMPUTE_BYTES,
574            }),
575        );
576    }
577
578    fn store_change_artifact(
579        &self,
580        tool_name: &str,
581        side: &str,
582        content: &str,
583        store: bool,
584    ) -> Option<serde_json::Value> {
585        if !store || content.len() > self.artifact_store.limits().max_bytes {
586            return None;
587        }
588        let artifact = tool_output_artifact(&format!("{tool_name}-{side}"), content, 0);
589        self.store_tool_artifact(tool_name, content, &artifact);
590        Some(serde_json::json!({
591            "artifact_id": artifact.artifact_id,
592            "artifact_uri": artifact.artifact_uri,
593        }))
594    }
595
596    fn record_trace_event(&self, name: &str, result: &ToolResult, duration: std::time::Duration) {
597        let sink = self.trace_sink();
598        sink.record(TraceEvent::tool_execution(
599            name,
600            result.exit_code == 0,
601            result.exit_code,
602            duration,
603            result.output.len(),
604            result.metadata.as_ref(),
605        ));
606
607        if name == "program" {
608            sink.record(TraceEvent::program_execution(
609                name,
610                result.exit_code == 0,
611                result.exit_code,
612                duration,
613                result.output.len(),
614                result.metadata.as_ref(),
615            ));
616        }
617    }
618}
619
620fn bounded_head_tail(content: &str, max_bytes: usize) -> String {
621    if content.len() <= max_bytes {
622        return content.to_string();
623    }
624    let head_limit = max_bytes / 2;
625    let tail_limit = max_bytes.saturating_sub(head_limit);
626    let head = crate::text::truncate_utf8(content, head_limit);
627    let mut tail_start = content.len().saturating_sub(tail_limit);
628    while tail_start < content.len() && !content.is_char_boundary(tail_start) {
629        tail_start += 1;
630    }
631    format!(
632        "{}\n\n... [{} bytes omitted from middle] ...\n\n{}",
633        head,
634        content
635            .len()
636            .saturating_sub(head.len())
637            .saturating_sub(content.len().saturating_sub(tail_start)),
638        &content[tail_start..]
639    )
640}
641
642#[cfg(test)]
643#[path = "registry/tests.rs"]
644mod tests;