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}