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    ImmutableContentAdapterSession, ImmutableContentError, ImmutableContentKindV1,
12    ToolOutputArtifact, ToolResultTransformBindingV1, ToolResultTransformPolicyV1,
13    TOOL_RESULT_CONTENT_MEDIA_TYPE,
14};
15use crate::llm::ToolDefinition;
16use crate::trace::{InMemoryTraceSink, TraceEvent, TraceSink};
17use anyhow::Result;
18use std::collections::HashMap;
19use std::hash::{Hash, Hasher};
20use std::path::PathBuf;
21use std::sync::{Arc, RwLock};
22use thiserror::Error;
23
24const MAX_TOOL_SCHEMA_BYTES: usize = 256 * 1024;
25const MAX_ARGUMENT_VALIDATION_ERRORS: usize = 8;
26const MAX_ARGUMENT_VALIDATION_MESSAGE_BYTES: usize = 4 * 1024;
27const MAX_INLINE_CHANGE_BYTES: usize = 64 * 1024;
28const CHANGE_SIDE_PREVIEW_BYTES: usize = 8 * 1024;
29const CHANGE_DIFF_PREVIEW_BYTES: usize = 32 * 1024;
30const MAX_DIFF_COMPUTE_BYTES: usize = 1024 * 1024;
31
32#[derive(Debug, Error)]
33#[error("projected tool name '{name}' conflicts with the compatibility registry")]
34pub(crate) struct ToolRegistrySnapshotError {
35    name: String,
36}
37
38impl ToolRegistrySnapshotError {
39    pub(crate) fn name(&self) -> &str {
40        &self.name
41    }
42}
43
44#[derive(Clone)]
45enum CachedArgumentValidator {
46    Valid(Arc<jsonschema::Validator>),
47    Invalid(String),
48}
49
50#[derive(Clone)]
51struct ArgumentValidatorCacheEntry {
52    schema_fingerprint: u64,
53    validator: CachedArgumentValidator,
54}
55
56/// Tool registry for managing all available tools
57pub struct ToolRegistry {
58    tools: RwLock<HashMap<String, Arc<dyn Tool>>>,
59    /// Names of builtin tools that cannot be overridden
60    builtins: RwLock<std::collections::HashSet<String>>,
61    context: RwLock<ToolContext>,
62    artifact_store: ArtifactStore,
63    immutable_content_adapter: Option<ImmutableContentAdapterSession>,
64    trace_sink: RwLock<Arc<dyn TraceSink>>,
65    argument_validators: RwLock<HashMap<String, ArgumentValidatorCacheEntry>>,
66    transform_policy: RwLock<ToolResultTransformPolicyV1>,
67}
68
69/// Output prepared by the registry's single observation pipeline.
70///
71/// Keeping the transform binding alongside the output prevents the `ToolResult`
72/// and `ToolOutput` entry points from independently reimplementing artifact,
73/// compaction, and evidence handling. The public raw-output API is intentionally
74/// only a shape projection over this prepared result.
75struct PreparedToolOutput {
76    output: Option<ToolOutput>,
77    transform_binding: ToolResultTransformBindingV1,
78}
79
80impl ToolRegistry {
81    /// Create a new tool registry
82    pub fn new(workspace: PathBuf) -> Self {
83        Self::with_artifact_limits(workspace, ArtifactStoreLimits::default())
84    }
85
86    /// Create a new tool registry with custom artifact retention limits.
87    pub fn with_artifact_limits(workspace: PathBuf, artifact_limits: ArtifactStoreLimits) -> Self {
88        Self::with_artifact_limits_and_workspace_services(
89            workspace.clone(),
90            artifact_limits,
91            crate::workspace::WorkspaceServices::local(workspace),
92        )
93    }
94
95    /// Create a new tool registry with custom artifact limits and workspace backend.
96    pub fn with_artifact_limits_and_workspace_services(
97        workspace: PathBuf,
98        artifact_limits: ArtifactStoreLimits,
99        workspace_services: Arc<crate::workspace::WorkspaceServices>,
100    ) -> Self {
101        Self::with_workspace_services_artifact_limits_and_immutable_content_adapter(
102            workspace,
103            artifact_limits,
104            workspace_services,
105            None,
106        )
107    }
108
109    pub(crate) fn with_workspace_services_artifact_limits_and_immutable_content_adapter(
110        workspace: PathBuf,
111        artifact_limits: ArtifactStoreLimits,
112        workspace_services: Arc<crate::workspace::WorkspaceServices>,
113        immutable_content_adapter: Option<ImmutableContentAdapterSession>,
114    ) -> Self {
115        let context = ToolContext::new(workspace).with_workspace_services(workspace_services);
116        Self {
117            tools: RwLock::new(HashMap::new()),
118            builtins: RwLock::new(std::collections::HashSet::new()),
119            context: RwLock::new(context),
120            artifact_store: ArtifactStore::with_limits(artifact_limits),
121            immutable_content_adapter,
122            trace_sink: RwLock::new(Arc::new(InMemoryTraceSink::default())),
123            argument_validators: RwLock::new(HashMap::new()),
124            transform_policy: RwLock::new(ToolResultTransformPolicyV1::default()),
125        }
126    }
127
128    /// Freeze the current compatibility registry and add one conflict-free
129    /// external generation. The returned registry shares service handles and
130    /// exact Tool `Arc`s but no mutable name map with the source registry.
131    pub(crate) fn snapshot_with_external_tools(
132        &self,
133        external: impl IntoIterator<Item = Arc<dyn Tool>>,
134    ) -> Result<Self, ToolRegistrySnapshotError> {
135        // Preserve the registry's established lock order: tools before
136        // builtins. The remaining fields have no operation that takes either
137        // lock while holding their own lock.
138        let mut tools = self.tools.read().unwrap().clone();
139        let builtins = self.builtins.read().unwrap().clone();
140        for tool in external {
141            let name = tool.name().to_owned();
142            if tools.contains_key(&name) {
143                return Err(ToolRegistrySnapshotError { name });
144            }
145            tools.insert(name, tool);
146        }
147        Ok(Self {
148            tools: RwLock::new(tools),
149            builtins: RwLock::new(builtins),
150            context: RwLock::new(self.context.read().unwrap().clone()),
151            artifact_store: self.artifact_store.clone(),
152            immutable_content_adapter: self.immutable_content_adapter.clone(),
153            trace_sink: RwLock::new(Arc::clone(&self.trace_sink.read().unwrap())),
154            argument_validators: RwLock::new(self.argument_validators.read().unwrap().clone()),
155            transform_policy: RwLock::new(self.transform_policy.read().unwrap().clone()),
156        })
157    }
158
159    pub(crate) fn set_tool_result_transform_policy(
160        &self,
161        policy: ToolResultTransformPolicyV1,
162    ) -> Result<()> {
163        policy.validate()?;
164        *self.transform_policy.write().unwrap() = policy;
165        Ok(())
166    }
167
168    /// Register a builtin tool (cannot be overridden by dynamic tools)
169    pub fn register_builtin(&self, tool: Arc<dyn Tool>) {
170        let name = tool.name().to_string();
171        let mut tools = self.tools.write().unwrap();
172        let mut builtins = self.builtins.write().unwrap();
173        tracing::debug!("Registering builtin tool: {}", name);
174        tools.insert(name.clone(), tool);
175        builtins.insert(name);
176    }
177
178    /// Register a tool
179    ///
180    /// If a tool with the same name already exists as a builtin, the registration
181    /// is rejected to prevent shadowing of core tools.
182    pub fn register(&self, tool: Arc<dyn Tool>) {
183        let name = tool.name().to_string();
184        // All operations that need both registry locks take `tools` first.
185        // This keeps the builtin check and insertion atomic with
186        // `register_builtin` and avoids lock-order inversion.
187        let mut tools = self.tools.write().unwrap();
188        let builtins = self.builtins.read().unwrap();
189        if builtins.contains(&name) {
190            tracing::warn!(
191                "Rejected registration of tool '{}': cannot shadow builtin",
192                name
193            );
194            return;
195        }
196        tracing::debug!("Registering tool: {}", name);
197        tools.insert(name, tool);
198    }
199
200    /// Register a dynamic tool and return the tool it shadowed.
201    ///
202    /// The lookup and replacement happen under one write lock so lifecycle
203    /// owners can later restore the exact prior registration without racing a
204    /// concurrent dynamic registration. The boolean is `false` when a builtin
205    /// owns the name and the dynamic registration was rejected.
206    pub(crate) fn register_with_shadow(
207        &self,
208        tool: Arc<dyn Tool>,
209    ) -> (bool, Option<Arc<dyn Tool>>) {
210        let name = tool.name().to_string();
211        let mut tools = self.tools.write().unwrap();
212        let builtins = self.builtins.read().unwrap();
213        if builtins.contains(&name) {
214            tracing::warn!(
215                "Rejected registration of tool '{}': cannot shadow builtin",
216                name
217            );
218            return (false, None);
219        }
220        tracing::debug!("Registering owned dynamic tool: {}", name);
221        (true, tools.insert(name, tool))
222    }
223
224    /// Restore a shadowed registration only while `expected` still owns the
225    /// name.
226    ///
227    /// This compare-and-replace prevents one lifecycle owner from deleting or
228    /// overwriting a tool installed later by another dynamic source.
229    pub(crate) fn restore_if_same(
230        &self,
231        name: &str,
232        expected: &Arc<dyn Tool>,
233        replacement: Option<Arc<dyn Tool>>,
234    ) -> bool {
235        let mut tools = self.tools.write().unwrap();
236        let Some(current) = tools.get(name) else {
237            return false;
238        };
239        if !Arc::ptr_eq(current, expected) {
240            return false;
241        }
242
243        match replacement {
244            Some(tool) => {
245                tools.insert(name.to_string(), tool);
246            }
247            None => {
248                tools.remove(name);
249            }
250        }
251        true
252    }
253
254    /// Register a dynamic tool only when no source currently owns its name.
255    pub(crate) fn register_if_absent(&self, tool: Arc<dyn Tool>) -> bool {
256        let name = tool.name().to_string();
257        let mut tools = self.tools.write().unwrap();
258        if tools.contains_key(&name) {
259            return false;
260        }
261        tracing::debug!("Registering previously absent dynamic tool: {}", name);
262        tools.insert(name, tool);
263        true
264    }
265
266    /// Unregister a tool by name
267    ///
268    /// Returns true if the tool was found and removed.
269    pub fn unregister(&self, name: &str) -> bool {
270        let mut tools = self.tools.write().unwrap();
271        let builtins = self.builtins.read().unwrap();
272        if builtins.contains(name) {
273            tracing::warn!(
274                "Rejected unregister of tool '{}': builtin tools cannot be removed through dynamic unregister",
275                name
276            );
277            return false;
278        }
279        tracing::debug!("Unregistering tool: {}", name);
280        tools.remove(name).is_some()
281    }
282
283    /// Unregister all tools whose names start with the given prefix.
284    pub fn unregister_by_prefix(&self, prefix: &str) {
285        let mut tools = self.tools.write().unwrap();
286        let builtins = self.builtins.read().unwrap();
287        tools.retain(|name, _| builtins.contains(name) || !name.starts_with(prefix));
288        tracing::debug!("Unregistered tools with prefix: {}", prefix);
289    }
290
291    /// Get a tool by name
292    pub fn get(&self, name: &str) -> Option<Arc<dyn Tool>> {
293        let tools = self.tools.read().unwrap();
294        let canonical = crate::tool_name::canonical_tool_name(name);
295        if let Some(tool) = tools.get(canonical) {
296            return Some(tool.clone());
297        }
298        let lower = canonical.to_ascii_lowercase();
299        if lower != canonical {
300            if let Some(tool) = tools.get(&lower) {
301                return Some(tool.clone());
302            }
303        }
304        tools
305            .iter()
306            .find(|(registered, _)| registered.eq_ignore_ascii_case(canonical))
307            .map(|(_, tool)| tool.clone())
308    }
309
310    pub(crate) fn capabilities(
311        &self,
312        name: &str,
313        args: &serde_json::Value,
314    ) -> Option<ToolCapabilities> {
315        self.get(name).map(|tool| tool.capabilities(args))
316    }
317
318    pub(crate) fn requires_confirmation(&self, name: &str, args: &serde_json::Value) -> bool {
319        self.get(name)
320            .is_some_and(|tool| tool.requires_confirmation(args))
321    }
322
323    /// Check if a tool exists
324    pub fn contains(&self, name: &str) -> bool {
325        self.get(name).is_some()
326    }
327
328    /// Get all tool definitions for LLM
329    pub fn definitions(&self) -> Vec<ToolDefinition> {
330        let tools = self.tools.read().unwrap();
331        let mut definitions = tools
332            .values()
333            .filter(|tool| tool.is_model_visible())
334            .map(|tool| tool.definition())
335            .collect::<Vec<_>>();
336        definitions.sort_by(|a, b| a.name.cmp(&b.name));
337        definitions
338    }
339
340    /// List all registered tool names
341    pub fn list(&self) -> Vec<String> {
342        let tools = self.tools.read().unwrap();
343        let mut names = tools.keys().cloned().collect::<Vec<_>>();
344        names.sort();
345        names
346    }
347
348    /// Validate model- or orchestrator-supplied arguments against the tool's
349    /// declared JSON Schema before permissions or execution side effects.
350    ///
351    /// Low-level standalone registry calls remain compatibility-oriented and
352    /// do not invoke this automatically. The governed agent/session gateway is
353    /// the enforcement boundary.
354    pub(crate) fn validate_arguments(
355        &self,
356        name: &str,
357        args: &serde_json::Value,
358    ) -> std::result::Result<(), String> {
359        let Some(tool) = self.get(name) else {
360            return Ok(());
361        };
362        let schema = tool.parameters();
363        let schema_bytes = serde_json::to_vec(&schema)
364            .map_err(|error| format!("tool parameter schema is not serializable: {error}"))?;
365        if schema_bytes.len() > MAX_TOOL_SCHEMA_BYTES {
366            return Err(format!(
367                "tool parameter schema exceeds the {} byte safety limit",
368                MAX_TOOL_SCHEMA_BYTES
369            ));
370        }
371        let mut hasher = std::collections::hash_map::DefaultHasher::new();
372        schema_bytes.hash(&mut hasher);
373        let schema_fingerprint = hasher.finish();
374        let cached = self
375            .argument_validators
376            .read()
377            .unwrap()
378            .get(name)
379            .filter(|entry| entry.schema_fingerprint == schema_fingerprint)
380            .cloned();
381        let validator = match cached.map(|entry| entry.validator) {
382            Some(CachedArgumentValidator::Valid(validator)) => validator,
383            Some(CachedArgumentValidator::Invalid(error)) => return Err(error),
384            None => {
385                let compiled = match jsonschema::draft202012::options().build(&schema) {
386                    Ok(validator) => CachedArgumentValidator::Valid(Arc::new(validator)),
387                    Err(error) => CachedArgumentValidator::Invalid(format!(
388                        "tool has an invalid parameter schema: {error}"
389                    )),
390                };
391                self.argument_validators.write().unwrap().insert(
392                    name.to_string(),
393                    ArgumentValidatorCacheEntry {
394                        schema_fingerprint,
395                        validator: compiled.clone(),
396                    },
397                );
398                match compiled {
399                    CachedArgumentValidator::Valid(validator) => validator,
400                    CachedArgumentValidator::Invalid(error) => return Err(error),
401                }
402            }
403        };
404        let mut errors = validator
405            .iter_errors(args)
406            .take(MAX_ARGUMENT_VALIDATION_ERRORS + 1)
407            .map(|error| {
408                let path = error.instance_path().to_string();
409                if path.is_empty() {
410                    format!("$: {error}")
411                } else {
412                    format!("{path}: {error}")
413                }
414            })
415            .collect::<Vec<_>>();
416        if errors.is_empty() {
417            return Ok(());
418        }
419
420        let omitted = errors.len() > MAX_ARGUMENT_VALIDATION_ERRORS;
421        errors.truncate(MAX_ARGUMENT_VALIDATION_ERRORS);
422        let mut message = errors.join("; ");
423        if omitted {
424            message.push_str("; additional validation errors omitted");
425        }
426        Err(crate::text::truncate_utf8(&message, MAX_ARGUMENT_VALIDATION_MESSAGE_BYTES).to_string())
427    }
428
429    /// Get the number of registered tools
430    pub fn len(&self) -> usize {
431        let tools = self.tools.read().unwrap();
432        tools.len()
433    }
434
435    /// Check if registry is empty
436    pub fn is_empty(&self) -> bool {
437        self.len() == 0
438    }
439
440    /// Get the tool context
441    pub fn context(&self) -> ToolContext {
442        self.context.read().unwrap().clone()
443    }
444
445    /// Return a clone of the registry's artifact store handle.
446    pub fn artifact_store(&self) -> ArtifactStore {
447        self.artifact_store.clone()
448    }
449
450    /// Return the session-scoped immutable-content adapter, when configured.
451    pub fn immutable_content_adapter(&self) -> Option<&ImmutableContentAdapterSession> {
452        self.immutable_content_adapter.as_ref()
453    }
454
455    /// Get a stored tool artifact by URI.
456    pub fn get_artifact(&self, artifact_uri: &str) -> Option<ToolArtifact> {
457        self.artifact_store.get(artifact_uri)
458    }
459
460    /// Replace the trace sink used for compact tool/program execution events.
461    pub fn set_trace_sink(&self, sink: Arc<dyn TraceSink>) {
462        *self.trace_sink.write().unwrap() = sink;
463    }
464
465    /// Return the current trace sink.
466    pub fn trace_sink(&self) -> Arc<dyn TraceSink> {
467        Arc::clone(&self.trace_sink.read().unwrap())
468    }
469
470    /// Set the search configuration for the tool context
471    pub fn set_search_config(&self, config: crate::config::SearchConfig) {
472        let mut ctx = self.context.write().unwrap();
473        *ctx = ctx.clone().with_search_config(config);
474    }
475
476    /// Set a sandbox executor so that `bash` tool calls use the sandbox even
477    /// when executed without an explicit `ToolContext` (i.e., via `execute()`).
478    pub fn set_sandbox(&self, sandbox: std::sync::Arc<dyn crate::sandbox::BashSandbox>) {
479        let mut ctx = self.context.write().unwrap();
480        *ctx = ctx.clone().with_sandbox(sandbox);
481    }
482
483    /// Set environment overrides used by subprocess-backed tools when executed
484    /// without an explicit context.
485    pub fn set_command_env(&self, env: Arc<HashMap<String, String>>) {
486        let mut ctx = self.context.write().unwrap();
487        *ctx = ctx.clone().with_command_env(env);
488    }
489
490    /// Execute a tool by name using the registry's default context.
491    ///
492    /// This is the lowest-level standalone registry boundary. It does not run
493    /// agent/session permission, HITL, hook, budget, queue, timeout,
494    /// cancellation, or sanitization policy.
495    pub async fn execute(&self, name: &str, args: &serde_json::Value) -> Result<ToolResult> {
496        let ctx = self.context();
497        self.execute_with_context(name, args, &ctx).await
498    }
499
500    /// Execute a tool by name with an external caller-owned context.
501    ///
502    /// This remains a low-level ungoverned call; agent/session paths must use
503    /// their scoped invocation gateway instead.
504    pub async fn execute_with_context(
505        &self,
506        name: &str,
507        args: &serde_json::Value,
508        ctx: &ToolContext,
509    ) -> Result<ToolResult> {
510        let start = std::time::Instant::now();
511        let prepared = self.prepare_output_with_context(name, args, ctx).await?;
512        let transform_binding = prepared.transform_binding;
513        let mut result = match prepared.output {
514            Some(output) => Ok(ToolResult {
515                name: name.to_string(),
516                output: output.content,
517                exit_code: if output.success { 0 } else { 1 },
518                metadata: output.metadata,
519                images: output.images,
520                error_kind: output.error_kind,
521                trust: crate::tools::ToolResultTrustV1::WorkspaceData,
522            }),
523            None => Ok(ToolResult::error(name, format!("Unknown tool: {name}"))),
524        };
525
526        if let Ok(result) = &mut result {
527            result.metadata = Some(super::ensure_tool_result_evidence_with_transform_binding(
528                result.metadata.take(),
529                &result.output,
530                &transform_binding,
531            )?);
532        }
533
534        if let Ok(ref r) = result {
535            crate::telemetry::record_tool_result(r.exit_code, start.elapsed());
536            self.record_trace_event(name, r, start.elapsed());
537        }
538
539        result
540    }
541
542    /// Execute a tool and return the `ToolOutput` projection using the registry's
543    /// default context. The same observation pipeline as `execute` is applied.
544    pub async fn execute_raw(
545        &self,
546        name: &str,
547        args: &serde_json::Value,
548    ) -> Result<Option<ToolOutput>> {
549        let ctx = self.context();
550        self.execute_raw_with_context(name, args, &ctx).await
551    }
552
553    /// Execute a tool and return the `ToolOutput` projection with an external
554    /// context. The same observation pipeline as `execute_with_context` is applied.
555    pub async fn execute_raw_with_context(
556        &self,
557        name: &str,
558        args: &serde_json::Value,
559        ctx: &ToolContext,
560    ) -> Result<Option<ToolOutput>> {
561        Ok(self
562            .prepare_output_with_context(name, args, ctx)
563            .await?
564            .output)
565    }
566
567    async fn prepare_output_with_context(
568        &self,
569        name: &str,
570        args: &serde_json::Value,
571        ctx: &ToolContext,
572    ) -> Result<PreparedToolOutput> {
573        let policy = self.transform_policy.read().unwrap().clone();
574        let transform_binding = ToolResultTransformBindingV1::from_policy(&policy)?;
575        let Some(tool) = self.get(name) else {
576            return Ok(PreparedToolOutput {
577                output: None,
578                transform_binding,
579            });
580        };
581
582        let mut output = tool.execute(args, ctx).await?;
583        self.compact_change_metadata(name, &mut output.metadata, ctx)
584            .await?;
585        let original_content = output.content.clone();
586        let truncated = transform_tool_output_with_artifact(name, &output.content, &policy);
587        output.content = truncated.content;
588        let loss_mode = truncated.loss_mode;
589        let projected_artifact_reference = truncated.artifact.is_some();
590        let artifact = truncated.artifact.or_else(|| {
591            self.immutable_content_adapter
592                .as_ref()
593                .map(|_| super::tool_output_artifact(name, &original_content, output.content.len()))
594        });
595        if let Some(mut artifact) = artifact {
596            let compatibility_uri = artifact.artifact_uri.clone();
597            self.store_tool_artifact(
598                name,
599                &original_content,
600                &mut artifact,
601                ImmutableContentKindV1::ToolResultOriginal,
602                ctx,
603            )
604            .await?;
605            if projected_artifact_reference {
606                rewrite_projected_artifact_uri(
607                    &mut output.content,
608                    &compatibility_uri,
609                    &artifact.artifact_uri,
610                )?;
611            }
612            output.metadata = Some(merge_tool_output_artifact_metadata(
613                output.metadata,
614                &artifact,
615            ));
616        }
617        output.metadata = Some(super::attach_tool_result_evidence_with_transform_binding(
618            output.metadata,
619            &original_content,
620            &output.content,
621            loss_mode,
622            &transform_binding,
623        )?);
624        Ok(PreparedToolOutput {
625            output: Some(output),
626            transform_binding,
627        })
628    }
629
630    async fn store_tool_artifact(
631        &self,
632        tool_name: &str,
633        content: &str,
634        artifact: &mut ToolOutputArtifact,
635        kind: ImmutableContentKindV1,
636        ctx: &ToolContext,
637    ) -> Result<()> {
638        if let Some(adapter) = &self.immutable_content_adapter {
639            let cancellation = ctx.cancellation_token();
640            let retained = tokio::select! {
641                biased;
642                _ = cancellation.cancelled() => {
643                    return Err(ImmutableContentError::Cancelled.into());
644                }
645                result = adapter.put(kind, TOOL_RESULT_CONTENT_MEDIA_TYPE, content.as_bytes()) => {
646                    result.map_err(|error| anyhow::anyhow!(
647                        "immutable content adapter '{}' rejected Tool content: {}",
648                        adapter.adapter_name(),
649                        error.redacted_message(),
650                    ))?
651                }
652            };
653            artifact.artifact_uri.clone_from(&retained.uri);
654            artifact.content_reference = Some(retained);
655            return Ok(());
656        }
657
658        self.artifact_store
659            .put_content_addressed(ToolArtifact {
660                artifact_id: artifact.artifact_id.clone(),
661                artifact_uri: artifact.artifact_uri.clone(),
662                tool_name: tool_name.to_string(),
663                content: content.to_string(),
664                original_bytes: artifact.original_bytes,
665                shown_bytes: artifact.shown_bytes,
666            })
667            .map_err(|error| anyhow::anyhow!("immutable Tool artifact conflict: {error}"))?;
668        Ok(())
669    }
670
671    async fn compact_change_metadata(
672        &self,
673        tool_name: &str,
674        metadata: &mut Option<serde_json::Value>,
675        ctx: &ToolContext,
676    ) -> Result<()> {
677        let Some(serde_json::Value::Object(object)) = metadata.as_mut() else {
678            return Ok(());
679        };
680        let before = object
681            .get("before")
682            .and_then(serde_json::Value::as_str)
683            .map(ToString::to_string);
684        let after = object
685            .get("after")
686            .and_then(serde_json::Value::as_str)
687            .map(ToString::to_string);
688        if before.is_none() && after.is_none() {
689            return Ok(());
690        }
691
692        let before_bytes = before.as_ref().map_or(0, String::len);
693        let after_bytes = after.as_ref().map_or(0, String::len);
694        let total_bytes = before_bytes.saturating_add(after_bytes);
695        let compacted = total_bytes > MAX_INLINE_CHANGE_BYTES;
696        let before_artifact = match before.as_deref() {
697            Some(content) => {
698                self.store_change_artifact(
699                    tool_name,
700                    "before",
701                    content,
702                    compacted,
703                    ImmutableContentKindV1::ToolChangeBefore,
704                    ctx,
705                )
706                .await?
707            }
708            None => None,
709        };
710        let after_artifact = match after.as_deref() {
711            Some(content) => {
712                self.store_change_artifact(
713                    tool_name,
714                    "after",
715                    content,
716                    compacted,
717                    ImmutableContentKindV1::ToolChangeAfter,
718                    ctx,
719                )
720                .await?
721            }
722            None => None,
723        };
724
725        let unified_diff = if compacted && total_bytes <= MAX_DIFF_COMPUTE_BYTES {
726            let diff = similar::TextDiff::from_lines(
727                before.as_deref().unwrap_or_default(),
728                after.as_deref().unwrap_or_default(),
729            )
730            .unified_diff()
731            .context_radius(3)
732            .header("before", "after")
733            .to_string();
734            Some(bounded_head_tail(&diff, CHANGE_DIFF_PREVIEW_BYTES))
735        } else {
736            None
737        };
738
739        if compacted {
740            if let Some(content) = before.as_deref() {
741                object.insert(
742                    "before".to_string(),
743                    serde_json::Value::String(bounded_head_tail(
744                        content,
745                        CHANGE_SIDE_PREVIEW_BYTES,
746                    )),
747                );
748            }
749            if let Some(content) = after.as_deref() {
750                object.insert(
751                    "after".to_string(),
752                    serde_json::Value::String(bounded_head_tail(
753                        content,
754                        CHANGE_SIDE_PREVIEW_BYTES,
755                    )),
756                );
757            }
758        }
759
760        object.insert(
761            "change".to_string(),
762            serde_json::json!({
763                "compacted": compacted,
764                "before": before.as_deref().map(|content| serde_json::json!({
765                    "bytes": content.len(),
766                    "sha256": sha256::digest(content.as_bytes()),
767                    "artifact": before_artifact,
768                })),
769                "after": after.as_deref().map(|content| serde_json::json!({
770                    "bytes": content.len(),
771                    "sha256": sha256::digest(content.as_bytes()),
772                    "artifact": after_artifact,
773                })),
774                "unified_diff": unified_diff,
775                "diff_omitted": compacted && total_bytes > MAX_DIFF_COMPUTE_BYTES,
776            }),
777        );
778        Ok(())
779    }
780
781    async fn store_change_artifact(
782        &self,
783        tool_name: &str,
784        side: &str,
785        content: &str,
786        store: bool,
787        kind: ImmutableContentKindV1,
788        ctx: &ToolContext,
789    ) -> Result<Option<serde_json::Value>> {
790        if !store
791            || self.immutable_content_adapter.is_none()
792                && content.len() > self.artifact_store.limits().max_bytes
793        {
794            return Ok(None);
795        }
796        let mut artifact = tool_output_artifact(&format!("{tool_name}-{side}"), content, 0);
797        self.store_tool_artifact(tool_name, content, &mut artifact, kind, ctx)
798            .await?;
799        let mut metadata = serde_json::json!({
800            "artifact_id": artifact.artifact_id,
801            "artifact_uri": artifact.artifact_uri,
802        });
803        if let Some(reference) = artifact.content_reference {
804            metadata["content_reference"] = serde_json::json!(reference);
805        }
806        Ok(Some(metadata))
807    }
808
809    fn record_trace_event(&self, name: &str, result: &ToolResult, duration: std::time::Duration) {
810        let sink = self.trace_sink();
811        sink.record(TraceEvent::tool_execution(
812            name,
813            result.exit_code == 0,
814            result.exit_code,
815            duration,
816            result.output.len(),
817            result.metadata.as_ref(),
818        ));
819
820        if name == "program" {
821            sink.record(TraceEvent::program_execution(
822                name,
823                result.exit_code == 0,
824                result.exit_code,
825                duration,
826                result.output.len(),
827                result.metadata.as_ref(),
828            ));
829        }
830    }
831}
832
833fn rewrite_projected_artifact_uri(
834    projected: &mut String,
835    compatibility_uri: &str,
836    retained_uri: &str,
837) -> Result<()> {
838    if compatibility_uri == retained_uri {
839        return Ok(());
840    }
841    let start = projected.rfind(compatibility_uri).ok_or_else(|| {
842        anyhow::anyhow!(
843            "Tool result projection lost its compatibility artifact reference before retention"
844        )
845    })?;
846    let end = start + compatibility_uri.len();
847    projected.replace_range(start..end, retained_uri);
848    Ok(())
849}
850
851fn bounded_head_tail(content: &str, max_bytes: usize) -> String {
852    if content.len() <= max_bytes {
853        return content.to_string();
854    }
855    let head_limit = max_bytes / 2;
856    let tail_limit = max_bytes.saturating_sub(head_limit);
857    let head = crate::text::truncate_utf8(content, head_limit);
858    let mut tail_start = content.len().saturating_sub(tail_limit);
859    while tail_start < content.len() && !content.is_char_boundary(tail_start) {
860        tail_start += 1;
861    }
862    format!(
863        "{}\n\n... [{} bytes omitted from middle] ...\n\n{}",
864        head,
865        content
866            .len()
867            .saturating_sub(head.len())
868            .saturating_sub(content.len().saturating_sub(tail_start)),
869        &content[tail_start..]
870    )
871}
872
873#[cfg(test)]
874#[path = "registry/tests.rs"]
875mod tests;