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