agentd/tools.rs
1// SPDX-License-Identifier: AGPL-3.0-only
2//! CODE-REGISTERED tools — the embedder seam.
3//!
4//! An embedder building its own binary on the `agentd-core` library can
5//! register **native Rust tools** the agent calls alongside MCP tools:
6//!
7//! ```no_run
8//! agentd::tools::register(agentd::tools::CodeTool::new(
9//! "shout",
10//! "Uppercase the input text.",
11//! serde_json::json!({"type": "object", "properties": {"text": {"type": "string"}},
12//! "required": ["text"]}),
13//! |args| {
14//! let text = args.get("text").and_then(serde_json::Value::as_str).unwrap_or("");
15//! Ok(serde_json::json!({ "text": text.to_uppercase() }))
16//! },
17//! ))
18//! .expect("unique tool name");
19//! ```
20//!
21//! Design constraints (binding):
22//!
23//! - **The registry is process-global and must be populated in `main`, BEFORE
24//! the subagent dispatch.** Subagents re-exec `current_exe()`; the child runs
25//! the embedder's `main` again, which re-registers the same tools — that is
26//! how a tool registered "by code" is visible in every process of the tree
27//! (the exact pattern the stock CLI uses for nothing, preserving its
28//! no-local-code posture: agentd-cli registers zero tools, so this registry
29//! is empty in every stock binary).
30//! - **Dispatch priority is self-tools → code tools → MCP.** A registered tool
31//! cannot shadow agentd's own orchestration primitives
32//! ([`SELF_CONTROL_TOOLS`](crate::agentloop::action::SELF_CONTROL_TOOLS) —
33//! registration refuses those names), and a remote MCP server cannot steal a
34//! code tool's calls by publishing a colliding name (the code tool wins the
35//! catalogue slot and the dispatch).
36//! - **Workflows address code tools as the reserved server name `code`**
37//! (`{"kind": "tool", "server": "code", "tool": "shout", …}`); config
38//! validation refuses an `--mcp` server named `code`.
39//! - Handlers are `Fn(&Value) -> Result<Value, String> + Send + Sync`: they may
40//! be called from the agent loop, from workflow `tool` nodes, and from
41//! parallel foreach/parallel lanes (threads of the same process)
42//! concurrently. Keep them reentrant; hold no lock across a call into agentd.
43//! - **Trust:** a code tool is the embedder's own compiled code — it is
44//! first-party by definition, like the binary itself. It sits OUTSIDE the
45//! `--mcp-tags` trifecta accounting; an embedder whose tool does egress or
46//! touches secrets owns that risk the way it owns the rest of its binary.
47
48use crate::wire::intel::ToolDef;
49use serde_json::Value;
50use std::collections::BTreeMap;
51use std::sync::{Arc, RwLock};
52
53/// The handler signature: JSON arguments in, JSON result (or a refusal string)
54/// out. `Err` takes the tool-error path (the model sees it as a failed call;
55/// a workflow `tool` node takes its `error` edge).
56pub type CodeToolFn = dyn Fn(&Value) -> Result<Value, String> + Send + Sync;
57
58/// One registered native tool: an MCP-shaped definition (name + description +
59/// input JSON Schema) plus the Rust handler.
60#[derive(Clone)]
61pub struct CodeTool {
62 name: String,
63 description: String,
64 input_schema: Value,
65 handler: Arc<CodeToolFn>,
66}
67
68impl CodeTool {
69 /// Build a tool. `input_schema` is the MCP `inputSchema` the model sees —
70 /// give it real properties; the model routes on schemas.
71 pub fn new(
72 name: impl Into<String>,
73 description: impl Into<String>,
74 input_schema: Value,
75 handler: impl Fn(&Value) -> Result<Value, String> + Send + Sync + 'static,
76 ) -> CodeTool {
77 CodeTool {
78 name: name.into(),
79 description: description.into(),
80 input_schema,
81 handler: Arc::new(handler),
82 }
83 }
84
85 fn def(&self) -> ToolDef {
86 ToolDef {
87 name: self.name.clone(),
88 description: self.description.clone(),
89 input_schema: self.input_schema.clone(),
90 }
91 }
92}
93
94fn registry() -> &'static RwLock<BTreeMap<String, CodeTool>> {
95 static REG: std::sync::OnceLock<RwLock<BTreeMap<String, CodeTool>>> =
96 std::sync::OnceLock::new();
97 REG.get_or_init(|| RwLock::new(BTreeMap::new()))
98}
99
100/// Register a tool. Refuses (Err) an empty name, a duplicate, or a name that
101/// collides with agentd's own self/control primitives — a code tool may shadow
102/// a remote MCP tool (first-party wins) but never the orchestration surface.
103pub fn register(tool: CodeTool) -> Result<(), String> {
104 if tool.name.trim().is_empty() {
105 return Err("code tool name must be non-empty".into());
106 }
107 if crate::agentloop::action::SELF_CONTROL_TOOLS.contains(&tool.name.as_str()) {
108 return Err(format!(
109 "code tool {:?} collides with an agentd self/control primitive",
110 tool.name
111 ));
112 }
113 let mut reg = registry().write().unwrap_or_else(|e| e.into_inner());
114 if reg.contains_key(&tool.name) {
115 return Err(format!("code tool {:?} is already registered", tool.name));
116 }
117 reg.insert(tool.name.clone(), tool);
118 Ok(())
119}
120
121/// Remove a registered tool (dynamic embedders). Returns whether it existed.
122/// Prefer registering once in `main` — see the module doc's re-exec rule.
123pub fn unregister(name: &str) -> bool {
124 registry()
125 .write()
126 .unwrap_or_else(|e| e.into_inner())
127 .remove(name)
128 .is_some()
129}
130
131/// How many tools are registered (the capabilities manifest surfaces this).
132pub fn count() -> usize {
133 registry().read().unwrap_or_else(|e| e.into_inner()).len()
134}
135
136/// Whether `name` is a registered code tool (the `ToolClass::Code` predicate).
137pub(crate) fn is_registered(name: &str) -> bool {
138 registry()
139 .read()
140 .unwrap_or_else(|e| e.into_inner())
141 .contains_key(name)
142}
143
144/// The catalogue entries for every registered tool (deterministic order).
145pub(crate) fn defs() -> Vec<ToolDef> {
146 registry()
147 .read()
148 .unwrap_or_else(|e| e.into_inner())
149 .values()
150 .map(CodeTool::def)
151 .collect()
152}
153
154/// Dispatch a loop tool call: `None` = not a code tool (fall through to MCP);
155/// `Some((content, is_error))` matches the self-handler convention. The handler
156/// runs OUTSIDE the registry lock, so a handler may itself consult the registry
157/// (and a slow tool never blocks registration reads elsewhere).
158pub(crate) fn dispatch(name: &str, args: &Value) -> Option<(String, bool)> {
159 let handler = {
160 let reg = registry().read().unwrap_or_else(|e| e.into_inner());
161 Arc::clone(®.get(name)?.handler)
162 };
163 Some(match handler(args) {
164 Ok(v) => (
165 match v {
166 Value::String(s) => s,
167 other => other.to_string(),
168 },
169 false,
170 ),
171 Err(e) => (e, true),
172 })
173}
174
175/// Call a registered code tool directly — the PUBLIC entry the runtime's tool
176/// executor (`registry` route `code`) and an embedder's dispatcher use. `None` =
177/// unregistered; `Some(Ok(v))` / `Some(Err(reason))` mirror the handler. The
178/// handler runs outside the registry lock.
179pub fn call(name: &str, args: &Value) -> Option<Result<Value, String>> {
180 let handler = {
181 let reg = registry().read().unwrap_or_else(|e| e.into_inner());
182 Arc::clone(®.get(name)?.handler)
183 };
184 Some(handler(args))
185}
186
187/// Serialize tests that mutate OR observe the process-global registry. Unit
188/// tests share a process and run in parallel, so a transient register/unregister
189/// in one test can otherwise perturb a catalogue-size assertion in another (a
190/// real cross-test race, seen intermittently under `--all-features`). Any test
191/// that registers/unregisters, or that asserts an exact tool-catalogue count,
192/// holds this for its duration. Poison-tolerant (a panicking test still frees it).
193#[cfg(test)]
194pub(crate) fn test_registry_guard() -> std::sync::MutexGuard<'static, ()> {
195 static LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
196 LOCK.lock().unwrap_or_else(|e| e.into_inner())
197}
198
199#[cfg(test)]
200mod tests {
201 use super::*;
202
203 /// A `(value, is_error)` shim over [`call`]: an unknown tool name and a
204 /// handler `Err` both surface as `(message, true)`, which is the shape a
205 /// workflow `tool` node's error edge sees.
206 fn call_for_workflow(name: &str, args: &Value) -> (Value, bool) {
207 match call(name, args) {
208 None => (Value::String(format!("no such code tool {name:?}")), true),
209 Some(Ok(v)) => (v, false),
210 Some(Err(e)) => (Value::String(e), true),
211 }
212 }
213 use serde_json::json;
214
215 // NOTE: the registry is process-global and unit tests share a process —
216 // every test uses UNIQUE tool names, cleans up after itself, and holds
217 // `test_registry_guard()` so it never races a catalogue-count assertion.
218
219 #[test]
220 fn register_dispatch_and_unregister_round_trip() {
221 let _guard = super::test_registry_guard();
222 register(CodeTool::new(
223 "t.echo",
224 "echo",
225 json!({"type": "object"}),
226 |args| Ok(json!({ "got": args.clone() })),
227 ))
228 .expect("fresh name registers");
229 assert!(is_registered("t.echo"));
230 assert!(count() >= 1);
231 assert_eq!(defs().iter().filter(|d| d.name == "t.echo").count(), 1);
232
233 let (content, is_err) = dispatch("t.echo", &json!({"x": 1})).expect("registered");
234 assert!(!is_err);
235 assert!(content.contains("\"x\":1"), "{content}");
236
237 let (v, e) = call_for_workflow("t.echo", &json!({"y": 2}));
238 assert!(!e);
239 assert_eq!(v["got"]["y"], json!(2));
240
241 assert!(unregister("t.echo"));
242 assert!(
243 dispatch("t.echo", &json!({})).is_none(),
244 "gone after unregister"
245 );
246 let (_, e) = call_for_workflow("t.echo", &json!({}));
247 assert!(
248 e,
249 "workflow call of an unregistered tool is an error result"
250 );
251 }
252
253 #[test]
254 fn registration_refuses_duplicates_empties_and_self_tool_names() {
255 let _guard = super::test_registry_guard();
256 register(CodeTool::new("t.dup", "", json!({}), |_| Ok(json!(1)))).unwrap();
257 assert!(register(CodeTool::new("t.dup", "", json!({}), |_| Ok(json!(2)))).is_err());
258 assert!(register(CodeTool::new(" ", "", json!({}), |_| Ok(json!(1)))).is_err());
259 assert!(
260 register(CodeTool::new("subagent.spawn", "", json!({}), |_| Ok(
261 json!(1)
262 )))
263 .is_err(),
264 "self/control primitives are unshadowable"
265 );
266 assert!(unregister("t.dup"));
267 }
268
269 #[test]
270 fn a_handler_error_is_a_tool_error_not_a_panic() {
271 let _guard = super::test_registry_guard();
272 register(CodeTool::new("t.fail", "", json!({}), |_| {
273 Err("deliberate".into())
274 }))
275 .unwrap();
276 let (content, is_err) = dispatch("t.fail", &json!({})).unwrap();
277 assert!(is_err);
278 assert_eq!(content, "deliberate");
279 let (v, e) = call_for_workflow("t.fail", &json!({}));
280 assert!(e);
281 assert_eq!(v, json!("deliberate"));
282 assert!(unregister("t.fail"));
283 }
284}