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