use crate::core::errors::{ConduitError, ErrorKind};
use crate::core::results::{ErrorPayload, ToolExecution};
use crate::core::tool_calls::normalize_tool_calls;
use crate::tools::context::ToolContext;
use crate::tools::schema::{Tool, ToolResult};
use serde_json::Value;
use std::collections::HashMap;
#[derive(Debug, Clone)]
pub struct ToolExecutor;
impl ToolExecutor {
pub fn new() -> Self {
Self
}
pub fn execute(
&self,
response: ToolCallResponse,
tools: &[Tool],
context: Option<&ToolContext>,
) -> Result<ToolExecution, ConduitError> {
let rt = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.map_err(|e| {
ConduitError::new(ErrorKind::Unknown, format!("Failed to create runtime: {e}"))
})?;
rt.block_on(self.execute_async(response, tools, context))
}
pub async fn execute_async(
&self,
response: ToolCallResponse,
tools: &[Tool],
context: Option<&ToolContext>,
) -> Result<ToolExecution, ConduitError> {
let tool_calls = self.normalize_response(response)?;
let tool_map = self.build_tool_map(tools);
if tool_map.is_empty() {
if !tool_calls.is_empty() {
return Err(ConduitError::new(
ErrorKind::Tool,
"No runnable tools are available.",
));
}
return Ok(ToolExecution::default());
}
let mut results = Vec::with_capacity(tool_calls.len());
let mut first_error: Option<ErrorPayload> = None;
for call in &tool_calls {
match self.handle_tool_call(call, &tool_map, context).await {
Ok(result) => results.push(result),
Err(err) => {
let payload = ErrorPayload::new(err.kind, &err.message);
let err_value = serde_json::to_value(&payload).unwrap_or(Value::Null);
if first_error.is_none() {
first_error = Some(payload);
}
results.push(err_value);
}
}
}
Ok(ToolExecution {
tool_calls,
tool_results: results,
error: first_error,
})
}
fn resolve_tool_call<'a>(
&self,
call: &'a Value,
tool_map: &'a HashMap<String, &'a Tool>,
) -> Result<(&'a str, &'a Tool, Value), ConduitError> {
let obj = call.as_object().ok_or_else(|| {
ConduitError::new(ErrorKind::InvalidInput, "Each tool call must be an object.")
})?;
let function = obj
.get("function")
.and_then(|v| v.as_object())
.ok_or_else(|| {
ConduitError::new(
ErrorKind::InvalidInput,
"Tool call is missing function object.",
)
})?;
let name = function
.get("name")
.and_then(|v| v.as_str())
.ok_or_else(|| {
ConduitError::new(ErrorKind::InvalidInput, "Tool call is missing name.")
})?;
let tool = self.resolve_tool(name, tool_map).ok_or_else(|| {
ConduitError::new(ErrorKind::Tool, format!("Unknown tool name: {name}."))
})?;
let resolved_name = tool.name.as_str();
let raw_args = function
.get("arguments")
.cloned()
.unwrap_or(Value::Object(Default::default()));
let args = self.normalize_tool_args(resolved_name, raw_args)?;
Ok((resolved_name, tool, args))
}
fn resolve_tool<'a>(
&self,
name: &str,
tool_map: &'a HashMap<String, &'a Tool>,
) -> Option<&'a Tool> {
tool_map
.get(name)
.copied()
.or_else(|| {
if name.contains('_') {
tool_map.get(&name.replace('_', ".")).copied()
} else {
None
}
})
.or_else(|| {
if name.contains('.') {
tool_map.get(&name.replace('.', "_")).copied()
} else {
None
}
})
}
async fn handle_tool_call(
&self,
call: &Value,
tool_map: &HashMap<String, &Tool>,
context: Option<&ToolContext>,
) -> ToolResult {
let (name, tool, args) = self.resolve_tool_call(call, tool_map)?;
if tool.context && context.is_none() {
return Err(ConduitError::new(
ErrorKind::InvalidInput,
format!("Tool '{name}' requires context but none was provided."),
));
}
let ctx = if tool.context { context.cloned() } else { None };
tool.run(args, ctx).await.map_err(|e| {
ConduitError::new(
ErrorKind::Tool,
format!("Tool '{name}' execution failed: {e}"),
)
})
}
fn normalize_response(&self, response: ToolCallResponse) -> Result<Vec<Value>, ConduitError> {
match response {
ToolCallResponse::Json(s) => {
let parsed: Value = serde_json::from_str(&s).map_err(|e| {
ConduitError::new(
ErrorKind::InvalidInput,
format!("Tool response is not valid JSON: {e}"),
)
})?;
self.normalize_response(ToolCallResponse::Value(parsed))
}
ToolCallResponse::Value(val) => match val {
Value::Array(arr) => self.normalize_tool_calls(arr),
Value::Object(obj) => {
if let Some(output) = obj.get("output").and_then(|v| v.as_array()) {
self.normalize_tool_calls(output.to_vec())
} else if let Some(content) = obj.get("content").and_then(|v| v.as_array()) {
self.normalize_tool_calls(content.to_vec())
} else if let Some(tool_calls) =
obj.get("tool_calls").and_then(|v| v.as_array())
{
self.normalize_tool_calls(tool_calls.to_vec())
} else {
self.normalize_tool_calls(vec![Value::Object(obj)])
}
}
_ => Err(ConduitError::new(
ErrorKind::InvalidInput,
"Tool response must be a list of objects.",
)),
},
ToolCallResponse::List(list) => self.normalize_tool_calls(list),
}
}
fn normalize_tool_calls(&self, raw_calls: Vec<Value>) -> Result<Vec<Value>, ConduitError> {
let normalized = normalize_tool_calls(&raw_calls);
if raw_calls.is_empty() || !normalized.is_empty() {
return Ok(normalized);
}
Err(ConduitError::new(
ErrorKind::InvalidInput,
"Tool response did not contain any valid tool calls.",
))
}
fn build_tool_map<'a>(&self, tools: &'a [Tool]) -> HashMap<String, &'a Tool> {
let mut map = HashMap::new();
for tool in tools {
if tool.is_runnable() && !tool.name.is_empty() {
map.insert(tool.name.clone(), tool);
}
}
map
}
fn normalize_tool_args(&self, tool_name: &str, args: Value) -> Result<Value, ConduitError> {
match args {
Value::String(s) => {
let parsed: Value = serde_json::from_str(&s).map_err(|_| {
ConduitError::new(
ErrorKind::InvalidInput,
format!("Tool '{tool_name}' arguments are not valid JSON."),
)
})?;
if parsed.is_object() {
Ok(parsed)
} else {
Err(ConduitError::new(
ErrorKind::InvalidInput,
format!("Tool '{tool_name}' arguments must be an object."),
))
}
}
Value::Object(_) => Ok(args),
_ => Err(ConduitError::new(
ErrorKind::InvalidInput,
format!("Tool '{tool_name}' arguments must be an object."),
)),
}
}
}
impl Default for ToolExecutor {
fn default() -> Self {
Self::new()
}
}
pub enum ToolCallResponse {
Json(String),
Value(Value),
List(Vec<Value>),
}
impl From<String> for ToolCallResponse {
fn from(s: String) -> Self {
Self::Json(s)
}
}
impl From<&str> for ToolCallResponse {
fn from(s: &str) -> Self {
Self::Json(s.to_string())
}
}
impl From<Value> for ToolCallResponse {
fn from(v: Value) -> Self {
Self::Value(v)
}
}
impl From<Vec<Value>> for ToolCallResponse {
fn from(v: Vec<Value>) -> Self {
Self::List(v)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::tools::schema::Tool;
use serde_json::json;
fn make_echo_tool() -> Tool {
Tool::new(
"echo",
"Echo the input",
json!({"type": "object", "properties": {"msg": {"type": "string"}}}),
|args, _ctx| {
Box::pin(async move {
let msg = args.get("msg").and_then(|v| v.as_str()).unwrap_or("(none)");
Ok(json!({"echoed": msg}))
})
},
)
}
fn make_failing_tool() -> Tool {
Tool::new(
"fail",
"Always fails",
json!({"type": "object", "properties": {}}),
|_args, _ctx| {
Box::pin(
async move { Err(ConduitError::new(ErrorKind::Tool, "intentional failure")) },
)
},
)
}
fn tool_call_json(name: &str, args: Value) -> Value {
json!({
"type": "function",
"function": {
"name": name,
"arguments": args,
}
})
}
#[test]
fn test_executor_creation() {
let executor = ToolExecutor::new();
let default_executor = ToolExecutor::default();
let _ = format!("{:?}", executor);
let _ = format!("{:?}", default_executor);
}
#[tokio::test]
async fn test_execute_async_single_tool_call() {
let executor = ToolExecutor::new();
let echo = make_echo_tool();
let call = tool_call_json("echo", json!({"msg": "hello"}));
let result = executor
.execute_async(ToolCallResponse::List(vec![call]), &[echo], None)
.await
.unwrap();
assert_eq!(result.tool_calls.len(), 1);
assert_eq!(result.tool_results.len(), 1);
assert!(result.error.is_none());
assert_eq!(result.tool_results[0]["echoed"], "hello");
}
#[tokio::test]
async fn test_execute_async_accepts_responses_function_call_shape() {
let executor = ToolExecutor::new();
let echo = make_echo_tool();
let call = json!({
"type": "function_call",
"call_id": "call_123",
"name": "echo",
"arguments": "{\"msg\": \"hello\"}"
});
let result = executor
.execute_async(ToolCallResponse::List(vec![call]), &[echo], None)
.await
.unwrap();
assert!(result.error.is_none());
assert_eq!(result.tool_calls[0]["id"], "call_123");
assert_eq!(result.tool_calls[0]["function"]["name"], "echo");
assert_eq!(result.tool_results[0]["echoed"], "hello");
}
#[tokio::test]
async fn test_execute_async_multiple_tool_calls() {
let executor = ToolExecutor::new();
let echo = make_echo_tool();
let calls = vec![
tool_call_json("echo", json!({"msg": "first"})),
tool_call_json("echo", json!({"msg": "second"})),
];
let result = executor
.execute_async(ToolCallResponse::List(calls), &[echo], None)
.await
.unwrap();
assert_eq!(result.tool_calls.len(), 2);
assert_eq!(result.tool_results.len(), 2);
assert!(result.error.is_none());
assert_eq!(result.tool_results[0]["echoed"], "first");
assert_eq!(result.tool_results[1]["echoed"], "second");
}
#[tokio::test]
async fn test_execute_async_unknown_tool_name() {
let executor = ToolExecutor::new();
let echo = make_echo_tool();
let call = tool_call_json("nonexistent", json!({}));
let result = executor
.execute_async(ToolCallResponse::List(vec![call]), &[echo], None)
.await
.unwrap();
assert!(result.error.is_some());
assert_eq!(result.error.unwrap().kind, ErrorKind::Tool);
}
#[tokio::test]
async fn test_execute_async_resolves_underscore_alias_for_dotted_tool() {
let executor = ToolExecutor::new();
let tool = Tool::new(
"tape.info",
"Tape info",
json!({"type": "object", "properties": {}}),
|_args, _ctx| Box::pin(async { Ok(json!({"ok": true})) }),
);
let call = tool_call_json("tape_info", json!({}));
let result = executor
.execute_async(ToolCallResponse::List(vec![call]), &[tool], None)
.await
.unwrap();
assert!(result.error.is_none());
assert_eq!(result.tool_results[0]["ok"], true);
}
#[tokio::test]
async fn test_execute_async_resolves_dotted_alias_for_underscore_tool() {
let executor = ToolExecutor::new();
let tool = Tool::new(
"tape_info",
"Tape info",
json!({"type": "object", "properties": {}}),
|_args, _ctx| Box::pin(async { Ok(json!({"ok": true})) }),
);
let call = tool_call_json("tape.info", json!({}));
let result = executor
.execute_async(ToolCallResponse::List(vec![call]), &[tool], None)
.await
.unwrap();
assert!(result.error.is_none());
assert_eq!(result.tool_results[0]["ok"], true);
}
#[tokio::test]
async fn test_execute_async_no_tools_with_calls_errors() {
let executor = ToolExecutor::new();
let call = tool_call_json("echo", json!({}));
let result = executor
.execute_async(ToolCallResponse::List(vec![call]), &[], None)
.await;
assert!(result.is_err());
assert_eq!(result.unwrap_err().kind, ErrorKind::Tool);
}
#[tokio::test]
async fn test_execute_async_no_tools_no_calls_ok() {
let executor = ToolExecutor::new();
let result = executor
.execute_async(ToolCallResponse::List(vec![]), &[], None)
.await
.unwrap();
assert!(result.tool_calls.is_empty());
assert!(result.tool_results.is_empty());
assert!(result.error.is_none());
}
#[tokio::test]
async fn test_execute_async_tool_failure_captured_in_error() {
let executor = ToolExecutor::new();
let fail = make_failing_tool();
let call = tool_call_json("fail", json!({}));
let result = executor
.execute_async(ToolCallResponse::List(vec![call]), &[fail], None)
.await
.unwrap();
assert!(result.error.is_some());
let err = result.error.unwrap();
assert_eq!(err.kind, ErrorKind::Tool);
assert!(err.message.contains("execution failed"));
}
#[tokio::test]
async fn test_normalize_response_json_string() {
let executor = ToolExecutor::new();
let echo = make_echo_tool();
let json_str =
serde_json::to_string(&vec![tool_call_json("echo", json!({"msg": "hi"}))]).unwrap();
let result = executor
.execute_async(ToolCallResponse::Json(json_str), &[echo], None)
.await
.unwrap();
assert_eq!(result.tool_results.len(), 1);
assert_eq!(result.tool_results[0]["echoed"], "hi");
}
#[tokio::test]
async fn test_normalize_response_single_object() {
let executor = ToolExecutor::new();
let echo = make_echo_tool();
let single = tool_call_json("echo", json!({"msg": "single"}));
let result = executor
.execute_async(ToolCallResponse::Value(single), &[echo], None)
.await
.unwrap();
assert_eq!(result.tool_results.len(), 1);
assert_eq!(result.tool_results[0]["echoed"], "single");
}
#[tokio::test]
async fn test_normalize_response_invalid_json() {
let executor = ToolExecutor::new();
let echo = make_echo_tool();
let result = executor
.execute_async(
ToolCallResponse::Json("not-json".to_string()),
&[echo],
None,
)
.await;
assert!(result.is_err());
assert_eq!(result.unwrap_err().kind, ErrorKind::InvalidInput);
}
#[tokio::test]
async fn test_normalize_response_rejects_primitive() {
let executor = ToolExecutor::new();
let echo = make_echo_tool();
let result = executor
.execute_async(ToolCallResponse::Value(json!(42)), &[echo], None)
.await;
assert!(result.is_err());
}
#[tokio::test]
async fn test_string_arguments_are_parsed() {
let executor = ToolExecutor::new();
let echo = make_echo_tool();
let call = json!({
"type": "function",
"function": {
"name": "echo",
"arguments": "{\"msg\": \"from_string\"}"
}
});
let result = executor
.execute_async(ToolCallResponse::List(vec![call]), &[echo], None)
.await
.unwrap();
assert_eq!(result.tool_results[0]["echoed"], "from_string");
}
#[test]
fn test_tool_call_response_from_string() {
let resp: ToolCallResponse = String::from("[]").into();
matches!(resp, ToolCallResponse::Json(_));
}
#[test]
fn test_tool_call_response_from_str() {
let resp: ToolCallResponse = "[]".into();
matches!(resp, ToolCallResponse::Json(_));
}
#[test]
fn test_tool_call_response_from_value() {
let resp: ToolCallResponse = json!([]).into();
matches!(resp, ToolCallResponse::Value(_));
}
#[test]
fn test_tool_call_response_from_vec() {
let resp: ToolCallResponse = vec![json!({})].into();
matches!(resp, ToolCallResponse::List(_));
}
#[tokio::test]
async fn test_context_tool_without_context_errors() {
let tool = Tool::with_context(
"ctx_tool",
"Needs context",
json!({"type": "object", "properties": {}}),
|_args, _ctx| Box::pin(async { Ok(json!("ok")) }),
);
let executor = ToolExecutor::new();
let call = tool_call_json("ctx_tool", json!({}));
let result = executor
.execute_async(ToolCallResponse::List(vec![call]), &[tool], None)
.await
.unwrap();
assert!(result.error.is_some());
assert!(result.error.unwrap().message.contains("requires context"));
}
#[tokio::test]
async fn test_context_tool_with_context_succeeds() {
let tool = Tool::with_context(
"ctx_tool",
"Needs context",
json!({"type": "object", "properties": {}}),
|_args, ctx| {
Box::pin(async move {
let run_id = ctx.map(|c| c.run_id).unwrap_or_default();
Ok(json!({"run_id": run_id}))
})
},
);
let executor = ToolExecutor::new();
let call = tool_call_json("ctx_tool", json!({}));
let context = ToolContext::new("test-run-123");
let result = executor
.execute_async(ToolCallResponse::List(vec![call]), &[tool], Some(&context))
.await
.unwrap();
assert!(result.error.is_none());
assert_eq!(result.tool_results[0]["run_id"], "test-run-123");
}
}