Skip to main content

zeroclaw_api/
tool.rs

1use async_trait::async_trait;
2use serde::{Deserialize, Serialize};
3
4/// Boilerplate-collapsing macro: pair a concrete `Tool` impl with a
5/// matching `Attributable` impl that surfaces the supplied `ToolKind`
6/// and uses the tool's `name()` as its alias.
7///
8/// Invoke once per `Tool` struct, in the same module as the struct:
9///
10/// ```ignore
11/// crate::tool_attribution!(ShellTool, ::zeroclaw_api::attribution::ToolKind::Shell);
12/// ```
13#[macro_export]
14macro_rules! tool_attribution {
15    ($ty:ty, $kind:expr) => {
16        impl $crate::attribution::Attributable for $ty {
17            fn role(&self) -> $crate::attribution::Role {
18                $crate::attribution::Role::Tool($kind)
19            }
20            fn alias(&self) -> &str {
21                <Self as $crate::tool::Tool>::name(self)
22            }
23        }
24    };
25}
26
27/// Bulk-impl `Attributable` for one or more `Tool` mock types in a
28/// test module. Every type gets `Role::Tool(ToolKind::Plugin)` and uses
29/// the mock's own `name()` as the alias — sufficient for test
30/// scaffolding where individual kinds don't matter.
31///
32/// ```ignore
33/// zeroclaw_api::mock_tool_attribution!(CountingTool, FailingTool);
34/// ```
35#[macro_export]
36macro_rules! mock_tool_attribution {
37    ($($ty:ty),+ $(,)?) => {
38        $(
39            $crate::tool_attribution!($ty, $crate::attribution::ToolKind::Plugin);
40        )+
41    };
42}
43
44/// Result of a tool execution
45#[derive(Debug, Clone, Serialize, Deserialize)]
46pub struct ToolResult {
47    pub success: bool,
48    pub output: String,
49    pub error: Option<String>,
50}
51
52/// Loud, actionable banner that filesystem-touching tools surface when the
53/// active runtime uses an **ephemeral workspace** — e.g. a Docker container
54/// with no host volume mount, where the workspace is a private tmpfs. In that
55/// mode writes succeed *inside the container* but never reach the host and are
56/// discarded when the session ends, and reads may return stale or empty data.
57/// Surfacing this prevents the silent data loss reported in issue #4627.
58///
59/// `file_write` refuses outright (it exists only to persist data). The
60/// general-purpose `shell`, `file_read`, and `file_edit` tools stay usable but
61/// attach this warning so the agent — and through it the user — knows the
62/// workspace is ephemeral and how to fix it.
63pub const EPHEMERAL_WORKSPACE_WARNING: &str = "\u{26a0}\u{fe0f} EPHEMERAL WORKSPACE: the active runtime uses an ephemeral workspace \
64     (tmpfs / no host volume mount). Files written here do NOT persist on the host after this \
65     session ends, and reads may return stale or empty data. To make the workspace persistent, \
66     set `runtime.docker.mount_workspace = true` in your config and ensure the workspace \
67     directory is bind-mounted into the container.";
68
69/// Prepend [`EPHEMERAL_WORKSPACE_WARNING`] to a tool's output/error text as a
70/// clearly delimited banner, preserving the original text below it.
71///
72/// The banner must live in the field the dispatcher forwards to the model
73/// (`output` on success, `error` on failure), so call this for whichever field
74/// will be shown. Returns the banner alone when `text` is empty.
75pub fn with_ephemeral_workspace_warning(text: &str) -> String {
76    if text.is_empty() {
77        EPHEMERAL_WORKSPACE_WARNING.to_string()
78    } else {
79        format!("{EPHEMERAL_WORKSPACE_WARNING}\n\n{text}")
80    }
81}
82
83/// Description of a tool for the LLM
84#[derive(Debug, Clone, Serialize, Deserialize)]
85pub struct ToolSpec {
86    pub name: String,
87    pub description: String,
88    pub parameters: serde_json::Value,
89}
90
91/// Core tool trait — implement for any capability.
92///
93/// Every `Tool` is `Attributable`: log emissions and audit traces from
94/// a tool call carry the same `<kind>.<alias>` composite the rest of
95/// the runtime uses for channels, providers, and memory. The supertrait
96/// bound makes `&dyn Tool` coerce to `&dyn Attributable` automatically,
97/// so dispatch-site logging can attribute without knowing the concrete
98/// tool type.
99#[async_trait]
100pub trait Tool: Send + Sync + crate::attribution::Attributable {
101    /// Tool name (used in LLM function calling)
102    fn name(&self) -> &str;
103
104    /// Human-readable description
105    fn description(&self) -> &str;
106
107    /// JSON schema for parameters
108    fn parameters_schema(&self) -> serde_json::Value;
109
110    /// Execute the tool with given arguments
111    async fn execute(&self, args: serde_json::Value) -> anyhow::Result<ToolResult>;
112
113    /// Get the full spec for LLM registration
114    fn spec(&self) -> ToolSpec {
115        ToolSpec {
116            name: self.name().to_string(),
117            description: self.description().to_string(),
118            parameters: self.parameters_schema(),
119        }
120    }
121}
122
123#[cfg(test)]
124mod tests {
125    use super::*;
126
127    #[test]
128    fn ephemeral_warning_names_cause_and_fix() {
129        assert!(EPHEMERAL_WORKSPACE_WARNING.contains("EPHEMERAL WORKSPACE"));
130        assert!(EPHEMERAL_WORKSPACE_WARNING.contains("tmpfs"));
131        assert!(EPHEMERAL_WORKSPACE_WARNING.contains("mount_workspace"));
132        // Line continuations must not leave doubled spaces.
133        assert!(!EPHEMERAL_WORKSPACE_WARNING.contains("  "));
134    }
135
136    #[test]
137    fn empty_text_returns_banner_alone() {
138        assert_eq!(
139            with_ephemeral_workspace_warning(""),
140            EPHEMERAL_WORKSPACE_WARNING
141        );
142    }
143
144    #[test]
145    fn nonempty_text_keeps_body_below_banner() {
146        let out = with_ephemeral_workspace_warning("body");
147        assert!(out.starts_with(EPHEMERAL_WORKSPACE_WARNING));
148        assert!(out.ends_with("\n\nbody"));
149    }
150}