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