use async_trait::async_trait;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum ParamType {
String,
Number,
Integer,
Boolean,
Array,
Object,
}
impl ParamType {
fn as_str(&self) -> &'static str {
match self {
ParamType::String => "string",
ParamType::Number => "number",
ParamType::Integer => "integer",
ParamType::Boolean => "boolean",
ParamType::Array => "array",
ParamType::Object => "object",
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ToolParam {
#[serde(rename = "type")]
pub param_type: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub default: Option<serde_json::Value>,
#[serde(rename = "enum", skip_serializing_if = "Option::is_none")]
pub enum_values: Option<Vec<String>>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ToolSchema {
pub name: String,
pub description: String,
pub parameters: ParameterSchema,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ParameterSchema {
#[serde(rename = "type")]
pub schema_type: String,
pub properties: HashMap<String, ToolParam>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub required: Vec<String>,
}
impl ToolSchema {
pub fn new(name: impl Into<String>, description: impl Into<String>) -> Self {
Self {
name: name.into(),
description: description.into(),
parameters: ParameterSchema {
schema_type: "object".to_string(),
properties: HashMap::new(),
required: vec![],
},
}
}
pub fn param(
mut self,
name: impl Into<String>,
param_type: ParamType,
description: impl Into<String>,
required: bool,
) -> Self {
let name = name.into();
self.parameters.properties.insert(
name.clone(),
ToolParam {
param_type: param_type.as_str().to_string(),
description: Some(description.into()),
default: None,
enum_values: None,
},
);
if required {
self.parameters.required.push(name);
}
self
}
pub fn param_with_default(
mut self,
name: impl Into<String>,
param_type: ParamType,
description: impl Into<String>,
default: serde_json::Value,
) -> Self {
let name = name.into();
self.parameters.properties.insert(
name,
ToolParam {
param_type: param_type.as_str().to_string(),
description: Some(description.into()),
default: Some(default),
enum_values: None,
},
);
self
}
pub fn param_enum(
mut self,
name: impl Into<String>,
values: &[&str],
description: impl Into<String>,
required: bool,
) -> Self {
let name = name.into();
self.parameters.properties.insert(
name.clone(),
ToolParam {
param_type: "string".to_string(),
description: Some(description.into()),
default: None,
enum_values: Some(values.iter().map(|s| s.to_string()).collect()),
},
);
if required {
self.parameters.required.push(name);
}
self
}
pub fn validate(&self) -> Result<(), String> {
if self.name.is_empty() {
return Err("Tool name cannot be empty".to_string());
}
if self.description.is_empty() {
return Err(format!("Tool '{}' must have a description", self.name));
}
Ok(())
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ToolResult {
pub success: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub result: Option<serde_json::Value>,
#[serde(skip_serializing_if = "Option::is_none")]
pub error: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub error_code: Option<String>,
}
impl ToolResult {
pub fn success(result: serde_json::Value) -> Self {
Self {
success: true,
result: Some(result),
error: None,
error_code: None,
}
}
pub fn ok() -> Self {
Self {
success: true,
result: Some(serde_json::json!({})),
error: None,
error_code: None,
}
}
pub fn error(message: impl Into<String>) -> Self {
Self {
success: false,
result: None,
error: Some(message.into()),
error_code: None,
}
}
pub fn error_with_code(message: impl Into<String>, code: impl Into<String>) -> Self {
Self {
success: false,
result: None,
error: Some(message.into()),
error_code: Some(code.into()),
}
}
}
#[async_trait]
pub trait ToolConnector: Send + Sync {
fn tools(&self) -> Vec<ToolSchema>;
async fn execute(&self, tool_name: &str, params: serde_json::Value) -> ToolResult;
async fn execute_with_context(
&self,
tool_name: &str,
params: serde_json::Value,
context: &HashMap<String, String>,
) -> ToolResult {
#[cfg(debug_assertions)]
if !context.is_empty() {
tracing::warn!(
target: "strike48_connector::context_drop",
context_keys = ?context.keys().collect::<Vec<_>>(),
"ToolConnector::execute_with_context default impl is dropping non-empty context. \
Override execute_with_context (not execute) if your tool needs caller metadata \
(tenant_id, user_id, etc.)."
);
}
#[cfg(not(debug_assertions))]
let _ = context;
self.execute(tool_name, params).await
}
fn tool_metadata(&self) -> HashMap<String, String> {
let schemas = self.tools();
let mut meta = HashMap::new();
meta.insert(
"tool_schemas".to_string(),
serde_json::to_string(&schemas).unwrap_or_default(),
);
meta.insert("tool_count".to_string(), schemas.len().to_string());
meta.insert(
"tool_names".to_string(),
schemas
.iter()
.map(|s| s.name.clone())
.collect::<Vec<_>>()
.join(","),
);
meta.insert("timeout_ms".to_string(), self.timeout_ms().to_string());
meta
}
fn timeout_ms(&self) -> u64 {
5000
}
fn validate_tools(&self) -> Result<(), String> {
let schemas = self.tools();
if schemas.is_empty() {
return Err("ToolConnector must define at least one tool".to_string());
}
for schema in &schemas {
schema.validate()?;
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_tool_schema_builder() {
let schema = ToolSchema::new("calculate", "Perform calculations")
.param("a", ParamType::Number, "First operand", true)
.param("b", ParamType::Number, "Second operand", true)
.param_enum("op", &["add", "subtract", "multiply"], "Operation", true);
assert_eq!(schema.name, "calculate");
assert_eq!(schema.parameters.properties.len(), 3);
assert_eq!(schema.parameters.required.len(), 3);
assert!(schema.validate().is_ok());
}
#[test]
fn test_tool_schema_with_default() {
let schema = ToolSchema::new("greet", "Greet someone")
.param("name", ParamType::String, "Name to greet", true)
.param_with_default(
"greeting",
ParamType::String,
"Greeting word",
serde_json::json!("Hello"),
);
assert_eq!(schema.parameters.required.len(), 1);
let greeting_param = schema.parameters.properties.get("greeting").unwrap();
assert_eq!(greeting_param.default, Some(serde_json::json!("Hello")));
}
#[test]
fn test_tool_result_success() {
let result = ToolResult::success(serde_json::json!({ "value": 42 }));
assert!(result.success);
assert_eq!(result.result.unwrap()["value"], 42);
assert!(result.error.is_none());
}
#[test]
fn test_tool_result_error() {
let result = ToolResult::error_with_code("Invalid input", "INVALID_INPUT");
assert!(!result.success);
assert!(result.result.is_none());
assert_eq!(result.error, Some("Invalid input".to_string()));
assert_eq!(result.error_code, Some("INVALID_INPUT".to_string()));
}
#[test]
fn test_tool_schema_validation() {
let empty_name = ToolSchema::new("", "Description");
assert!(empty_name.validate().is_err());
let empty_desc = ToolSchema::new("tool", "");
assert!(empty_desc.validate().is_err());
let valid = ToolSchema::new("tool", "Valid tool");
assert!(valid.validate().is_ok());
}
#[test]
fn test_tool_schema_serialization() {
let schema = ToolSchema::new("test", "Test tool").param(
"input",
ParamType::String,
"Input value",
true,
);
let json = serde_json::to_string(&schema).unwrap();
assert!(json.contains("\"name\":\"test\""));
assert!(json.contains("\"type\":\"string\""));
}
}