use std::rc::Rc;
use crate::agent_events::ToolExecutor;
use crate::value::{ErrorCategory, VmClosure, VmError, VmValue};
pub(super) fn stable_hash(val: &serde_json::Value) -> u64 {
use std::hash::{Hash, Hasher};
let mut hasher = std::collections::hash_map::DefaultHasher::new();
let canonical = serde_json::to_string(val).unwrap_or_default();
canonical.hash(&mut hasher);
hasher.finish()
}
pub(super) fn denied_tool_result(tool_name: &str, reason: impl Into<String>) -> serde_json::Value {
serde_json::json!({
"error": "permission_denied",
"tool": tool_name,
"reason": reason.into(),
})
}
pub(super) fn render_tool_result(value: &serde_json::Value) -> String {
if let Some(text) = value.as_str() {
text.to_string()
} else if value.is_null() {
"(no output)".to_string()
} else {
serde_json::to_string_pretty(value).unwrap_or_default()
}
}
pub(super) fn is_denied_tool_result(value: &serde_json::Value) -> bool {
if is_denied_tool_result_object(value) {
return true;
}
value
.as_str()
.and_then(|text| serde_json::from_str::<serde_json::Value>(text).ok())
.is_some_and(|parsed| is_denied_tool_result_object(&parsed))
}
fn is_denied_tool_result_object(value: &serde_json::Value) -> bool {
value
.get("error")
.and_then(|error| error.as_str())
.is_some_and(|error| error == "permission_denied")
|| value
.get("blocked")
.and_then(|blocked| blocked.as_bool())
.unwrap_or(false)
|| value
.get("status")
.and_then(|status| status.as_str())
.is_some_and(|status| status == "blocked")
}
pub(super) fn next_call_id() -> String {
uuid::Uuid::now_v7().to_string()
}
pub(super) struct ToolDispatchOutcome {
pub result: Result<serde_json::Value, VmError>,
pub executor: Option<ToolExecutor>,
}
#[cfg(test)]
pub(super) async fn dispatch_tool_execution(
tool_name: &str,
tool_args: &serde_json::Value,
tools_val: Option<&VmValue>,
bridge: Option<&Rc<crate::bridge::HostBridge>>,
tool_retries: usize,
tool_backoff_ms: u64,
) -> ToolDispatchOutcome {
dispatch_tool_execution_with_mcp(
tool_name,
tool_args,
tools_val,
None,
bridge,
tool_retries,
tool_backoff_ms,
)
.await
}
pub(super) async fn dispatch_tool_execution_with_mcp(
tool_name: &str,
tool_args: &serde_json::Value,
tools_val: Option<&VmValue>,
mcp_clients: Option<&std::collections::BTreeMap<String, crate::mcp::VmMcpClientHandle>>,
bridge: Option<&Rc<crate::bridge::HostBridge>>,
tool_retries: usize,
tool_backoff_ms: u64,
) -> ToolDispatchOutcome {
use super::tools::handle_tool_locally;
let declared = declared_executor_for_tool(tools_val, tool_name);
let mut attempt = 0usize;
let mut executor: Option<ToolExecutor> = None;
loop {
let result = if matches!(declared.as_deref(), Some("provider_native")) {
executor = Some(ToolExecutor::ProviderNative);
Err(VmError::CategorizedError {
message: format!(
"tool '{tool_name}' is declared executor: \"provider_native\" — \
the runtime does not dispatch these locally; the provider must \
have already executed the call"
),
category: ErrorCategory::ToolRejected,
})
} else if matches!(declared.as_deref(), Some("host_bridge")) {
let Some(bridge) = bridge else {
executor = Some(ToolExecutor::HostBridge);
return ToolDispatchOutcome {
result: Err(VmError::CategorizedError {
message: format!(
"tool '{tool_name}' is declared executor: \"host_bridge\" \
but no host bridge is connected to this environment"
),
category: ErrorCategory::ToolRejected,
}),
executor,
};
};
executor = Some(ToolExecutor::HostBridge);
match bridge
.call(
"builtin_call",
serde_json::json!({
"name": tool_name,
"args": [tool_args],
}),
)
.await
{
Err(VmError::CategorizedError {
message,
category: ErrorCategory::ToolRejected,
}) => Ok(denied_tool_result(tool_name, message)),
other => other,
}
} else if matches!(declared.as_deref(), Some("mcp_server")) {
let server_name = declared_mcp_server_for_tool(tools_val, tool_name)
.or_else(|| mcp_server_for_tool(tools_val, tool_name))
.unwrap_or_else(|| "mcp".to_string());
executor = Some(ToolExecutor::McpServer {
server_name: server_name.clone(),
});
if let Some(client) = mcp_clients.and_then(|clients| clients.get(&server_name)) {
let original_name = declared_mcp_tool_name_for_tool(tools_val, tool_name)
.unwrap_or_else(|| tool_name.to_string());
crate::mcp::call_mcp_tool(client, &original_name, tool_args.clone()).await
} else if let Some(handler) = find_tool_handler(tools_val, tool_name) {
let Some(mut vm) = crate::vm::clone_async_builtin_child_vm() else {
return ToolDispatchOutcome {
result: Err(VmError::CategorizedError {
message: format!(
"tool '{tool_name}' is MCP-served but no child VM context was available"
),
category: ErrorCategory::ToolRejected,
}),
executor,
};
};
let args_vm = crate::stdlib::json_to_vm_value(tool_args);
let _trusted_bridge_guard = crate::orchestration::allow_trusted_bridge_calls();
let outcome = vm.call_closure_pub(&handler, &[args_vm]).await;
let captured = vm.take_output();
crate::vm::forward_child_output_to_parent(&captured);
match outcome {
Ok(val) => Ok(serde_json::Value::String(val.display())),
Err(VmError::CategorizedError {
message,
category: ErrorCategory::ToolRejected,
}) => Ok(denied_tool_result(tool_name, message)),
Err(e) => Err(e),
}
} else if let Some(bridge) = bridge {
match bridge
.call(
"builtin_call",
serde_json::json!({
"name": tool_name,
"args": [tool_args],
}),
)
.await
{
Err(VmError::CategorizedError {
message,
category: ErrorCategory::ToolRejected,
}) => Ok(denied_tool_result(tool_name, message)),
other => other,
}
} else {
Err(VmError::CategorizedError {
message: format!(
"tool '{tool_name}' (mcp_server: \"{server_name}\") cannot be \
dispatched: no direct MCP client, bridge, or Harn handler"
),
category: ErrorCategory::ToolRejected,
})
}
} else if let Some(handler) = find_tool_handler(tools_val, tool_name) {
executor = Some(match mcp_server_for_tool(tools_val, tool_name) {
Some(server_name) => ToolExecutor::McpServer { server_name },
None => ToolExecutor::HarnBuiltin,
});
let Some(mut vm) = crate::vm::clone_async_builtin_child_vm() else {
return ToolDispatchOutcome {
result: Err(VmError::CategorizedError {
message: format!(
"tool '{tool_name}' is Harn-owned but no child VM context was available"
),
category: ErrorCategory::ToolRejected,
}),
executor,
};
};
let args_vm = crate::stdlib::json_to_vm_value(tool_args);
let _trusted_bridge_guard = crate::orchestration::allow_trusted_bridge_calls();
let outcome = vm.call_closure_pub(&handler, &[args_vm]).await;
let captured = vm.take_output();
crate::vm::forward_child_output_to_parent(&captured);
match outcome {
Ok(val) => Ok(serde_json::Value::String(val.display())),
Err(VmError::CategorizedError {
message,
category: ErrorCategory::ToolRejected,
}) => Ok(denied_tool_result(tool_name, message)),
Err(e) => Err(e),
}
} else if let Some(local_result) = handle_tool_locally(tool_name, tool_args) {
executor = Some(ToolExecutor::HarnBuiltin);
Ok(serde_json::Value::String(local_result))
} else if let Some(bridge) = bridge {
executor = Some(match mcp_server_for_tool(tools_val, tool_name) {
Some(server_name) => ToolExecutor::McpServer { server_name },
None => ToolExecutor::HostBridge,
});
match bridge
.call(
"builtin_call",
serde_json::json!({
"name": tool_name,
"args": [tool_args],
}),
)
.await
{
Err(VmError::CategorizedError {
message,
category: ErrorCategory::ToolRejected,
}) => Ok(denied_tool_result(tool_name, message)),
other => other,
}
} else {
Err(VmError::CategorizedError {
message: format!(
"Tool '{}' is not available in the current environment. \
Use only the tools listed in the tool-calling contract.",
tool_name
),
category: ErrorCategory::ToolRejected,
})
};
match &result {
Ok(_) => break ToolDispatchOutcome { result, executor },
Err(VmError::CategorizedError {
category: ErrorCategory::ToolRejected,
..
}) => break ToolDispatchOutcome { result, executor },
Err(_) if attempt < tool_retries => {
attempt += 1;
let delay = tool_backoff_ms * (1u64 << attempt.min(5));
crate::clock_mock::sleep(tokio::time::Duration::from_millis(delay)).await;
}
Err(_) => break ToolDispatchOutcome { result, executor },
}
}
}
pub(super) fn mcp_server_for_tool(tools_val: Option<&VmValue>, tool_name: &str) -> Option<String> {
let dict = tools_val?.as_dict()?;
let tools_list = match dict.get("tools") {
Some(VmValue::List(l)) => l,
_ => return None,
};
for tool in tools_list.iter() {
let entry: &std::collections::BTreeMap<String, VmValue> = match tool {
VmValue::Dict(d) => d,
_ => continue,
};
let name = match entry.get("name") {
Some(v) => v.display(),
None => entry
.get("function")
.and_then(|f| f.as_dict())
.and_then(|f| f.get("name"))
.map(|v| v.display())
.unwrap_or_default(),
};
if name != tool_name {
continue;
}
if let Some(VmValue::String(s)) = entry.get("_mcp_server") {
return Some(s.to_string());
}
if let Some(VmValue::Dict(func)) = entry.get("function") {
if let Some(VmValue::String(s)) = func.get("_mcp_server") {
return Some(s.to_string());
}
}
return None;
}
None
}
pub(super) fn declared_executor_for_tool(
tools_val: Option<&VmValue>,
tool_name: &str,
) -> Option<String> {
let dict = tools_val?.as_dict()?;
let tools_list = match dict.get("tools") {
Some(VmValue::List(l)) => l,
_ => return None,
};
for tool in tools_list.iter() {
let entry: &std::collections::BTreeMap<String, VmValue> = match tool {
VmValue::Dict(d) => d,
_ => continue,
};
let name = match entry.get("name") {
Some(v) => v.display(),
None => continue,
};
if name != tool_name {
continue;
}
if let Some(VmValue::String(s)) = entry.get("executor") {
return Some(s.to_string());
}
return None;
}
None
}
fn declared_mcp_server_for_tool(tools_val: Option<&VmValue>, tool_name: &str) -> Option<String> {
let dict = tools_val?.as_dict()?;
let tools_list = match dict.get("tools") {
Some(VmValue::List(l)) => l,
_ => return None,
};
for tool in tools_list.iter() {
let entry: &std::collections::BTreeMap<String, VmValue> = match tool {
VmValue::Dict(d) => d,
_ => continue,
};
if entry.get("name").map(|v| v.display()).as_deref() != Some(tool_name) {
continue;
}
if let Some(VmValue::String(s)) = entry.get("mcp_server") {
return Some(s.to_string());
}
return None;
}
None
}
fn declared_mcp_tool_name_for_tool(tools_val: Option<&VmValue>, tool_name: &str) -> Option<String> {
let dict = tools_val?.as_dict()?;
let tools_list = match dict.get("tools") {
Some(VmValue::List(l)) => l,
_ => return None,
};
for tool in tools_list.iter() {
let entry: &std::collections::BTreeMap<String, VmValue> = match tool {
VmValue::Dict(d) => d,
_ => continue,
};
if entry.get("name").map(|v| v.display()).as_deref() != Some(tool_name) {
continue;
}
if let Some(VmValue::String(s)) = entry.get("_mcp_tool_name") {
return Some(s.to_string());
}
return None;
}
None
}
pub(super) fn find_tool_handler(
tools_val: Option<&VmValue>,
tool_name: &str,
) -> Option<Rc<VmClosure>> {
let dict = tools_val?.as_dict()?;
let tools_list = match dict.get("tools") {
Some(VmValue::List(l)) => l,
_ => return None,
};
for tool in tools_list.iter() {
let entry: &std::collections::BTreeMap<String, VmValue> = match tool {
VmValue::Dict(d) => d,
_ => continue,
};
let name = match entry.get("name") {
Some(v) => v.display(),
None => continue,
};
if name == tool_name {
if let Some(VmValue::Closure(c)) = entry.get("handler") {
return Some(Rc::clone(c));
}
return None;
}
}
None
}
#[cfg(test)]
mod tests {
use super::*;
use std::collections::BTreeMap;
use std::sync::atomic::AtomicBool;
use std::sync::Arc;
use tokio::sync::Mutex;
fn tools_dict(entries: Vec<(&str, BTreeMap<String, VmValue>)>) -> VmValue {
let list: Vec<VmValue> = entries
.into_iter()
.map(|(name, mut entry)| {
entry
.entry("name".to_string())
.or_insert_with(|| VmValue::String(Rc::from(name.to_string())));
VmValue::Dict(Rc::new(entry))
})
.collect();
let mut dict = BTreeMap::new();
dict.insert("tools".to_string(), VmValue::List(Rc::new(list)));
VmValue::Dict(Rc::new(dict))
}
#[test]
fn denied_tool_result_detects_rendered_blocked_json() {
let blocked = serde_json::json!({
"blocked": true,
"status": "blocked",
"reason": "policy rejected command"
});
assert!(is_denied_tool_result(&blocked));
assert!(is_denied_tool_result(&serde_json::Value::String(
blocked.to_string()
)));
assert!(!is_denied_tool_result(&serde_json::json!({
"status": "completed",
"stdout": "ok"
})));
}
#[test]
fn mcp_server_for_tool_finds_top_level_annotation() {
let mut entry = BTreeMap::new();
entry.insert(
"_mcp_server".to_string(),
VmValue::String(Rc::from("linear".to_string())),
);
let tools = tools_dict(vec![("create_issue", entry)]);
assert_eq!(
mcp_server_for_tool(Some(&tools), "create_issue"),
Some("linear".to_string())
);
}
#[test]
fn mcp_server_for_tool_finds_nested_function_annotation() {
let mut function = BTreeMap::new();
function.insert(
"name".to_string(),
VmValue::String(Rc::from("create_issue".to_string())),
);
function.insert(
"_mcp_server".to_string(),
VmValue::String(Rc::from("linear".to_string())),
);
let mut entry = BTreeMap::new();
entry.insert("function".to_string(), VmValue::Dict(Rc::new(function)));
let mut dict = BTreeMap::new();
dict.insert(
"tools".to_string(),
VmValue::List(Rc::new(vec![VmValue::Dict(Rc::new(entry))])),
);
let tools = VmValue::Dict(Rc::new(dict));
assert_eq!(
mcp_server_for_tool(Some(&tools), "create_issue"),
Some("linear".to_string())
);
}
#[test]
fn mcp_server_for_tool_returns_none_for_plain_tool() {
let tools = tools_dict(vec![("read", BTreeMap::new())]);
assert!(mcp_server_for_tool(Some(&tools), "read").is_none());
assert!(mcp_server_for_tool(Some(&tools), "missing").is_none());
assert!(mcp_server_for_tool(None, "read").is_none());
}
#[tokio::test(flavor = "current_thread")]
async fn dispatch_tags_harn_builtin_for_local_short_circuit() {
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("hello.txt");
std::fs::write(&path, "harn#691").expect("write");
let args = serde_json::json!({ "path": path.to_string_lossy() });
let outcome = dispatch_tool_execution("read_file", &args, None, None, 0, 0).await;
assert!(outcome.result.is_ok(), "got: {:?}", outcome.result);
assert_eq!(outcome.executor, Some(ToolExecutor::HarnBuiltin));
}
#[tokio::test(flavor = "current_thread")]
async fn dispatch_tags_host_bridge_when_only_bridge_can_serve() {
let bridge = crate::bridge::HostBridge::from_parts_with_writer(
Arc::new(Mutex::new(std::collections::HashMap::new())),
Arc::new(AtomicBool::new(false)),
Arc::new(|_| Err("test bridge: no host attached".to_string())),
1,
);
let bridge = Rc::new(bridge);
let args = serde_json::json!({});
let outcome =
dispatch_tool_execution("custom_host_tool", &args, None, Some(&bridge), 0, 0).await;
assert!(outcome.result.is_err());
assert_eq!(outcome.executor, Some(ToolExecutor::HostBridge));
}
#[tokio::test(flavor = "current_thread")]
async fn dispatch_tags_mcp_server_when_tool_is_mcp_owned_via_bridge() {
let bridge = crate::bridge::HostBridge::from_parts_with_writer(
Arc::new(Mutex::new(std::collections::HashMap::new())),
Arc::new(AtomicBool::new(false)),
Arc::new(|_| Err("test bridge".to_string())),
1,
);
let bridge = Rc::new(bridge);
let mut entry = BTreeMap::new();
entry.insert(
"_mcp_server".to_string(),
VmValue::String(Rc::from("linear".to_string())),
);
let tools = tools_dict(vec![("create_issue", entry)]);
let args = serde_json::json!({});
let outcome =
dispatch_tool_execution("create_issue", &args, Some(&tools), Some(&bridge), 0, 0).await;
assert_eq!(
outcome.executor,
Some(ToolExecutor::McpServer {
server_name: "linear".to_string()
})
);
}
#[tokio::test(flavor = "current_thread")]
async fn dispatch_returns_none_executor_when_no_backend_available() {
let outcome =
dispatch_tool_execution("nonexistent_tool", &serde_json::json!({}), None, None, 0, 0)
.await;
assert!(outcome.result.is_err());
assert!(outcome.executor.is_none());
}
#[tokio::test(flavor = "current_thread")]
async fn dispatch_honors_declared_host_bridge_executor() {
let bridge = crate::bridge::HostBridge::from_parts_with_writer(
Arc::new(Mutex::new(std::collections::HashMap::new())),
Arc::new(AtomicBool::new(false)),
Arc::new(|_| Err("test bridge".to_string())),
1,
);
let bridge = Rc::new(bridge);
let mut entry = BTreeMap::new();
entry.insert(
"executor".to_string(),
VmValue::String(Rc::from("host_bridge")),
);
entry.insert(
"host_capability".to_string(),
VmValue::String(Rc::from("interaction.ask")),
);
let tools = tools_dict(vec![("ask_user", entry)]);
let outcome = dispatch_tool_execution(
"ask_user",
&serde_json::json!({"prompt": "x"}),
Some(&tools),
Some(&bridge),
0,
0,
)
.await;
assert_eq!(outcome.executor, Some(ToolExecutor::HostBridge));
}
#[tokio::test(flavor = "current_thread")]
async fn dispatch_honors_declared_provider_native_executor() {
let mut entry = BTreeMap::new();
entry.insert(
"executor".to_string(),
VmValue::String(Rc::from("provider_native")),
);
let tools = tools_dict(vec![("tool_search", entry)]);
let outcome = dispatch_tool_execution(
"tool_search",
&serde_json::json!({}),
Some(&tools),
None,
0,
0,
)
.await;
assert_eq!(outcome.executor, Some(ToolExecutor::ProviderNative));
assert!(outcome.result.is_err());
}
#[tokio::test(flavor = "current_thread")]
async fn dispatch_honors_declared_mcp_server_executor() {
let bridge = crate::bridge::HostBridge::from_parts_with_writer(
Arc::new(Mutex::new(std::collections::HashMap::new())),
Arc::new(AtomicBool::new(false)),
Arc::new(|_| Err("test bridge".to_string())),
1,
);
let bridge = Rc::new(bridge);
let mut entry = BTreeMap::new();
entry.insert(
"executor".to_string(),
VmValue::String(Rc::from("mcp_server")),
);
entry.insert(
"mcp_server".to_string(),
VmValue::String(Rc::from("github")),
);
let tools = tools_dict(vec![("github_search_issues", entry)]);
let outcome = dispatch_tool_execution(
"github_search_issues",
&serde_json::json!({"query": "x"}),
Some(&tools),
Some(&bridge),
0,
0,
)
.await;
assert_eq!(
outcome.executor,
Some(ToolExecutor::McpServer {
server_name: "github".to_string()
})
);
}
}