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