Skip to main content

a3s_code_core/tools/
mod.rs

1//! Extensible Tool System
2//!
3//! Provides a trait-based abstraction for tools.
4//!
5//! ## Architecture
6//!
7//! ```text
8//! ToolRegistry
9//!   └── builtin tools (file, search, execution, web, and Code Intelligence queries)
10//! ```
11
12mod agent_dir_script_tool;
13mod artifacts;
14pub(crate) mod builtin;
15mod immutable_content;
16mod invocation;
17mod pagination;
18mod presentation;
19pub(crate) mod process;
20mod program_tool;
21mod registry;
22mod result_transform;
23mod selector;
24pub mod skill;
25pub mod task;
26mod types;
27
28pub use crate::dynamic_workflow::register_dynamic_workflow;
29pub use agent_dir_script_tool::AgentDirScriptTool;
30pub use artifacts::{ArtifactStore, ArtifactStoreLimits, ToolArtifact};
31pub use builtin::{
32    register_generate_object, register_program, register_program_with_catalog, register_task,
33    register_task_with_mcp, register_task_with_mcp_managers,
34};
35pub(crate) use builtin::{
36    register_skill, register_task_with_mcp_managers_and_scheduler,
37    register_task_with_mcp_sources_and_scheduler,
38};
39pub use immutable_content::{
40    ImmutableContentAdapter, ImmutableContentAdapterBindingV1, ImmutableContentAdapterSession,
41    ImmutableContentDescriptorV1, ImmutableContentError, ImmutableContentKindV1,
42    ImmutableContentReferenceV1, ImmutableContentResult, ImmutableContentWriteRequestV1,
43    IMMUTABLE_CONTENT_ADAPTER_BINDING_SCHEMA_V1, IMMUTABLE_CONTENT_DESCRIPTOR_SCHEMA_V1,
44    IMMUTABLE_CONTENT_REFERENCE_SCHEMA_V1, TOOL_RESULT_CONTENT_MEDIA_TYPE,
45};
46pub(crate) use invocation::{
47    registry_bound_tool_invoker, registry_tool_invoker, HostDirectPolicy, InvocationOrigin,
48    ToolInvocation, ToolInvoker,
49};
50pub(crate) use presentation::{
51    canonical_source as canonical_presentation_source, estimated_definition_tokens,
52    is_definition_subset,
53};
54pub use presentation::{
55    ToolPresentationError, ToolPresentationModeV1, ToolPresentationProfileV1,
56    TOOL_PRESENTATION_PROFILE_V1_SCHEMA,
57};
58pub use program_tool::{ProgramTool, MAX_PROGRAM_SCRIPT_SOURCE_BYTES};
59pub use registry::ToolRegistry;
60pub(crate) use registry::ToolRegistrySnapshotError;
61pub use result_transform::{
62    ToolResultTransformBindingV1, ToolResultTransformPolicyV1, TOOL_RESULT_TRANSFORM_ALGORITHM_V1,
63    TOOL_RESULT_TRANSFORM_BINDING_METADATA_KEY, TOOL_RESULT_TRANSFORM_BINDING_SCHEMA_V1,
64    TOOL_RESULT_TRANSFORM_POLICY_DIGEST_DOMAIN_V1, TOOL_RESULT_TRANSFORM_SCHEMA_V1,
65};
66pub(crate) use selector::is_standalone_conversation;
67pub use selector::{select_tools_for_messages, select_tools_for_prompt};
68pub use task::{
69    parallel_task_params_schema, task_params_schema, ParallelTaskParams, ParallelTaskTool,
70    TaskExecutor, TaskParams, TaskResult, TaskTool,
71};
72pub(crate) use types::{AgentEventBarrier, AgentEventBarrierReceiver};
73pub use types::{
74    InvocationRuntime, Tool, ToolCapabilities, ToolContext, ToolErrorKind, ToolEventSender,
75    ToolOutput, ToolOutputKind, ToolStreamEvent,
76};
77
78use crate::llm::ToolDefinition;
79use anyhow::Result;
80use serde::{Deserialize, Serialize};
81use sha2::{Digest, Sha256};
82use std::collections::HashMap;
83use std::path::PathBuf;
84use std::sync::Arc;
85
86/// Maximum output size in bytes before truncation
87pub const MAX_OUTPUT_SIZE: usize = 100 * 1024; // 100KB
88
89/// Maximum lines to read from a file
90pub const MAX_READ_LINES: usize = 2000;
91
92/// Maximum line length before truncation
93pub const MAX_LINE_LENGTH: usize = 2000;
94
95#[derive(Debug, Clone, PartialEq, Eq)]
96pub(crate) struct ToolOutputArtifact {
97    pub artifact_id: String,
98    pub artifact_uri: String,
99    pub original_bytes: usize,
100    pub shown_bytes: usize,
101    pub content_reference: Option<ImmutableContentReferenceV1>,
102}
103
104#[derive(Debug, Clone)]
105pub(crate) struct TruncatedToolOutput {
106    pub content: String,
107    pub artifact: Option<ToolOutputArtifact>,
108    pub loss_mode: ToolResultLossModeV1,
109}
110
111#[cfg(test)]
112pub(crate) fn truncate_tool_output_with_artifact(
113    tool_name: &str,
114    output: &str,
115) -> TruncatedToolOutput {
116    transform_tool_output_with_artifact(
117        tool_name,
118        output,
119        &ToolResultTransformPolicyV1::conservative(),
120    )
121}
122
123pub(crate) fn transform_tool_output_with_artifact(
124    tool_name: &str,
125    output: &str,
126    policy: &ToolResultTransformPolicyV1,
127) -> TruncatedToolOutput {
128    let transformed = result_transform::transform(output, policy);
129    if transformed.loss_mode == ToolResultLossModeV1::None {
130        return TruncatedToolOutput {
131            content: transformed.content,
132            artifact: None,
133            loss_mode: transformed.loss_mode,
134        };
135    }
136    let artifact = tool_output_artifact(tool_name, output, transformed.retained_original_bytes);
137    let artifact_uri = artifact.artifact_uri.clone();
138    let content = format!(
139        "{}\n\n[Full output artifact: {artifact_uri}]",
140        transformed.content
141    );
142
143    TruncatedToolOutput {
144        content,
145        artifact: Some(artifact),
146        loss_mode: transformed.loss_mode,
147    }
148}
149
150pub(crate) fn tool_output_artifact(
151    tool_name: &str,
152    output: &str,
153    shown_bytes: usize,
154) -> ToolOutputArtifact {
155    let digest = format!("{:x}", Sha256::digest(output.as_bytes()));
156    let sanitized_tool = tool_name
157        .chars()
158        .map(|ch| {
159            if ch.is_ascii_alphanumeric() || ch == '_' || ch == '-' {
160                ch
161            } else {
162                '_'
163            }
164        })
165        .collect::<String>();
166    let artifact_id = format!("tool-output:{sanitized_tool}:{digest}");
167    let artifact_uri = format!("a3s://tool-output/{sanitized_tool}/{digest}");
168
169    ToolOutputArtifact {
170        artifact_id,
171        artifact_uri,
172        original_bytes: output.len(),
173        shown_bytes,
174        content_reference: None,
175    }
176}
177
178pub(crate) fn merge_tool_output_artifact_metadata(
179    metadata: Option<serde_json::Value>,
180    artifact: &ToolOutputArtifact,
181) -> serde_json::Value {
182    let mut artifact_json = serde_json::json!({
183        "artifact_id": artifact.artifact_id,
184        "artifact_uri": artifact.artifact_uri,
185        "original_bytes": artifact.original_bytes,
186        "shown_bytes": artifact.shown_bytes,
187    });
188    if let Some(reference) = &artifact.content_reference {
189        artifact_json["content_reference"] = serde_json::json!(reference);
190    }
191
192    match metadata {
193        Some(serde_json::Value::Object(mut object)) => {
194            object.insert("artifact".to_string(), artifact_json);
195            serde_json::Value::Object(object)
196        }
197        Some(value) => serde_json::json!({
198            "artifact": artifact_json,
199            "previous_metadata": value,
200        }),
201        None => serde_json::json!({
202            "artifact": artifact_json,
203        }),
204    }
205}
206
207pub const TOOL_RESULT_EVIDENCE_SCHEMA_V1: &str = "a3s.code.tool-result-evidence.v1";
208pub const TOOL_RESULT_TOKEN_ESTIMATOR_V1: &str = "utf8-bytes-ceil-div-4/v1";
209
210#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
211#[serde(deny_unknown_fields)]
212pub struct ToolResultEvidenceV1 {
213    pub schema: String,
214    pub original_bytes: usize,
215    pub projected_bytes: usize,
216    pub original_estimated_tokens: usize,
217    pub projected_estimated_tokens: usize,
218    pub token_estimator: String,
219    /// Versioned transform algorithm. `None` is accepted only when reading
220    /// evidence emitted before CAR-03 extended the unreleased v1 schema.
221    #[serde(default, skip_serializing_if = "Option::is_none")]
222    pub transform_algorithm: Option<String>,
223    pub content_digest: String,
224    #[serde(default, skip_serializing_if = "Option::is_none")]
225    pub projected_digest: Option<String>,
226    pub repeat_key: String,
227    pub content_ref: String,
228    pub loss_mode: ToolResultLossModeV1,
229    #[serde(default, skip_serializing_if = "Option::is_none")]
230    pub byte_delta: Option<i64>,
231    #[serde(default, skip_serializing_if = "Option::is_none")]
232    pub estimated_token_delta: Option<i64>,
233}
234
235#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
236#[serde(rename_all = "snake_case")]
237pub enum ToolResultLossModeV1 {
238    None,
239    BoundedPreview,
240    HeadTail,
241    DeterministicTransform,
242    Composite,
243}
244
245pub(crate) fn attach_tool_result_evidence(
246    metadata: Option<serde_json::Value>,
247    original: &str,
248    projected: &str,
249    loss_mode: ToolResultLossModeV1,
250) -> serde_json::Value {
251    let digest = format!("sha256:{:x}", Sha256::digest(original.as_bytes()));
252    let projected_digest = format!("sha256:{:x}", Sha256::digest(projected.as_bytes()));
253    let artifact_uri = metadata
254        .as_ref()
255        .and_then(|value| value.pointer("/artifact/artifact_uri"))
256        .and_then(serde_json::Value::as_str);
257    let evidence = ToolResultEvidenceV1 {
258        schema: TOOL_RESULT_EVIDENCE_SCHEMA_V1.to_string(),
259        original_bytes: original.len(),
260        projected_bytes: projected.len(),
261        original_estimated_tokens: estimated_text_tokens(original),
262        projected_estimated_tokens: estimated_text_tokens(projected),
263        token_estimator: TOOL_RESULT_TOKEN_ESTIMATOR_V1.to_string(),
264        transform_algorithm: Some(TOOL_RESULT_TRANSFORM_ALGORITHM_V1.to_string()),
265        content_digest: digest.clone(),
266        projected_digest: Some(projected_digest),
267        repeat_key: digest.clone(),
268        content_ref: artifact_uri
269            .map(str::to_owned)
270            .unwrap_or_else(|| format!("inline:{digest}")),
271        loss_mode,
272        byte_delta: Some(signed_delta(projected.len(), original.len())),
273        estimated_token_delta: Some(signed_delta(
274            estimated_text_tokens(projected),
275            estimated_text_tokens(original),
276        )),
277    };
278    let evidence = serde_json::json!({
279        "schema": evidence.schema,
280        "original_bytes": evidence.original_bytes,
281        "projected_bytes": evidence.projected_bytes,
282        "original_estimated_tokens": evidence.original_estimated_tokens,
283        "projected_estimated_tokens": evidence.projected_estimated_tokens,
284        "token_estimator": evidence.token_estimator,
285        "transform_algorithm": evidence.transform_algorithm,
286        "content_digest": evidence.content_digest,
287        "projected_digest": evidence.projected_digest,
288        "repeat_key": evidence.repeat_key,
289        "content_ref": evidence.content_ref,
290        "loss_mode": evidence.loss_mode,
291        "byte_delta": evidence.byte_delta,
292        "estimated_token_delta": evidence.estimated_token_delta,
293    });
294    match metadata {
295        Some(serde_json::Value::Object(mut object)) => {
296            object.insert("a3s_tool_result_evidence".to_string(), evidence);
297            serde_json::Value::Object(object)
298        }
299        Some(value) => serde_json::json!({
300            "a3s_tool_result_evidence": evidence,
301            "previous_metadata": value,
302        }),
303        None => serde_json::json!({"a3s_tool_result_evidence": evidence}),
304    }
305}
306
307pub(crate) fn attach_tool_result_evidence_with_transform_binding(
308    metadata: Option<serde_json::Value>,
309    original: &str,
310    projected: &str,
311    loss_mode: ToolResultLossModeV1,
312    transform_binding: &ToolResultTransformBindingV1,
313) -> Result<serde_json::Value> {
314    transform_binding.validate()?;
315    let mut metadata = attach_tool_result_evidence(metadata, original, projected, loss_mode);
316    let encoded_binding = serde_json::to_value(transform_binding)?;
317    let serde_json::Value::Object(object) = &mut metadata else {
318        anyhow::bail!("Tool result evidence metadata must be an object");
319    };
320    object.insert(
321        TOOL_RESULT_TRANSFORM_BINDING_METADATA_KEY.to_string(),
322        encoded_binding,
323    );
324    Ok(metadata)
325}
326
327pub(crate) fn ensure_tool_result_evidence(
328    metadata: Option<serde_json::Value>,
329    output: &str,
330) -> serde_json::Value {
331    match metadata {
332        Some(value) if value.get("a3s_tool_result_evidence").is_some() => value,
333        metadata => {
334            attach_tool_result_evidence(metadata, output, output, ToolResultLossModeV1::None)
335        }
336    }
337}
338
339pub(crate) fn ensure_tool_result_evidence_with_transform_binding(
340    metadata: Option<serde_json::Value>,
341    output: &str,
342    transform_binding: &ToolResultTransformBindingV1,
343) -> Result<serde_json::Value> {
344    if let Some(value) = metadata {
345        if value.get("a3s_tool_result_evidence").is_some() {
346            if let Some(encoded_binding) = value.get(TOOL_RESULT_TRANSFORM_BINDING_METADATA_KEY) {
347                let retained: ToolResultTransformBindingV1 =
348                    serde_json::from_value(encoded_binding.clone())?;
349                retained.validate()?;
350                anyhow::ensure!(
351                    &retained == transform_binding,
352                    "Tool result transform binding drifted before result release"
353                );
354                return Ok(value);
355            }
356        }
357        return attach_tool_result_evidence_with_transform_binding(
358            Some(value),
359            output,
360            output,
361            ToolResultLossModeV1::None,
362            transform_binding,
363        );
364    }
365
366    attach_tool_result_evidence_with_transform_binding(
367        None,
368        output,
369        output,
370        ToolResultLossModeV1::None,
371        transform_binding,
372    )
373}
374
375pub(crate) fn has_tool_metadata_beyond_evidence(metadata: Option<&serde_json::Value>) -> bool {
376    match metadata {
377        None => false,
378        Some(serde_json::Value::Object(object)) => object.keys().any(|key| {
379            key != "a3s_tool_result_evidence" && key != TOOL_RESULT_TRANSFORM_BINDING_METADATA_KEY
380        }),
381        Some(_) => true,
382    }
383}
384
385fn estimated_text_tokens(value: &str) -> usize {
386    value.len().saturating_add(3) / 4
387}
388
389fn signed_delta(value: usize, baseline: usize) -> i64 {
390    if value >= baseline {
391        i64::try_from(value - baseline).unwrap_or(i64::MAX)
392    } else {
393        -i64::try_from(baseline - value).unwrap_or(i64::MAX)
394    }
395}
396
397/// Tool execution result returned by direct tool execution.
398#[derive(Debug, Clone, Serialize, Deserialize)]
399pub struct ToolResult {
400    pub name: String,
401    pub output: String,
402    pub exit_code: i32,
403    #[serde(skip_serializing_if = "Option::is_none")]
404    pub metadata: Option<serde_json::Value>,
405    /// Image attachments from tool execution (multi-modal output).
406    #[serde(skip)]
407    pub images: Vec<crate::llm::Attachment>,
408    /// Structured discriminant for tool failures. Populated by built-in
409    /// tools that can map their failure into a typed [`ToolErrorKind`]
410    /// (e.g. `edit`/`patch` setting `VersionConflict` on a CAS rejection
411    /// from `WorkspaceError`). Forwarded to the SDK so callers can react
412    /// programmatically without parsing `output`.
413    #[serde(skip_serializing_if = "Option::is_none")]
414    pub error_kind: Option<types::ToolErrorKind>,
415}
416
417impl ToolResult {
418    pub fn success(name: &str, output: String) -> Self {
419        Self {
420            name: name.to_string(),
421            output,
422            exit_code: 0,
423            metadata: None,
424            images: Vec::new(),
425            error_kind: None,
426        }
427    }
428
429    pub fn error(name: &str, message: String) -> Self {
430        Self {
431            name: name.to_string(),
432            output: message,
433            exit_code: 1,
434            metadata: None,
435            images: Vec::new(),
436            error_kind: None,
437        }
438    }
439
440    pub fn error_with_kind(name: &str, message: String, kind: types::ToolErrorKind) -> Self {
441        let mut result = Self::error(name, message);
442        result.error_kind = Some(kind);
443        result
444    }
445}
446
447impl From<ToolOutput> for ToolResult {
448    fn from(output: ToolOutput) -> Self {
449        Self {
450            name: String::new(),
451            output: output.content,
452            exit_code: if output.success { 0 } else { 1 },
453            metadata: output.metadata,
454            images: output.images,
455            error_kind: output.error_kind,
456        }
457    }
458}
459
460/// Tool executor with workspace sandboxing
461///
462/// This is the main entry point for tool execution. It wraps the ToolRegistry.
463pub struct ToolExecutor {
464    workspace: PathBuf,
465    registry: Arc<ToolRegistry>,
466    command_env: Option<Arc<HashMap<String, String>>>,
467}
468
469/// Build a log line for a tool invocation that excludes argument *values*.
470///
471/// Argument values (full bash commands, file contents written by `write`/`edit`)
472/// can contain secrets, so the summary records only the tool name, the sorted
473/// argument field names, and the serialized payload size — never the values. This
474/// keeps the always-on `info!` tool trace (also exported to OTLP) compliant with
475/// the "never log secrets" boundary. Full argument values are intentionally
476/// absent at every log level because search queries and file content may carry
477/// workspace-sensitive material.
478fn redacted_tool_log_summary(name: &str, args: &serde_json::Value) -> String {
479    let arg_keys: Vec<&str> = match args.as_object() {
480        Some(map) => {
481            let mut keys: Vec<&str> = map.keys().map(String::as_str).collect();
482            keys.sort_unstable();
483            keys
484        }
485        None => Vec::new(),
486    };
487    format!(
488        "Executing tool: {} (arg_keys={:?}, {} bytes)",
489        name,
490        arg_keys,
491        args.to_string().len()
492    )
493}
494
495/// Log a tool invocation without leaking argument values. See
496/// [`redacted_tool_log_summary`] for the redaction rationale.
497fn log_tool_invocation(name: &str, args: &serde_json::Value) {
498    tracing::info!("{}", redacted_tool_log_summary(name, args));
499}
500
501impl ToolExecutor {
502    pub fn new(workspace: String) -> Self {
503        let workspace_services =
504            crate::workspace::WorkspaceServices::local(PathBuf::from(&workspace));
505        Self::build(
506            workspace,
507            None,
508            ArtifactStoreLimits::default(),
509            workspace_services,
510            None,
511        )
512    }
513
514    pub fn new_with_artifact_limits(
515        workspace: String,
516        artifact_limits: ArtifactStoreLimits,
517    ) -> Self {
518        let workspace_services =
519            crate::workspace::WorkspaceServices::local(PathBuf::from(&workspace));
520        Self::build(workspace, None, artifact_limits, workspace_services, None)
521    }
522
523    pub fn new_with_workspace_services(
524        workspace: String,
525        workspace_services: Arc<crate::workspace::WorkspaceServices>,
526    ) -> Self {
527        Self::build(
528            workspace,
529            None,
530            ArtifactStoreLimits::default(),
531            workspace_services,
532            None,
533        )
534    }
535
536    pub fn new_with_workspace_services_and_artifact_limits(
537        workspace: String,
538        workspace_services: Arc<crate::workspace::WorkspaceServices>,
539        artifact_limits: ArtifactStoreLimits,
540    ) -> Self {
541        Self::build(workspace, None, artifact_limits, workspace_services, None)
542    }
543
544    /// Create a local low-level executor that retains every raw Tool result
545    /// through a host-authorized immutable-content adapter.
546    pub fn new_with_immutable_content_adapter(
547        workspace: String,
548        adapter: ImmutableContentAdapterSession,
549    ) -> Self {
550        let workspace_services =
551            crate::workspace::WorkspaceServices::local(PathBuf::from(&workspace));
552        Self::build(
553            workspace,
554            None,
555            ArtifactStoreLimits::default(),
556            workspace_services,
557            Some(adapter),
558        )
559    }
560
561    pub(crate) fn new_with_workspace_services_artifact_limits_and_immutable_content_adapter(
562        workspace: String,
563        workspace_services: Arc<crate::workspace::WorkspaceServices>,
564        artifact_limits: ArtifactStoreLimits,
565        adapter: Option<ImmutableContentAdapterSession>,
566    ) -> Self {
567        Self::build(
568            workspace,
569            None,
570            artifact_limits,
571            workspace_services,
572            adapter,
573        )
574    }
575
576    fn build(
577        workspace: String,
578        command_env: Option<HashMap<String, String>>,
579        artifact_limits: ArtifactStoreLimits,
580        workspace_services: Arc<crate::workspace::WorkspaceServices>,
581        immutable_content_adapter: Option<ImmutableContentAdapterSession>,
582    ) -> Self {
583        let workspace_path = PathBuf::from(&workspace);
584        let command_env = command_env.map(Arc::new);
585        let registry = Arc::new(
586            ToolRegistry::with_workspace_services_artifact_limits_and_immutable_content_adapter(
587                workspace_path.clone(),
588                artifact_limits,
589                Arc::clone(&workspace_services),
590                immutable_content_adapter,
591            ),
592        );
593        if let Some(env) = command_env.clone() {
594            registry.set_command_env(env);
595        }
596
597        // Register native Rust built-in tools — only those whose required
598        // workspace capability is available, so the model never sees a tool
599        // the backend cannot service.
600        builtin::register_builtins(&registry, &workspace_services);
601        // Batch tool requires Arc<ToolRegistry>, registered separately
602        builtin::register_batch(&registry);
603        builtin::register_program(&registry);
604
605        Self {
606            workspace: workspace_path,
607            registry,
608            command_env,
609        }
610    }
611
612    fn check_workspace_boundary(
613        name: &str,
614        args: &serde_json::Value,
615        ctx: &ToolContext,
616    ) -> Result<()> {
617        let path_field = match name {
618            "read" | "write" | "edit" | "patch" | "download" => Some("file_path"),
619            "ls" | "search" | "code_symbols" | "code_navigation" | "code_diagnostics" => {
620                Some("path")
621            }
622            _ => None,
623        };
624
625        if let Some(field) = path_field {
626            if let Some(path_str) = args.get(field).and_then(|v| v.as_str()) {
627                ctx.resolve_workspace_path(path_str).map_err(|e| {
628                    anyhow::anyhow!(
629                        "Workspace boundary check failed for tool '{}' path '{}': {}",
630                        name,
631                        path_str,
632                        e
633                    )
634                })?;
635            }
636        }
637
638        Ok(())
639    }
640
641    pub fn workspace(&self) -> &PathBuf {
642        &self.workspace
643    }
644
645    pub fn registry(&self) -> &Arc<ToolRegistry> {
646        &self.registry
647    }
648
649    pub(crate) fn snapshot_with_external_tools(
650        &self,
651        external: impl IntoIterator<Item = Arc<dyn Tool>>,
652    ) -> Result<Self, ToolRegistrySnapshotError> {
653        Ok(Self {
654            workspace: self.workspace.clone(),
655            registry: Arc::new(self.registry.snapshot_with_external_tools(external)?),
656            command_env: self.command_env.clone(),
657        })
658    }
659
660    /// Get a stored tool artifact by URI.
661    pub fn get_artifact(&self, artifact_uri: &str) -> Option<ToolArtifact> {
662        self.registry.get_artifact(artifact_uri)
663    }
664
665    /// Return a clone of the executor's artifact store handle.
666    pub fn artifact_store(&self) -> ArtifactStore {
667        self.registry.artifact_store()
668    }
669
670    /// Replace the sink used for compact execution trace events.
671    pub fn set_trace_sink(&self, sink: Arc<dyn crate::trace::TraceSink>) {
672        self.registry.set_trace_sink(sink);
673    }
674
675    /// Return the currently configured execution trace sink.
676    pub fn trace_sink(&self) -> Arc<dyn crate::trace::TraceSink> {
677        self.registry.trace_sink()
678    }
679
680    pub fn command_env(&self) -> Option<Arc<HashMap<String, String>>> {
681        self.command_env.clone()
682    }
683
684    pub fn register_dynamic_tool(&self, tool: Arc<dyn Tool>) {
685        self.registry.register(tool);
686    }
687
688    pub(crate) fn register_dynamic_tool_with_shadow(
689        &self,
690        tool: Arc<dyn Tool>,
691    ) -> (bool, Option<Arc<dyn Tool>>) {
692        self.registry.register_with_shadow(tool)
693    }
694
695    pub(crate) fn restore_dynamic_tool_if_same(
696        &self,
697        name: &str,
698        expected: &Arc<dyn Tool>,
699        replacement: Option<Arc<dyn Tool>>,
700    ) -> bool {
701        self.registry.restore_if_same(name, expected, replacement)
702    }
703
704    pub(crate) fn register_dynamic_tool_if_absent(&self, tool: Arc<dyn Tool>) -> bool {
705        self.registry.register_if_absent(tool)
706    }
707
708    pub fn unregister_dynamic_tool(&self, name: &str) {
709        self.registry.unregister(name);
710    }
711
712    /// Unregister all dynamic tools whose names start with the given prefix.
713    pub fn unregister_tools_by_prefix(&self, prefix: &str) {
714        self.registry.unregister_by_prefix(prefix);
715    }
716
717    /// Replace the model-visible `program` tool with a custom PTC catalog.
718    pub fn register_program_catalog(&self, catalog: crate::program::ProgramCatalog) {
719        builtin::register_program_with_catalog(&self.registry, catalog);
720    }
721
722    /// Execute directly against this low-level executor.
723    ///
724    /// This API intentionally does not install agent/session permission, HITL,
725    /// hook, budget, queue, timeout, cancellation, or sanitization policy.
726    /// Session hosts should use [`crate::AgentSession::tool`] only for already
727    /// authorized control-plane calls, or [`crate::AgentSession::governed_tool`]
728    /// when permission and HITL must still apply. Agent runtimes must dispatch
729    /// through their scoped tool invocation gateway.
730    pub async fn execute(&self, name: &str, args: &serde_json::Value) -> Result<ToolResult> {
731        let ctx = self.registry.context();
732        if let Err(e) = Self::check_workspace_boundary(name, args, &ctx) {
733            return Ok(ToolResult::error(name, e.to_string()));
734        }
735
736        log_tool_invocation(name, args);
737        let mut result = self.registry.execute_with_context(name, args, &ctx).await;
738        if let Ok(ref mut r) = result {
739            self.attach_diff_metadata(name, args, r);
740        }
741        match &result {
742            Ok(r) => tracing::info!("Tool {} completed with exit_code={}", name, r.exit_code),
743            Err(e) => tracing::error!("Tool {} failed: {}", name, e),
744        }
745        result
746    }
747
748    /// Execute directly with a caller-owned context.
749    ///
750    /// Like [`Self::execute`], this is an ungoverned standalone boundary. A
751    /// `ToolContext` supplies capabilities to the tool but is not itself a
752    /// substitute for the agent/session invocation gateway.
753    pub async fn execute_with_context(
754        &self,
755        name: &str,
756        args: &serde_json::Value,
757        ctx: &ToolContext,
758    ) -> Result<ToolResult> {
759        Self::check_workspace_boundary(name, args, ctx)?;
760        log_tool_invocation(name, args);
761        let mut result = self.registry.execute_with_context(name, args, ctx).await;
762        if let Ok(ref mut r) = result {
763            self.attach_diff_metadata(name, args, r);
764        }
765        match &result {
766            Ok(r) => tracing::info!("Tool {} completed with exit_code={}", name, r.exit_code),
767            Err(e) => tracing::error!("Tool {} failed: {}", name, e),
768        }
769        result
770    }
771
772    fn attach_diff_metadata(&self, name: &str, args: &serde_json::Value, result: &mut ToolResult) {
773        if !matches!(name, "write" | "edit" | "patch") {
774            return;
775        }
776        let Some(file_path) = args.get("file_path").and_then(serde_json::Value::as_str) else {
777            return;
778        };
779        // Only store file_path in metadata, let translate_event read the actual content
780        // using the session's correct workspace
781        let meta = result.metadata.get_or_insert_with(|| serde_json::json!({}));
782        meta["file_path"] = serde_json::Value::String(file_path.to_string());
783    }
784
785    pub fn definitions(&self) -> Vec<ToolDefinition> {
786        self.registry.definitions()
787    }
788}
789
790#[cfg(test)]
791#[path = "tests.rs"]
792mod tests;