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