use std::sync::Arc;
use serde_json::Value;
use crate::extensions::runtime::process::RegisteredExtensionToolSpec;
use crate::extensions::runtime::ExtensionHandler;
use crate::{Result, RuntimeError, ToolContext};
pub struct ExtensionTool {
runtime_name: String,
description: String,
input_schema: Value,
handler: Arc<dyn ExtensionHandler>,
tool_name: String,
plugin_id: String,
}
impl ExtensionTool {
pub fn new(
plugin_id: &str,
spec: RegisteredExtensionToolSpec,
handler: Arc<dyn ExtensionHandler>,
) -> Self {
Self {
runtime_name: format!("{}:{}", plugin_id, spec.name),
description: spec.description,
input_schema: spec.input_schema,
handler,
tool_name: spec.name,
plugin_id: plugin_id.to_string(),
}
}
}
fn caller_of(ctx: &ToolContext) -> Option<agent_core::memory::author::Author> {
ctx.capabilities
.memory_backend
.as_ref()
.map(|binding| binding.author().clone())
}
#[async_trait::async_trait]
impl crate::tools::Tool for ExtensionTool {
fn name(&self) -> &str {
&self.runtime_name
}
fn description(&self) -> &str {
&self.description
}
fn parameters(&self) -> Value {
self.input_schema.clone()
}
async fn execute(&self, params: Value, ctx: ToolContext) -> Result<String> {
let caller = caller_of(&ctx);
let value = self
.handler
.call_tool_as(
&self.tool_name,
params,
ctx.capabilities.session_id.as_deref(),
ctx.capabilities.cwd.as_deref(),
caller.as_ref(),
)
.await
.map_err(RuntimeError::Tool)?;
if let Some(text) = value.as_str() {
Ok(text.to_string())
} else if let Some(text) = value.get("content").and_then(Value::as_str) {
Ok(text.to_string())
} else {
Ok(value.to_string())
}
}
fn extension_id(&self) -> Option<&str> {
Some(&self.plugin_id)
}
fn source_operation(&self) -> Option<&str> {
Some(&self.tool_name)
}
}
#[cfg(test)]
mod tests {
use std::sync::atomic::{AtomicUsize, Ordering};
use serde_json::json;
use super::*;
use crate::extensions::hooks::events::{HookEvent, HookResult};
use crate::tools::test_helpers::{context_with_memory, create_tool_context, memory_binding};
use crate::tools::{Tool, ToolRegistry};
#[derive(Default)]
struct Counting(AtomicUsize);
#[async_trait::async_trait]
impl ExtensionHandler for Counting {
fn id(&self) -> &str {
"fixture"
}
async fn handle(&self, _event: &HookEvent) -> HookResult {
HookResult::Continue
}
async fn shutdown(&self) {}
async fn call_tool_for(
&self,
_name: &str,
_input: Value,
_session_id: Option<&str>,
_cwd: Option<&std::path::Path>,
) -> std::result::Result<Value, String> {
self.0.fetch_add(1, Ordering::SeqCst);
Ok(json!("extension result"))
}
}
fn tool(plugin: &str, operation: &str, handler: &Arc<Counting>) -> ExtensionTool {
ExtensionTool::new(
plugin,
RegisteredExtensionToolSpec {
name: operation.into(),
description: format!("{plugin} {operation}"),
input_schema: json!({"type": "object"}),
},
handler.clone(),
)
}
async fn call(tool: &dyn Tool) -> [Result<String>; 2] {
let dir = tempfile::tempdir().unwrap();
let binding = memory_binding(dir.path());
[
tool.execute(json!({}), context_with_memory(binding)).await,
tool.execute(json!({}), create_tool_context()).await,
]
}
#[tokio::test]
async fn extension_tools_named_like_memory_tools_run() {
let handler = Arc::new(Counting::default());
let named = [
("memory", "search"),
("memory", "fetch"),
("memory", "store"),
("memory", "forget"),
("plug", "memory.search"),
("Memory", "Search"),
("axel", "memory_search"),
("axel-memory-manager", "memory_search"),
("axel-memory-manager", "memory_fetch"),
("axel-memory-manager", "memory_store"),
("axel-memory-manager", "memory_forget"),
("weather", "search"),
("memory", "summarize"),
];
for (plugin, operation) in named {
let tool = tool(plugin, operation, &handler);
for result in call(&tool).await {
assert_eq!(result.unwrap(), "extension result", "{plugin}:{operation}");
}
}
assert_eq!(handler.0.load(Ordering::SeqCst), named.len() * 2, "every call reached the extension");
}
#[tokio::test]
async fn the_suffixed_wire_name_of_an_alias_runs() {
let handler = Arc::new(Counting::default());
let registry = ToolRegistry::from_tools_for_tests(vec![
Arc::new(crate::tools::memory::MemorySearchTool),
Arc::new(tool("memory", "search", &handler)),
]);
assert_eq!(registry.runtime_name_for_api("memory_search"), "memory_search");
assert_eq!(registry.runtime_name_for_api("memory_search_2"), "memory:search");
let alias = registry.get("memory:search").expect("alias registered");
for result in call(alias.as_ref()).await {
assert_eq!(result.unwrap(), "extension result");
}
assert_eq!(handler.0.load(Ordering::SeqCst), 2);
}
}
#[cfg(all(test, unix))]
mod caller_tests {
use std::path::Path;
use std::time::Duration;
use agent_core::memory::author::Author;
use serde_json::json;
use super::*;
use crate::extensions::manifest::CURRENT_EXTENSION_PROTOCOL_VERSION;
use crate::extensions::runtime::process::ProcessExtension;
use crate::memory_backend::MemoryBinding;
use crate::tools::test_helpers::{context_with_memory, create_tool_context};
use crate::tools::Tool;
async fn wire_tool(log: &Path) -> ExtensionTool {
let init = json!({
"protocol_version": CURRENT_EXTENSION_PROTOCOL_VERSION,
"capabilities": {}
});
let script = format!(
r#"while IFS= read -r header; do
length=${{header#Content-Length: }}
length=${{length%?}}
IFS= read -r blank || exit 1
body=$(dd bs=1 count="$length" 2>/dev/null) || exit 1
id=${{body##*\"id\":}}
id=${{id%%[!0-9]*}}
case "$body" in
*'"method":"initialize"'*) result='{init}' ;;
*'"method":"tool.call"'*) printf '%s\n' "$body" >> '{log}'; result='"ok"' ;;
*) result=null ;;
esac
reply='{{"jsonrpc":"2.0","id":'"$id"',"result":'"$result"'}}'
printf 'Content-Length: %s\r\n\r\n%s' "${{#reply}}" "$reply"
done"#,
log = log.display(),
);
let process = ProcessExtension::spawn("caller-fixture", "/bin/sh", &["-c".into(), script])
.await
.expect("spawn the fixture");
tokio::time::timeout(Duration::from_secs(5), process.initialize_for_test(None))
.await
.expect("bounded initialize")
.expect("initialize the fixture");
ExtensionTool::new(
"fixture",
RegisteredExtensionToolSpec {
name: "whoami".into(),
description: "keeps the call".into(),
input_schema: json!({"type": "object"}),
},
Arc::new(process),
)
}
async fn params_sent(tool: &ExtensionTool, log: &Path, ctx: ToolContext) -> Value {
let before = sent(log).len();
let reply = tokio::time::timeout(Duration::from_secs(5), tool.execute(json!({}), ctx))
.await
.expect("bounded call")
.expect("the fixture answers");
assert_eq!(reply, "ok");
let all = sent(log);
assert_eq!(all.len(), before + 1, "one tool.call a call");
let params = all[before]["params"].clone();
assert_eq!(params["name"], json!("whoami"));
params
}
fn sent(log: &Path) -> Vec<Value> {
std::fs::read_to_string(log)
.unwrap_or_default()
.lines()
.map(|line| serde_json::from_str(line).expect("a tool.call body"))
.collect()
}
fn chat_binding(dir: &Path) -> MemoryBinding {
MemoryBinding::for_cwd(Some(dir.to_path_buf()))
}
fn author_of(binding: &MemoryBinding) -> Value {
serde_json::to_value(binding.author()).unwrap()
}
fn worker_binding(chat: &MemoryBinding) -> MemoryBinding {
let mut worker = crate::Runtime::new_headless();
crate::tools::subagent::apply_subagent_runtime_policy(
&mut worker,
&Default::default(),
Some(chat),
None,
None,
&Default::default(),
);
worker.memory_backend_for_test()
}
fn as_author(caller: &Value) -> Author {
let author: Author = serde_json::from_value(caller.clone()).expect("an Author");
author.validate().expect("a valid Author");
author
}
#[tokio::test]
async fn a_chats_tool_call_names_the_author_its_binding_holds() {
let dir = tempfile::tempdir().unwrap();
let dir = dir.path().canonicalize().unwrap();
let log = dir.join("calls.jsonl");
let tool = wire_tool(&log).await;
let chat = chat_binding(&dir);
let params = params_sent(&tool, &log, context_with_memory(chat.clone())).await;
assert_eq!(params["caller"], author_of(&chat), "the run's own author");
let caller = as_author(¶ms["caller"]);
assert_eq!(&caller, chat.author());
assert!(caller.parent.is_none());
let mut keys: Vec<&str> =
params["caller"].as_object().unwrap().keys().map(String::as_str).collect();
keys.sort_unstable();
assert_eq!(keys, ["actor", "group"]);
let again = params_sent(&tool, &log, context_with_memory(chat.clone())).await;
assert_eq!(again["caller"], params["caller"]);
let other = params_sent(&tool, &log, context_with_memory(chat_binding(&dir))).await;
let other = as_author(&other["caller"]);
assert_ne!(other.actor, caller.actor);
assert_ne!(other.group, caller.group);
}
#[tokio::test]
async fn a_workers_tool_call_names_its_own_actor_in_the_chats_group() {
let dir = tempfile::tempdir().unwrap();
let dir = dir.path().canonicalize().unwrap();
let log = dir.join("calls.jsonl");
let tool = wire_tool(&log).await;
let chat = chat_binding(&dir);
let (first, second) = (worker_binding(&chat), worker_binding(&chat));
let of_chat = params_sent(&tool, &log, context_with_memory(chat.clone())).await;
let of_chat = as_author(&of_chat["caller"]);
let mut actors = vec![of_chat.actor.clone()];
for worker in [&first, &second] {
let params = params_sent(&tool, &log, context_with_memory(worker.clone())).await;
assert_eq!(
params["caller"],
author_of(worker),
"a worker's call names the worker's own author"
);
let caller = as_author(¶ms["caller"]);
assert_eq!(caller.group, of_chat.group);
assert_eq!(caller.parent.as_deref(), Some(of_chat.actor.as_str()));
assert!(!actors.contains(&caller.actor), "each run its own actor");
actors.push(caller.actor);
}
let after = params_sent(&tool, &log, context_with_memory(chat.clone())).await;
assert_eq!(as_author(&after["caller"]), of_chat);
}
#[tokio::test]
async fn a_call_with_no_binding_has_no_caller_key() {
let dir = tempfile::tempdir().unwrap();
let dir = dir.path().canonicalize().unwrap();
let log = dir.join("calls.jsonl");
let tool = wire_tool(&log).await;
let ctx = create_tool_context();
assert!(ctx.capabilities.memory_backend.is_none());
let params = params_sent(&tool, &log, ctx).await;
let mut keys: Vec<&str> = params.as_object().unwrap().keys().map(String::as_str).collect();
keys.sort_unstable();
assert_eq!(keys, ["cwd", "input", "name", "session_id"]);
}
}