#[cfg(feature = "a2a")]
pub mod a2a;
pub mod advisor;
pub mod builtins;
pub mod handoff;
pub mod mcp;
pub mod mcp_presets;
pub mod mcp_server;
pub mod set_goal;
pub mod set_scope;
use std::future::Future;
use std::pin::Pin;
use crate::error::Error;
use crate::llm::types::ToolDefinition;
#[derive(Debug, Clone)]
pub struct ToolOutput {
pub content: String,
pub is_error: bool,
}
impl ToolOutput {
pub fn success(content: impl Into<String>) -> Self {
Self {
content: content.into(),
is_error: false,
}
}
pub fn error(content: impl Into<String>) -> Self {
Self {
content: content.into(),
is_error: true,
}
}
pub fn truncated(mut self, max_bytes: usize) -> Self {
if max_bytes == 0 {
return self;
}
if self.content.len() > max_bytes {
let mut cut = max_bytes;
while cut > 0 && !self.content.is_char_boundary(cut) {
cut -= 1;
}
let omitted = self.content.len() - cut;
self.content.truncate(cut);
self.content
.push_str(&format!("\n\n[truncated: {omitted} bytes omitted]"));
}
self
}
}
pub trait Tool: Send + Sync {
fn definition(&self) -> ToolDefinition;
fn execute(
&self,
ctx: &crate::ExecutionContext,
input: serde_json::Value,
) -> Pin<Box<dyn Future<Output = Result<ToolOutput, Error>> + Send + '_>>;
fn redact_for_history(&self, output: &str) -> String {
output.to_string()
}
}
pub fn validate_tool_input(
schema: &serde_json::Value,
input: &serde_json::Value,
) -> Result<(), String> {
let validator = match jsonschema::validator_for(schema) {
Ok(v) => v,
Err(e) => {
tracing::warn!(error = %e, "invalid tool schema, skipping validation");
return Ok(());
}
};
let errors: Vec<String> = validator
.iter_errors(input)
.map(|e| e.to_string())
.collect();
if errors.is_empty() {
Ok(())
} else {
let mut msg = format!("Input validation failed: {}", errors.join("; "));
if input.as_object().is_some_and(|o| o.is_empty()) {
msg.push_str(
". You sent NO arguments — parameters must be passed in the \
tool call's arguments (e.g. file content goes in the tool's \
input fields), not in your text response.",
);
}
Err(msg)
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn tool_output_success() {
let output = ToolOutput::success("result data");
assert_eq!(output.content, "result data");
assert!(!output.is_error);
}
#[test]
fn empty_input_validation_error_names_the_channel() {
let schema = json!({
"type": "object",
"properties": {
"file_path": {"type": "string"},
"content": {"type": "string"}
},
"required": ["file_path", "content"]
});
let err = validate_tool_input(&schema, &json!({})).unwrap_err();
assert!(err.contains("required property"), "got: {err}");
assert!(
err.contains("tool call's arguments") && err.contains("not in your text"),
"empty input must carry the channel hint, got: {err}"
);
let err = validate_tool_input(&schema, &json!({"file_path": "x"})).unwrap_err();
assert!(
!err.contains("text response"),
"partial input must not get the empty-args hint: {err}"
);
}
#[test]
fn tool_output_error() {
let output = ToolOutput::error("something failed");
assert_eq!(output.content, "something failed");
assert!(output.is_error);
}
#[test]
fn tool_output_truncated_noop_when_within_limit() {
let output = ToolOutput::success("short text");
let truncated = output.truncated(100);
assert_eq!(truncated.content, "short text");
assert!(!truncated.is_error);
}
#[test]
fn tool_output_truncated_cuts_long_content() {
let output = ToolOutput::success("a".repeat(1000));
let truncated = output.truncated(100);
assert!(truncated.content.len() < 1000);
assert!(truncated.content.starts_with("aaaa"));
assert!(truncated.content.contains("[truncated:"));
assert!(truncated.content.contains("bytes omitted]"));
assert!(!truncated.is_error); }
#[test]
fn tool_output_truncated_preserves_utf8() {
let output = ToolOutput::success("ééééé"); let truncated = output.truncated(5);
assert!(truncated.content.starts_with("éé"));
assert!(truncated.content.contains("[truncated:"));
}
#[test]
fn tool_output_truncated_exact_boundary_noop() {
let output = ToolOutput::success("hello"); let truncated = output.truncated(5);
assert_eq!(truncated.content, "hello");
}
#[test]
fn tool_output_truncated_zero_is_noop() {
let output = ToolOutput::success("some content");
let truncated = output.truncated(0);
assert_eq!(truncated.content, "some content"); }
#[test]
fn tool_output_truncated_error_also_truncates() {
let output = ToolOutput::error("e".repeat(200));
let truncated = output.truncated(50);
assert!(truncated.content.contains("[truncated:"));
assert!(truncated.is_error); }
#[test]
fn validate_accepts_valid_input() {
let schema = json!({
"type": "object",
"properties": {
"query": {"type": "string"}
},
"required": ["query"]
});
let input = json!({"query": "test"});
assert!(validate_tool_input(&schema, &input).is_ok());
}
#[test]
fn validate_rejects_missing_required() {
let schema = json!({
"type": "object",
"properties": {
"query": {"type": "string"}
},
"required": ["query"]
});
let input = json!({});
let err = validate_tool_input(&schema, &input).unwrap_err();
assert!(err.contains("validation failed"), "got: {err}");
}
#[test]
fn validate_rejects_wrong_type() {
let schema = json!({
"type": "object",
"properties": {
"query": {"type": "string"}
},
"required": ["query"]
});
let input = json!({"query": 42});
let err = validate_tool_input(&schema, &input).unwrap_err();
assert!(err.contains("validation failed"), "got: {err}");
}
#[test]
fn validate_accepts_any_for_minimal_schema() {
let schema = json!({"type": "object"});
let input = json!({});
assert!(validate_tool_input(&schema, &input).is_ok());
}
#[test]
fn validate_skips_on_invalid_schema() {
let schema = json!({"type": "not-a-real-type"});
let input = json!({"anything": true});
assert!(validate_tool_input(&schema, &input).is_ok());
}
#[test]
fn redact_for_history_default_is_identity() {
struct NoopTool;
impl Tool for NoopTool {
fn definition(&self) -> ToolDefinition {
ToolDefinition {
name: "noop".into(),
description: "noop".into(),
input_schema: json!({"type": "object"}),
}
}
fn execute(
&self,
_ctx: &crate::ExecutionContext,
_input: serde_json::Value,
) -> std::pin::Pin<
Box<
dyn std::future::Future<Output = Result<ToolOutput, crate::error::Error>>
+ Send
+ '_,
>,
> {
Box::pin(async { Ok(ToolOutput::success("ok")) })
}
}
let tool = NoopTool;
let raw = "anything goes here, including [IMAGE:base64:abc]";
assert_eq!(tool.redact_for_history(raw), raw);
}
#[test]
fn validate_accepts_extra_properties() {
let schema = json!({
"type": "object",
"properties": {
"query": {"type": "string"}
},
"required": ["query"]
});
let input = json!({"query": "test", "extra": true});
assert!(validate_tool_input(&schema, &input).is_ok());
}
}