use crate::core::errors::{ConduitError, ErrorKind};
use crate::tools::context::ToolContext;
use futures::future::BoxFuture;
use serde_json::Value;
use std::fmt;
use std::sync::Arc;
pub type ToolResult = Result<Value, ConduitError>;
pub type ToolHandlerFn =
dyn Fn(Value, Option<ToolContext>) -> BoxFuture<'static, ToolResult> + Send + Sync;
pub struct Tool {
pub name: String,
pub description: String,
pub parameters: Value,
pub handler: Option<Arc<ToolHandlerFn>>,
pub context: bool,
pub timeout: Option<std::time::Duration>,
pub read_only: bool,
}
impl fmt::Debug for Tool {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("Tool")
.field("name", &self.name)
.field("description", &self.description)
.field("parameters", &self.parameters)
.field("handler", &self.handler.is_some())
.field("context", &self.context)
.field("timeout", &self.timeout)
.field("read_only", &self.read_only)
.finish()
}
}
#[derive(Debug)]
pub enum ToolAction {
Keep,
Remove,
Replace(Tool),
}
impl Clone for Tool {
fn clone(&self) -> Self {
Self {
name: self.name.clone(),
description: self.description.clone(),
parameters: self.parameters.clone(),
handler: self.handler.clone(),
context: self.context,
timeout: self.timeout,
read_only: self.read_only,
}
}
}
impl Tool {
pub fn schema_only(
name: impl Into<String>,
description: impl Into<String>,
parameters: Value,
) -> Self {
Self {
name: name.into(),
description: description.into(),
parameters,
handler: None,
context: false,
timeout: None,
read_only: false,
}
}
pub fn new<F>(
name: impl Into<String>,
description: impl Into<String>,
parameters: Value,
handler: F,
) -> Self
where
F: Fn(Value, Option<ToolContext>) -> BoxFuture<'static, ToolResult> + Send + Sync + 'static,
{
Self {
name: name.into(),
description: description.into(),
parameters,
handler: Some(Arc::new(handler)),
context: false,
timeout: None,
read_only: false,
}
}
pub fn with_context<F>(
name: impl Into<String>,
description: impl Into<String>,
parameters: Value,
handler: F,
) -> Self
where
F: Fn(Value, Option<ToolContext>) -> BoxFuture<'static, ToolResult> + Send + Sync + 'static,
{
Self {
name: name.into(),
description: description.into(),
parameters,
handler: Some(Arc::new(handler)),
context: true,
timeout: None,
read_only: false,
}
}
pub fn read_only(mut self) -> Self {
self.read_only = true;
self
}
pub fn schema(&self) -> Value {
serde_json::json!({
"type": "function",
"function": {
"name": self.name,
"description": self.description,
"parameters": self.parameters,
}
})
}
pub fn as_tool(&self, json_mode: bool) -> Value {
let schema = self.schema();
if json_mode {
Value::String(serde_json::to_string_pretty(&schema).unwrap_or_default())
} else {
schema
}
}
pub async fn run(&self, args: Value, context: Option<ToolContext>) -> ToolResult {
match &self.handler {
Some(handler) => handler(args, context).await,
None => Err(ConduitError::new(
ErrorKind::Tool,
format!(
"Tool '{}' is schema-only and cannot be executed.",
self.name
),
)),
}
}
pub fn is_runnable(&self) -> bool {
self.handler.is_some()
}
pub fn with_timeout(mut self, timeout: std::time::Duration) -> Self {
self.timeout = Some(timeout);
self
}
}
#[derive(Debug, Clone)]
pub struct ToolSet {
pub schemas: Vec<Value>,
pub runnable: Vec<Tool>,
}
impl ToolSet {
pub fn empty() -> Self {
Self {
schemas: Vec::new(),
runnable: Vec::new(),
}
}
pub fn payload(&self) -> Option<&[Value]> {
if self.schemas.is_empty() {
None
} else {
Some(&self.schemas)
}
}
pub fn require_runnable(&self) -> Result<(), ConduitError> {
if !self.schemas.is_empty() && self.runnable.is_empty() {
return Err(ConduitError::new(
ErrorKind::Tool,
"Schema-only tools cannot be executed.",
));
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
fn make_echo_tool(name: &str, desc: &str) -> Tool {
Tool::new(
name,
desc,
json!({"type": "object", "properties": {"msg": {"type": "string"}}}),
|args, _ctx| Box::pin(async move { Ok(args) }),
)
}
#[test]
fn read_only_defaults_false_and_builder_sets_it() {
let tool = make_echo_tool("x", "x");
assert!(!tool.read_only, "tools default to mutating (safe default)");
assert!(tool.read_only().read_only, "builder marks read-only");
}
fn make_schema_only_tool(name: &str) -> Tool {
Tool::schema_only(
name,
"A schema-only tool",
json!({"type": "object", "properties": {}}),
)
}
#[test]
fn test_tool_creation_with_handler() {
let tool = make_echo_tool("echo", "Echo args back");
assert_eq!(tool.name, "echo");
assert_eq!(tool.description, "Echo args back");
assert!(tool.is_runnable());
assert!(!tool.context);
}
#[test]
fn test_tool_creation_schema_only() {
let tool = make_schema_only_tool("readonly");
assert_eq!(tool.name, "readonly");
assert!(!tool.is_runnable());
}
#[test]
fn test_tool_with_context_flag() {
let tool = Tool::with_context(
"ctx_tool",
"Needs context",
json!({"type": "object", "properties": {}}),
|_args, _ctx| Box::pin(async { Ok(json!(null)) }),
);
assert!(tool.context);
assert!(tool.is_runnable());
}
#[test]
fn test_tool_schema_format() {
let tool = make_echo_tool("my_tool", "Does stuff");
let schema = tool.schema();
assert_eq!(schema["type"], "function");
assert_eq!(schema["function"]["name"], "my_tool");
assert_eq!(schema["function"]["description"], "Does stuff");
assert!(schema["function"]["parameters"].is_object());
}
#[test]
fn test_tool_as_tool_json_mode() {
let tool = make_echo_tool("t", "d");
let result = tool.as_tool(true);
assert!(result.is_string());
let parsed: Value = serde_json::from_str(result.as_str().unwrap()).unwrap();
assert_eq!(parsed["type"], "function");
}
#[test]
fn test_tool_as_tool_value_mode() {
let tool = make_echo_tool("t", "d");
let result = tool.as_tool(false);
assert!(result.is_object());
assert_eq!(result["type"], "function");
}
#[tokio::test]
async fn test_tool_run_returns_args() {
let tool = make_echo_tool("echo", "Echo");
let args = json!({"msg": "hello"});
let result = tool.run(args.clone(), None).await.unwrap();
assert_eq!(result, args);
}
#[tokio::test]
async fn test_schema_only_tool_run_errors() {
let tool = make_schema_only_tool("readonly");
let result = tool.run(json!({}), None).await;
assert!(result.is_err());
let err = result.unwrap_err();
assert_eq!(err.kind, ErrorKind::Tool);
assert!(err.message.contains("schema-only"));
}
#[test]
fn test_toolset_empty() {
let ts = ToolSet::empty();
assert!(ts.schemas.is_empty());
assert!(ts.runnable.is_empty());
assert!(ts.payload().is_none());
}
#[test]
fn test_toolset_require_runnable_ok_when_empty() {
let ts = ToolSet::empty();
assert!(ts.require_runnable().is_ok());
}
#[test]
fn test_toolset_require_runnable_fails_when_schema_only() {
let ts = ToolSet {
schemas: vec![json!({"type": "function", "function": {"name": "x", "parameters": {}}})],
runnable: vec![],
};
assert!(ts.require_runnable().is_err());
}
#[test]
fn test_tool_clone() {
let tool = make_echo_tool("orig", "Original");
let cloned = tool.clone();
assert_eq!(cloned.name, "orig");
assert_eq!(cloned.description, "Original");
assert!(cloned.is_runnable());
}
#[test]
fn test_tool_debug() {
let tool = make_echo_tool("dbg", "Debug test");
let debug_str = format!("{:?}", tool);
assert!(debug_str.contains("dbg"));
assert!(debug_str.contains("handler: true"));
}
}