Skip to main content

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(&reg.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(&reg.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}