Skip to main content

heartbit_core/tool/
mod.rs

1//! Tool trait and built-in tool implementations (filesystem, web, MCP, A2A, etc.).
2
3#[cfg(feature = "a2a")]
4pub mod a2a;
5pub mod advisor;
6pub mod builtins;
7pub mod handoff;
8pub mod mcp;
9pub mod mcp_presets;
10pub mod mcp_server;
11pub mod set_goal;
12pub mod set_scope;
13
14use std::future::Future;
15use std::pin::Pin;
16
17use crate::error::Error;
18use crate::llm::types::ToolDefinition;
19
20/// Output of a tool execution.
21#[derive(Debug, Clone)]
22pub struct ToolOutput {
23    /// Stringified tool output (may be JSON, plain text, etc.).
24    pub content: String,
25    /// `true` when the tool returned an error.
26    pub is_error: bool,
27}
28
29impl ToolOutput {
30    /// Construct a successful tool output.
31    pub fn success(content: impl Into<String>) -> Self {
32        Self {
33            content: content.into(),
34            is_error: false,
35        }
36    }
37
38    /// Construct a failed tool output (`is_error = true`).
39    pub fn error(content: impl Into<String>) -> Self {
40        Self {
41            content: content.into(),
42            is_error: true,
43        }
44    }
45
46    /// Truncate content if it exceeds `max_bytes`, preserving UTF-8 validity.
47    ///
48    /// When truncated, appends a `[truncated: N bytes omitted]` suffix so the
49    /// LLM knows data was cut. Content within the limit is returned unchanged.
50    /// A `max_bytes` of 0 is treated as no-op (returns content unchanged).
51    ///
52    /// Note: the suffix itself is not counted toward `max_bytes`, so the
53    /// result may slightly exceed the limit.
54    pub fn truncated(mut self, max_bytes: usize) -> Self {
55        if max_bytes == 0 {
56            return self;
57        }
58        if self.content.len() > max_bytes {
59            let mut cut = max_bytes;
60            while cut > 0 && !self.content.is_char_boundary(cut) {
61                cut -= 1;
62            }
63            let omitted = self.content.len() - cut;
64            self.content.truncate(cut);
65            self.content
66                .push_str(&format!("\n\n[truncated: {omitted} bytes omitted]"));
67        }
68        self
69    }
70}
71
72/// Trait for tools that agents can invoke.
73///
74/// Uses `Pin<Box<dyn Future>>` return type for dyn-compatibility,
75/// allowing tools to be stored as `Arc<dyn Tool>`.
76///
77/// # Example
78///
79/// Implementing a simple synchronous tool that echoes its input:
80///
81/// ```rust
82/// use std::future::Future;
83/// use std::pin::Pin;
84/// use heartbit_core::{Tool, ToolOutput};
85/// use heartbit_core::llm::types::ToolDefinition;
86///
87/// struct EchoTool;
88///
89/// impl Tool for EchoTool {
90///     fn definition(&self) -> ToolDefinition {
91///         ToolDefinition {
92///             name: "echo".into(),
93///             description: "Echo back the input string.".into(),
94///             input_schema: serde_json::json!({
95///                 "type": "object",
96///                 "properties": { "text": { "type": "string" } },
97///                 "required": ["text"]
98///             }),
99///         }
100///     }
101///
102///     fn execute(
103///         &self,
104///         _ctx: &heartbit_core::ExecutionContext,
105///         input: serde_json::Value,
106///     ) -> Pin<Box<dyn Future<Output = Result<ToolOutput, heartbit_core::Error>> + Send + '_>> {
107///         Box::pin(async move {
108///             let text = input.get("text").and_then(|v| v.as_str()).unwrap_or("");
109///             Ok(ToolOutput::success(text.to_string()))
110///         })
111///     }
112/// }
113/// ```
114pub trait Tool: Send + Sync {
115    /// Return the tool's definition (name, description, input schema) for the LLM.
116    fn definition(&self) -> ToolDefinition;
117
118    /// Execute the tool with the LLM-supplied `input` JSON.
119    ///
120    /// Input is validated against the tool's declared schema before reaching this method.
121    fn execute(
122        &self,
123        ctx: &crate::ExecutionContext,
124        input: serde_json::Value,
125    ) -> Pin<Box<dyn Future<Output = Result<ToolOutput, Error>> + Send + '_>>;
126
127    /// Optional override: produce a compact version of the tool output
128    /// suitable for inclusion in conversation history sent to subsequent
129    /// LLM turns. The default implementation returns the input verbatim.
130    ///
131    /// Tools that emit large blobs (e.g., base64 image data) should
132    /// override this to strip or replace the blob with a small
133    /// placeholder. This prevents conversation-history bloat that can
134    /// trip per-model context limits (e.g., Anthropic's 200k token cap).
135    ///
136    /// The full, unredacted output is preserved on
137    /// `AgentOutput.tool_call_results` for the caller's use.
138    fn redact_for_history(&self, output: &str) -> String {
139        output.to_string()
140    }
141}
142
143/// Validate tool input against the tool's declared JSON Schema.
144///
145/// Returns `Ok(())` if valid, `Err(error_message)` if the input
146/// does not conform. The error message is suitable for sending back
147/// to the LLM so it can self-correct.
148pub fn validate_tool_input(
149    schema: &serde_json::Value,
150    input: &serde_json::Value,
151) -> Result<(), String> {
152    let validator = match jsonschema::validator_for(schema) {
153        Ok(v) => v,
154        Err(e) => {
155            // If the schema itself is invalid, skip validation rather than
156            // rejecting every call. Log a warning for the operator.
157            tracing::warn!(error = %e, "invalid tool schema, skipping validation");
158            return Ok(());
159        }
160    };
161
162    let errors: Vec<String> = validator
163        .iter_errors(input)
164        .map(|e| e.to_string())
165        .collect();
166    if errors.is_empty() {
167        Ok(())
168    } else {
169        let mut msg = format!("Input validation failed: {}", errors.join("; "));
170        // An EMPTY input object with required properties is the signature of a
171        // model that put its payload in the text channel instead of the tool
172        // call (live /analyze finding: write({}) alongside a 4000-token text
173        // response, repeated). Name the actual mistake so it can self-correct.
174        if input.as_object().is_some_and(|o| o.is_empty()) {
175            msg.push_str(
176                ". You sent NO arguments — parameters must be passed in the \
177                 tool call's arguments (e.g. file content goes in the tool's \
178                 input fields), not in your text response.",
179            );
180        }
181        Err(msg)
182    }
183}
184
185#[cfg(test)]
186mod tests {
187    use super::*;
188    use serde_json::json;
189
190    #[test]
191    fn tool_output_success() {
192        let output = ToolOutput::success("result data");
193        assert_eq!(output.content, "result data");
194        assert!(!output.is_error);
195    }
196
197    // Live /analyze finding: a model generated 4000-token file bodies in its
198    // TEXT response and called write with input={} — twice, identically. The
199    // bare "is a required property" error never told it the load-bearing
200    // fact: arguments go in the tool call, not the text channel.
201    #[test]
202    fn empty_input_validation_error_names_the_channel() {
203        let schema = json!({
204            "type": "object",
205            "properties": {
206                "file_path": {"type": "string"},
207                "content": {"type": "string"}
208            },
209            "required": ["file_path", "content"]
210        });
211        let err = validate_tool_input(&schema, &json!({})).unwrap_err();
212        assert!(err.contains("required property"), "got: {err}");
213        assert!(
214            err.contains("tool call's arguments") && err.contains("not in your text"),
215            "empty input must carry the channel hint, got: {err}"
216        );
217        // Non-empty-but-invalid input keeps the plain error (no hint spam).
218        let err = validate_tool_input(&schema, &json!({"file_path": "x"})).unwrap_err();
219        assert!(
220            !err.contains("text response"),
221            "partial input must not get the empty-args hint: {err}"
222        );
223    }
224
225    #[test]
226    fn tool_output_error() {
227        let output = ToolOutput::error("something failed");
228        assert_eq!(output.content, "something failed");
229        assert!(output.is_error);
230    }
231
232    #[test]
233    fn tool_output_truncated_noop_when_within_limit() {
234        let output = ToolOutput::success("short text");
235        let truncated = output.truncated(100);
236        assert_eq!(truncated.content, "short text");
237        assert!(!truncated.is_error);
238    }
239
240    #[test]
241    fn tool_output_truncated_cuts_long_content() {
242        let output = ToolOutput::success("a".repeat(1000));
243        let truncated = output.truncated(100);
244        assert!(truncated.content.len() < 1000);
245        assert!(truncated.content.starts_with("aaaa"));
246        assert!(truncated.content.contains("[truncated:"));
247        assert!(truncated.content.contains("bytes omitted]"));
248        assert!(!truncated.is_error); // preserves is_error flag
249    }
250
251    #[test]
252    fn tool_output_truncated_preserves_utf8() {
253        // "é" is 2 bytes in UTF-8. A cut at byte 5 would split a char boundary.
254        let output = ToolOutput::success("ééééé"); // 10 bytes
255        let truncated = output.truncated(5);
256        // Should cut at char boundary (4 bytes = 2 chars), not mid-char
257        assert!(truncated.content.starts_with("éé"));
258        assert!(truncated.content.contains("[truncated:"));
259    }
260
261    #[test]
262    fn tool_output_truncated_exact_boundary_noop() {
263        let output = ToolOutput::success("hello"); // 5 bytes
264        let truncated = output.truncated(5);
265        assert_eq!(truncated.content, "hello");
266    }
267
268    #[test]
269    fn tool_output_truncated_zero_is_noop() {
270        let output = ToolOutput::success("some content");
271        let truncated = output.truncated(0);
272        assert_eq!(truncated.content, "some content"); // unchanged
273    }
274
275    #[test]
276    fn tool_output_truncated_error_also_truncates() {
277        let output = ToolOutput::error("e".repeat(200));
278        let truncated = output.truncated(50);
279        assert!(truncated.content.contains("[truncated:"));
280        assert!(truncated.is_error); // preserves error flag
281    }
282
283    #[test]
284    fn validate_accepts_valid_input() {
285        let schema = json!({
286            "type": "object",
287            "properties": {
288                "query": {"type": "string"}
289            },
290            "required": ["query"]
291        });
292        let input = json!({"query": "test"});
293        assert!(validate_tool_input(&schema, &input).is_ok());
294    }
295
296    #[test]
297    fn validate_rejects_missing_required() {
298        let schema = json!({
299            "type": "object",
300            "properties": {
301                "query": {"type": "string"}
302            },
303            "required": ["query"]
304        });
305        let input = json!({});
306        let err = validate_tool_input(&schema, &input).unwrap_err();
307        assert!(err.contains("validation failed"), "got: {err}");
308    }
309
310    #[test]
311    fn validate_rejects_wrong_type() {
312        let schema = json!({
313            "type": "object",
314            "properties": {
315                "query": {"type": "string"}
316            },
317            "required": ["query"]
318        });
319        let input = json!({"query": 42});
320        let err = validate_tool_input(&schema, &input).unwrap_err();
321        assert!(err.contains("validation failed"), "got: {err}");
322    }
323
324    #[test]
325    fn validate_accepts_any_for_minimal_schema() {
326        let schema = json!({"type": "object"});
327        let input = json!({});
328        assert!(validate_tool_input(&schema, &input).is_ok());
329    }
330
331    #[test]
332    fn validate_skips_on_invalid_schema() {
333        // An invalid schema should not block tool execution
334        let schema = json!({"type": "not-a-real-type"});
335        let input = json!({"anything": true});
336        // Should not fail even though schema is invalid — skips validation
337        assert!(validate_tool_input(&schema, &input).is_ok());
338    }
339
340    #[test]
341    fn redact_for_history_default_is_identity() {
342        // A tool with no override should return the output verbatim.
343        struct NoopTool;
344        impl Tool for NoopTool {
345            fn definition(&self) -> ToolDefinition {
346                ToolDefinition {
347                    name: "noop".into(),
348                    description: "noop".into(),
349                    input_schema: json!({"type": "object"}),
350                }
351            }
352
353            fn execute(
354                &self,
355                _ctx: &crate::ExecutionContext,
356                _input: serde_json::Value,
357            ) -> std::pin::Pin<
358                Box<
359                    dyn std::future::Future<Output = Result<ToolOutput, crate::error::Error>>
360                        + Send
361                        + '_,
362                >,
363            > {
364                Box::pin(async { Ok(ToolOutput::success("ok")) })
365            }
366        }
367
368        let tool = NoopTool;
369        let raw = "anything goes here, including [IMAGE:base64:abc]";
370        assert_eq!(tool.redact_for_history(raw), raw);
371    }
372
373    #[test]
374    fn validate_accepts_extra_properties() {
375        let schema = json!({
376            "type": "object",
377            "properties": {
378                "query": {"type": "string"}
379            },
380            "required": ["query"]
381        });
382        // Extra properties are allowed by default in JSON Schema
383        let input = json!({"query": "test", "extra": true});
384        assert!(validate_tool_input(&schema, &input).is_ok());
385    }
386}