use std::sync::Arc;
use serde_json::json;
use crate::content::Content;
use crate::hooks::{HookRunner, TurnContext};
use crate::tools::ToolRunner;
use crate::types::{HookResult, ToolCall, ToolResult};
pub(crate) async fn gate_pre_turn(
hook_runner: Option<&Arc<HookRunner>>,
turn_ctx: &TurnContext,
prompt: &Content,
) -> Option<String> {
let hooks = hook_runner?;
let decision = hooks.dispatch_pre_turn(turn_ctx, prompt).await;
if decision.allow {
None
} else {
Some(format!("turn denied by hook: {}", decision.message))
}
}
pub(crate) async fn dispatch_post_turn(
hook_runner: Option<&Arc<HookRunner>>,
turn_ctx: &TurnContext,
response: &str,
) {
if let Some(hooks) = hook_runner {
hooks.dispatch_post_turn(turn_ctx, response).await;
}
}
pub(crate) async fn dispatch_tool_call(
tool_runner: Option<&Arc<ToolRunner>>,
hook_runner: Option<&Arc<HookRunner>>,
turn_ctx: &TurnContext,
call: &ToolCall,
) -> ToolResult {
let (decision, op_ctx) = if let Some(hooks) = hook_runner {
hooks.dispatch_pre_tool_call(turn_ctx, call).await
} else {
(HookResult::allow(), turn_ctx.clone())
};
let (result_value, error): (serde_json::Value, Option<String>) = if !decision.allow {
let msg = decision.message.clone();
(json!({ "error": msg.clone() }), Some(msg))
} else if let Some(runner) = tool_runner {
match runner.execute(&call.name, call.args.clone()).await {
Ok(v) => {
let err = v.get("error").and_then(|e| e.as_str()).map(String::from);
(v, err)
}
Err(e) => {
let s = e.to_string();
(json!({ "error": s.clone() }), Some(s))
}
}
} else {
let s = format!("no tool runner registered for '{}'", call.name);
(json!({ "error": s.clone() }), Some(s))
};
let result = ToolResult {
name: call.name.clone(),
id: call.id.clone(),
result: Some(result_value),
error,
};
if let Some(hooks) = hook_runner {
hooks.dispatch_post_tool_call(&op_ctx, &result).await;
}
result
}
#[cfg(test)]
mod tests {
use super::*;
use crate::tools::ClosureTool;
use crate::types::ToolCall;
fn tool(name: &'static str, body: serde_json::Value) -> Arc<ClosureTool> {
ClosureTool::new(name, "", json!({ "type": "object" }), move |_a, _c| {
let body = body.clone();
async move { Ok(body) }
})
}
fn call(name: &str) -> ToolCall {
ToolCall { name: name.to_string(), args: json!({}), id: None, canonical_path: None }
}
#[tokio::test]
async fn success_result_carries_value_and_no_error() {
let runner = Arc::new(ToolRunner::new());
runner.register(tool("ok", json!({ "answer": 42 })));
let r = dispatch_tool_call(Some(&runner), None, &TurnContext::new(), &call("ok")).await;
assert_eq!(r.error, None);
assert_eq!(r.result.unwrap()["answer"], 42);
assert_eq!(r.name, "ok");
}
#[tokio::test]
async fn ok_value_with_error_key_is_lifted_to_the_typed_error() {
let runner = Arc::new(ToolRunner::new());
runner.register(tool("soft_fail", json!({ "error": "boom" })));
let r = dispatch_tool_call(Some(&runner), None, &TurnContext::new(), &call("soft_fail")).await;
assert_eq!(r.error.as_deref(), Some("boom"));
assert_eq!(r.result.unwrap()["error"], "boom");
}
#[tokio::test]
async fn execute_err_becomes_an_error_result_not_a_panic() {
let runner = Arc::new(ToolRunner::new());
runner.register(ClosureTool::new("hard_fail", "", json!({ "type": "object" }), |_a, _c| async {
Err(crate::error::Error::other("kaboom"))
}));
let r = dispatch_tool_call(Some(&runner), None, &TurnContext::new(), &call("hard_fail")).await;
assert!(r.error.as_deref().unwrap().contains("kaboom"));
assert!(r.result.unwrap()["error"].as_str().unwrap().contains("kaboom"));
}
#[tokio::test]
async fn missing_runner_and_unknown_tool_both_surface_as_errors() {
let r = dispatch_tool_call(None, None, &TurnContext::new(), &call("x")).await;
assert!(r.error.as_deref().unwrap().contains("no tool runner"));
let runner = Arc::new(ToolRunner::new());
let r = dispatch_tool_call(Some(&runner), None, &TurnContext::new(), &call("ghost")).await;
assert!(r.error.is_some());
}
}