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