Skip to main content

agent_engine/tools/
extension.rs

1use std::sync::Arc;
2
3use serde_json::Value;
4
5use crate::extensions::runtime::process::RegisteredExtensionToolSpec;
6use crate::extensions::runtime::ExtensionHandler;
7use crate::{Result, RuntimeError, ToolContext};
8
9pub struct ExtensionTool {
10    runtime_name: String,
11    description: String,
12    input_schema: Value,
13    handler: Arc<dyn ExtensionHandler>,
14    tool_name: String,
15    plugin_id: String,
16}
17
18impl ExtensionTool {
19    pub fn new(
20        plugin_id: &str,
21        spec: RegisteredExtensionToolSpec,
22        handler: Arc<dyn ExtensionHandler>,
23    ) -> Self {
24        Self {
25            runtime_name: format!("{}:{}", plugin_id, spec.name),
26            description: spec.description,
27            input_schema: spec.input_schema,
28            handler,
29            tool_name: spec.name,
30            plugin_id: plugin_id.to_string(),
31        }
32    }
33}
34
35/// Who an extension tool call is made by, for `tool.call` `params.caller`:
36/// the author of the memory binding the call's context came with (an
37/// extension's forum records it as the run's author). `None` when
38/// the context has no binding: a binding made on the spot would carry an
39/// author no run has, so none is sent.
40fn caller_of(ctx: &ToolContext) -> Option<agent_core::memory::author::Author> {
41    ctx.capabilities
42        .memory_backend
43        .as_ref()
44        .map(|binding| binding.author().clone())
45}
46
47#[async_trait::async_trait]
48impl crate::tools::Tool for ExtensionTool {
49    fn name(&self) -> &str {
50        &self.runtime_name
51    }
52
53    fn description(&self) -> &str {
54        &self.description
55    }
56
57    fn parameters(&self) -> Value {
58        self.input_schema.clone()
59    }
60
61    async fn execute(&self, params: Value, ctx: ToolContext) -> Result<String> {
62        let caller = caller_of(&ctx);
63        let value = self
64            .handler
65            .call_tool_as(
66                &self.tool_name,
67                params,
68                ctx.capabilities.session_id.as_deref(),
69                ctx.capabilities.cwd.as_deref(),
70                caller.as_ref(),
71            )
72            .await
73            .map_err(RuntimeError::Tool)?;
74        if let Some(text) = value.as_str() {
75            Ok(text.to_string())
76        } else if let Some(text) = value.get("content").and_then(Value::as_str) {
77            Ok(text.to_string())
78        } else {
79            Ok(value.to_string())
80        }
81    }
82
83    fn extension_id(&self) -> Option<&str> {
84        Some(&self.plugin_id)
85    }
86
87    fn source_operation(&self) -> Option<&str> {
88        Some(&self.tool_name)
89    }
90}
91
92#[cfg(test)]
93mod tests {
94    use std::sync::atomic::{AtomicUsize, Ordering};
95
96    use serde_json::json;
97
98    use super::*;
99    use crate::extensions::hooks::events::{HookEvent, HookResult};
100    use crate::tools::test_helpers::{context_with_memory, create_tool_context, memory_binding};
101    use crate::tools::{Tool, ToolRegistry};
102
103    /// An extension process stand-in that counts the tool calls it receives.
104    #[derive(Default)]
105    struct Counting(AtomicUsize);
106
107    #[async_trait::async_trait]
108    impl ExtensionHandler for Counting {
109        fn id(&self) -> &str {
110            "fixture"
111        }
112        async fn handle(&self, _event: &HookEvent) -> HookResult {
113            HookResult::Continue
114        }
115        async fn shutdown(&self) {}
116        async fn call_tool_for(
117            &self,
118            _name: &str,
119            _input: Value,
120            _session_id: Option<&str>,
121            _cwd: Option<&std::path::Path>,
122        ) -> std::result::Result<Value, String> {
123            self.0.fetch_add(1, Ordering::SeqCst);
124            Ok(json!("extension result"))
125        }
126    }
127
128    fn tool(plugin: &str, operation: &str, handler: &Arc<Counting>) -> ExtensionTool {
129        ExtensionTool::new(
130            plugin,
131            RegisteredExtensionToolSpec {
132                name: operation.into(),
133                description: format!("{plugin} {operation}"),
134                input_schema: json!({"type": "object"}),
135            },
136            handler.clone(),
137        )
138    }
139
140    /// One call with a chat's binding and one with no binding at all.
141    async fn call(tool: &dyn Tool) -> [Result<String>; 2] {
142        let dir = tempfile::tempdir().unwrap();
143        let binding = memory_binding(dir.path());
144        [
145            tool.execute(json!({}), context_with_memory(binding)).await,
146            tool.execute(json!({}), create_tool_context()).await,
147        ]
148    }
149
150    /// T-B7. The guard that refused an extension's memory tools beside an
151    /// exclusive backend went with the backend: an extension tool runs
152    /// whatever it is named, `memory_search` and its aliases included.
153    #[tokio::test]
154    async fn extension_tools_named_like_memory_tools_run() {
155        let handler = Arc::new(Counting::default());
156        let named = [
157            ("memory", "search"),
158            ("memory", "fetch"),
159            ("memory", "store"),
160            ("memory", "forget"),
161            ("plug", "memory.search"),
162            ("Memory", "Search"),
163            ("axel", "memory_search"),
164            ("axel-memory-manager", "memory_search"),
165            ("axel-memory-manager", "memory_fetch"),
166            ("axel-memory-manager", "memory_store"),
167            ("axel-memory-manager", "memory_forget"),
168            ("weather", "search"),
169            ("memory", "summarize"),
170        ];
171        for (plugin, operation) in named {
172            let tool = tool(plugin, operation, &handler);
173            for result in call(&tool).await {
174                assert_eq!(result.unwrap(), "extension result", "{plugin}:{operation}");
175            }
176        }
177        assert_eq!(handler.0.load(Ordering::SeqCst), named.len() * 2, "every call reached the extension");
178    }
179
180    /// The suffixed wire name the model sees beside the host's own tool
181    /// reaches the extension tool, which runs.
182    #[tokio::test]
183    async fn the_suffixed_wire_name_of_an_alias_runs() {
184        let handler = Arc::new(Counting::default());
185        let registry = ToolRegistry::from_tools_for_tests(vec![
186            Arc::new(crate::tools::memory::MemorySearchTool),
187            Arc::new(tool("memory", "search", &handler)),
188        ]);
189        assert_eq!(registry.runtime_name_for_api("memory_search"), "memory_search");
190        assert_eq!(registry.runtime_name_for_api("memory_search_2"), "memory:search");
191        let alias = registry.get("memory:search").expect("alias registered");
192        for result in call(alias.as_ref()).await {
193            assert_eq!(result.unwrap(), "extension result");
194        }
195        assert_eq!(handler.0.load(Ordering::SeqCst), 2);
196    }
197}
198
199/// `tool.call` `params.caller` over the real wire: a child process that
200/// keeps every `tool.call` it is sent, called through `ExtensionTool` with
201/// the contexts a chat and its workers have.
202#[cfg(all(test, unix))]
203mod caller_tests {
204    use std::path::Path;
205    use std::time::Duration;
206
207    use agent_core::memory::author::Author;
208    use serde_json::json;
209
210    use super::*;
211    use crate::extensions::manifest::CURRENT_EXTENSION_PROTOCOL_VERSION;
212    use crate::extensions::runtime::process::ProcessExtension;
213    use crate::memory_backend::MemoryBinding;
214    use crate::tools::test_helpers::{context_with_memory, create_tool_context};
215    use crate::tools::Tool;
216
217    /// An extension process that appends the body of each `tool.call` to
218    /// `log` (one JSON-RPC request a line) and answers it `"ok"`. It reads
219    /// no param of the call: an extension that ignores `caller`.
220    async fn wire_tool(log: &Path) -> ExtensionTool {
221        let init = json!({
222            "protocol_version": CURRENT_EXTENSION_PROTOCOL_VERSION,
223            "capabilities": {}
224        });
225        let script = format!(
226            r#"while IFS= read -r header; do
227    length=${{header#Content-Length: }}
228    length=${{length%?}}
229    IFS= read -r blank || exit 1
230    body=$(dd bs=1 count="$length" 2>/dev/null) || exit 1
231    id=${{body##*\"id\":}}
232    id=${{id%%[!0-9]*}}
233    case "$body" in
234        *'"method":"initialize"'*) result='{init}' ;;
235        *'"method":"tool.call"'*) printf '%s\n' "$body" >> '{log}'; result='"ok"' ;;
236        *) result=null ;;
237    esac
238    reply='{{"jsonrpc":"2.0","id":'"$id"',"result":'"$result"'}}'
239    printf 'Content-Length: %s\r\n\r\n%s' "${{#reply}}" "$reply"
240done"#,
241            log = log.display(),
242        );
243        let process = ProcessExtension::spawn("caller-fixture", "/bin/sh", &["-c".into(), script])
244            .await
245            .expect("spawn the fixture");
246        tokio::time::timeout(Duration::from_secs(5), process.initialize_for_test(None))
247            .await
248            .expect("bounded initialize")
249            .expect("initialize the fixture");
250        ExtensionTool::new(
251            "fixture",
252            RegisteredExtensionToolSpec {
253                name: "whoami".into(),
254                description: "keeps the call".into(),
255                input_schema: json!({"type": "object"}),
256            },
257            Arc::new(process),
258        )
259    }
260
261    /// One call with `ctx`; the `params` the process was sent for it.
262    async fn params_sent(tool: &ExtensionTool, log: &Path, ctx: ToolContext) -> Value {
263        let before = sent(log).len();
264        let reply = tokio::time::timeout(Duration::from_secs(5), tool.execute(json!({}), ctx))
265            .await
266            .expect("bounded call")
267            .expect("the fixture answers");
268        assert_eq!(reply, "ok");
269        let all = sent(log);
270        assert_eq!(all.len(), before + 1, "one tool.call a call");
271        let params = all[before]["params"].clone();
272        assert_eq!(params["name"], json!("whoami"));
273        params
274    }
275
276    fn sent(log: &Path) -> Vec<Value> {
277        std::fs::read_to_string(log)
278            .unwrap_or_default()
279            .lines()
280            .map(|line| serde_json::from_str(line).expect("a tool.call body"))
281            .collect()
282    }
283
284    /// A chat's binding, for a chat that runs in `dir`.
285    fn chat_binding(dir: &Path) -> MemoryBinding {
286        MemoryBinding::for_cwd(Some(dir.to_path_buf()))
287    }
288
289    /// The author `binding` holds, as it serialises.
290    fn author_of(binding: &MemoryBinding) -> Value {
291        serde_json::to_value(binding.author()).unwrap()
292    }
293
294    /// What `tools/subagent` gives a worker runtime of a chat with `chat`:
295    /// oneshot, start and resume all go through this one function.
296    fn worker_binding(chat: &MemoryBinding) -> MemoryBinding {
297        let mut worker = crate::Runtime::new_headless();
298        crate::tools::subagent::apply_subagent_runtime_policy(
299            &mut worker,
300            &Default::default(),
301            Some(chat),
302            None,
303            None,
304            &Default::default(),
305        );
306        worker.memory_backend_for_test()
307    }
308
309    fn as_author(caller: &Value) -> Author {
310        let author: Author = serde_json::from_value(caller.clone()).expect("an Author");
311        author.validate().expect("a valid Author");
312        author
313    }
314
315    #[tokio::test]
316    async fn a_chats_tool_call_names_the_author_its_binding_holds() {
317        let dir = tempfile::tempdir().unwrap();
318        let dir = dir.path().canonicalize().unwrap();
319        let log = dir.join("calls.jsonl");
320        let tool = wire_tool(&log).await;
321        let chat = chat_binding(&dir);
322
323        let params = params_sent(&tool, &log, context_with_memory(chat.clone())).await;
324        assert_eq!(params["caller"], author_of(&chat), "the run's own author");
325
326        // As the type serialises: a chat has no `parent` key; it reads back
327        // as an `Author` and validates.
328        let caller = as_author(&params["caller"]);
329        assert_eq!(&caller, chat.author());
330        assert!(caller.parent.is_none());
331        let mut keys: Vec<&str> =
332            params["caller"].as_object().unwrap().keys().map(String::as_str).collect();
333        keys.sort_unstable();
334        assert_eq!(keys, ["actor", "group"]);
335        // The same run later: the same caller.
336        let again = params_sent(&tool, &log, context_with_memory(chat.clone())).await;
337        assert_eq!(again["caller"], params["caller"]);
338        // Another chat is another actor in another group.
339        let other = params_sent(&tool, &log, context_with_memory(chat_binding(&dir))).await;
340        let other = as_author(&other["caller"]);
341        assert_ne!(other.actor, caller.actor);
342        assert_ne!(other.group, caller.group);
343    }
344
345    #[tokio::test]
346    async fn a_workers_tool_call_names_its_own_actor_in_the_chats_group() {
347        let dir = tempfile::tempdir().unwrap();
348        let dir = dir.path().canonicalize().unwrap();
349        let log = dir.join("calls.jsonl");
350        let tool = wire_tool(&log).await;
351        let chat = chat_binding(&dir);
352        let (first, second) = (worker_binding(&chat), worker_binding(&chat));
353
354        let of_chat = params_sent(&tool, &log, context_with_memory(chat.clone())).await;
355        let of_chat = as_author(&of_chat["caller"]);
356        let mut actors = vec![of_chat.actor.clone()];
357        for worker in [&first, &second] {
358            let params = params_sent(&tool, &log, context_with_memory(worker.clone())).await;
359            assert_eq!(
360                params["caller"],
361                author_of(worker),
362                "a worker's call names the worker's own author"
363            );
364            let caller = as_author(&params["caller"]);
365            assert_eq!(caller.group, of_chat.group);
366            assert_eq!(caller.parent.as_deref(), Some(of_chat.actor.as_str()));
367            assert!(!actors.contains(&caller.actor), "each run its own actor");
368            actors.push(caller.actor);
369        }
370        // The chat's own author is what it was before it had workers.
371        let after = params_sent(&tool, &log, context_with_memory(chat.clone())).await;
372        assert_eq!(as_author(&after["caller"]), of_chat);
373    }
374
375    /// No binding in the context: no `caller` key at all, and the call is
376    /// otherwise the call it was.
377    #[tokio::test]
378    async fn a_call_with_no_binding_has_no_caller_key() {
379        let dir = tempfile::tempdir().unwrap();
380        let dir = dir.path().canonicalize().unwrap();
381        let log = dir.join("calls.jsonl");
382        let tool = wire_tool(&log).await;
383        let ctx = create_tool_context();
384        assert!(ctx.capabilities.memory_backend.is_none());
385        let params = params_sent(&tool, &log, ctx).await;
386        let mut keys: Vec<&str> = params.as_object().unwrap().keys().map(String::as_str).collect();
387        keys.sort_unstable();
388        assert_eq!(keys, ["cwd", "input", "name", "session_id"]);
389    }
390}