heartbit_core/tool/
mod.rs1#[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#[derive(Debug, Clone)]
22pub struct ToolOutput {
23 pub content: String,
25 pub is_error: bool,
27}
28
29impl ToolOutput {
30 pub fn success(content: impl Into<String>) -> Self {
32 Self {
33 content: content.into(),
34 is_error: false,
35 }
36 }
37
38 pub fn error(content: impl Into<String>) -> Self {
40 Self {
41 content: content.into(),
42 is_error: true,
43 }
44 }
45
46 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
72pub trait Tool: Send + Sync {
115 fn definition(&self) -> ToolDefinition;
117
118 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 fn redact_for_history(&self, output: &str) -> String {
139 output.to_string()
140 }
141}
142
143pub 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 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 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 #[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 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); }
250
251 #[test]
252 fn tool_output_truncated_preserves_utf8() {
253 let output = ToolOutput::success("ééééé"); let truncated = output.truncated(5);
256 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"); 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"); }
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); }
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 let schema = json!({"type": "not-a-real-type"});
335 let input = json!({"anything": true});
336 assert!(validate_tool_input(&schema, &input).is_ok());
338 }
339
340 #[test]
341 fn redact_for_history_default_is_identity() {
342 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 let input = json!({"query": "test", "extra": true});
384 assert!(validate_tool_input(&schema, &input).is_ok());
385 }
386}