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