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