use async_trait::async_trait;
use oxi_agent::{AgentTool, AgentToolResult, ToolContext, ToolError};
use serde_json::Value;
use std::future::Future;
use std::pin::Pin;
use std::sync::Arc;
pub type ToolHandler =
Arc<dyn Fn(Value, &ToolContext) -> Result<AgentToolResult, ToolError> + Send + Sync>;
pub type AsyncToolHandler = Arc<
dyn Fn(
Value,
&ToolContext,
) -> Pin<Box<dyn Future<Output = Result<AgentToolResult, ToolError>> + Send>>
+ Send
+ Sync,
>;
pub struct ClosureTool {
name: String,
description: String,
schema: Value,
handler: AsyncToolHandler,
}
impl ClosureTool {
pub fn new_sync(
name: impl Into<String>,
description: impl Into<String>,
schema: Value,
handler: impl Fn(Value, &ToolContext) -> Result<AgentToolResult, ToolError>
+ Send
+ Sync
+ 'static,
) -> Self {
#[allow(clippy::type_complexity)]
let handler_arc: Arc<
dyn Fn(Value, &ToolContext) -> Result<AgentToolResult, ToolError> + Send + Sync,
> = Arc::new(handler);
Self {
name: name.into(),
description: description.into(),
schema,
handler: Arc::new(move |params, ctx| {
let result = handler_arc(params, ctx);
Box::pin(async move { result })
}),
}
}
pub fn new_async(
name: impl Into<String>,
description: impl Into<String>,
schema: Value,
handler: impl Fn(
Value,
&ToolContext,
)
-> Pin<Box<dyn Future<Output = Result<AgentToolResult, ToolError>> + Send>>
+ Send
+ Sync
+ 'static,
) -> Self {
Self {
name: name.into(),
description: description.into(),
schema,
handler: Arc::new(handler),
}
}
}
impl ClosureTool {
pub fn new_typed_async<Args, F, Fut>(
name: impl Into<String>,
description: impl Into<String>,
handler: F,
) -> Self
where
Args: schemars::JsonSchema + serde::de::DeserializeOwned + Send + 'static,
F: Fn(Args, &ToolContext) -> Fut + Send + Sync + 'static,
Fut: Future<Output = Result<AgentToolResult, ToolError>> + Send + 'static,
{
use std::sync::Arc;
let schema = serde_json::to_value(schemars::schema_for!(Args))
.unwrap_or_else(|_| serde_json::json!({"type": "object"}));
let name_owned: String = name.into();
let handler: Arc<F> = Arc::new(handler);
Self::new_async(name_owned, description, schema, move |params, ctx| {
match serde_json::from_value::<Args>(params) {
Ok(args) => {
let handler = handler.clone();
let fut = handler(args, ctx);
Box::pin(fut)
}
Err(e) => Box::pin(async move {
Err::<AgentToolResult, ToolError>(format!(
"invalid args for typed closure: {e}"
))
}),
}
})
}
}
#[async_trait]
impl AgentTool for ClosureTool {
fn name(&self) -> &str {
&self.name
}
fn label(&self) -> &str {
&self.name
}
fn description(&self) -> &str {
&self.description
}
fn parameters_schema(&self) -> Value {
self.schema.clone()
}
async fn execute(
&self,
_tool_call_id: &str,
params: Value,
_signal: Option<tokio::sync::oneshot::Receiver<()>>,
ctx: &ToolContext,
) -> Result<AgentToolResult, ToolError> {
(self.handler)(params, ctx).await
}
}
#[cfg(test)]
mod tests {
use super::*;
use schemars::JsonSchema;
use serde::Deserialize;
#[derive(Debug, Deserialize, JsonSchema)]
struct EchoArgs {
message: String,
}
fn ctx() -> ToolContext {
ToolContext::default()
}
#[tokio::test]
async fn new_typed_async_generates_schema() {
let tool = ClosureTool::new_typed_async::<EchoArgs, _, _>(
"echo",
"echo back",
|args, _ctx| async move { Ok(AgentToolResult::success(args.message)) },
);
assert_eq!(tool.name(), "echo");
assert_eq!(tool.description(), "echo back");
let schema = tool.parameters_schema();
assert!(
schema.get("properties").is_some(),
"schema missing properties: {schema}"
);
}
#[tokio::test]
async fn new_typed_async_deserializes_args() {
let tool = ClosureTool::new_typed_async::<EchoArgs, _, _>(
"echo",
"echo back",
|args, _ctx| async move { Ok(AgentToolResult::success(args.message)) },
);
let result = tool
.execute(
"call_1",
serde_json::json!({"message": "hello"}),
None,
&ctx(),
)
.await
.expect("execute ok");
assert!(result.success);
assert_eq!(result.output, "hello");
}
#[tokio::test]
async fn new_typed_async_rejects_invalid_args() {
let tool = ClosureTool::new_typed_async::<EchoArgs, _, _>(
"echo",
"echo back",
|args, _ctx| async move { Ok(AgentToolResult::success(args.message)) },
);
let err = tool
.execute("call_1", serde_json::json!({}), None, &ctx())
.await
.expect_err("missing field should fail");
assert!(
err.contains("invalid args for typed closure"),
"unexpected error: {err}"
);
}
}