Skip to main content

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