Skip to main content

behest_tool/
lib.rs

1//! Tool trait, registry, and execution strategies for the behest agent runtime.
2//!
3//! # Key types
4//!
5//! - [`Tool`] trait: the fundamental abstraction for callable capabilities
6//! - [`ToolRegistry`]: thread-safe dynamic registration and dispatch
7//! - [`ToolExecutionStrategy`]: controls serial vs parallel execution
8//! - [`FunctionTool`]: closure-based tool with metadata
9//! - [`ToolOutput`]: wrapped JSON output from tool execution
10//!
11//! # ToolExecutionStrategy
12//!
13//! - **Sequential**: strict one-at-a-time execution
14//! - **Parallel**: concurrent execution with a max concurrency cap
15//! - **Auto**: intelligently groups read-only/concurrency-safe tools for
16//!   parallel execution while serializing stateful tools
17
18#![forbid(unsafe_code)]
19#![deny(missing_docs)]
20#![deny(unreachable_pub)]
21
22use std::sync::{Arc, RwLockReadGuard, RwLockWriteGuard};
23
24use async_trait::async_trait;
25use behest_context::ToolContext;
26use behest_core::tool_types::{ToolCall, ToolSpec};
27use serde::{Deserialize, Serialize};
28use serde_json::Value;
29
30mod strategy;
31pub use strategy::{ExecutionPlan, ToolExecutionStrategy};
32
33/// Canonical tool error type. Aliased here so that downstream callers
34/// can write `behest_tool::ToolError` without taking a direct
35/// dependency on `behest_core`.
36pub use behest_core::error::ToolError;
37
38/// Alias for a tool execution result.
39pub type ToolResult<T = ToolOutput> = std::result::Result<T, ToolError>;
40
41/// The output of a tool execution, wrapping a JSON value.
42#[derive(Debug, Clone, Serialize, Deserialize)]
43pub struct ToolOutput {
44    /// The tool's output as a JSON value.
45    pub value: Value,
46}
47
48impl ToolOutput {
49    /// Creates a tool output from a JSON value.
50    #[must_use]
51    pub fn new(value: Value) -> Self {
52        Self { value }
53    }
54
55    /// Creates a tool output from a text string.
56    #[must_use]
57    pub fn text(text: impl Into<String>) -> Self {
58        Self {
59            value: Value::String(text.into()),
60        }
61    }
62
63    /// Creates a tool output representing an error.
64    #[must_use]
65    pub fn error(message: impl Into<String>) -> Self {
66        Self {
67            value: serde_json::json!({"error": message.into()}),
68        }
69    }
70}
71
72/// Side effects a tool may produce.
73///
74/// Used by [`ToolExecutionStrategy::Auto`] to determine safe concurrency.
75#[derive(Debug, Clone, Copy, Default)]
76pub struct SideEffects {
77    /// Tool performs read operations (safe to run concurrently).
78    pub read: bool,
79    /// Tool performs write operations (requires serialization).
80    pub write: bool,
81    /// Tool makes external API calls.
82    pub external: bool,
83    /// Tool may delete or destroy resources.
84    pub destructive: bool,
85}
86
87impl SideEffects {
88    /// Returns `true` if the tool is safe to run concurrently with other tools.
89    #[must_use]
90    pub const fn is_concurrency_safe(&self) -> bool {
91        !self.write && !self.destructive
92    }
93}
94
95/// An executable tool that can be called by an LLM.
96///
97/// Implementations must provide at minimum: `name()`, `description()`,
98/// `parameters_schema()`, and `execute()`.
99#[async_trait]
100pub trait Tool: Send + Sync {
101    /// Returns the tool's name (visible to the model).
102    fn name(&self) -> &str;
103
104    /// Returns a human-readable description of the tool.
105    fn description(&self) -> &str;
106
107    /// Returns the JSON Schema for the tool's parameters.
108    fn parameters_schema(&self) -> Value;
109
110    /// Returns the JSON Schema for the tool's response (if any).
111    fn response_schema(&self) -> Option<Value> {
112        None
113    }
114
115    /// Executes the tool with the given arguments.
116    async fn execute(&self, args: Value) -> ToolResult<ToolOutput>;
117
118    /// Executes the tool with the given [`ToolContext`] and arguments.
119    ///
120    /// Defaults to calling [`Self::execute`] without context. Override
121    /// to consume the context (e.g., for memory / cancellation / sink).
122    async fn execute_with_ctx(
123        &self,
124        _ctx: &dyn ToolContext,
125        args: Value,
126    ) -> ToolResult<ToolOutput> {
127        self.execute(args).await
128    }
129
130    /// Returns `true` if the tool is read-only (no side effects).
131    fn is_read_only(&self) -> bool {
132        false
133    }
134
135    /// Returns `true` if the tool is safe to run concurrently.
136    fn is_concurrency_safe(&self) -> bool {
137        false
138    }
139
140    /// Returns `true` if the tool is expected to run for a long time.
141    fn is_long_running(&self) -> bool {
142        false
143    }
144
145    /// Returns the side effects the tool may produce.
146    fn side_effects(&self) -> SideEffects {
147        SideEffects::default()
148    }
149
150    /// Returns `true` if the tool requires human approval before execution.
151    fn requires_approval(&self) -> bool {
152        false
153    }
154
155    /// Returns the reason approval is needed, if applicable.
156    fn approval_reason(&self) -> Option<String> {
157        None
158    }
159
160    /// Returns the scopes required to use this tool.
161    fn required_scopes(&self) -> &[&str] {
162        &[]
163    }
164
165    /// Produces a [`ToolSpec`] for this tool.
166    #[must_use]
167    fn to_spec(&self) -> ToolSpec {
168        ToolSpec::new(self.name(), self.description(), self.parameters_schema())
169    }
170}
171
172/// Type alias for the async handler function used by [`FunctionTool`].
173///
174/// The handler returns a JSON [`Value`] (not a [`ToolOutput`]); the
175/// wrapper internally converts it to a [`ToolOutput`]. This matches
176/// the long-standing convention from the facade's `FunctionTool::new`.
177pub type ToolHandler = dyn Fn(Value) -> std::pin::Pin<Box<dyn std::future::Future<Output = ToolResult<Value>> + Send>>
178    + Send
179    + Sync;
180
181/// A closure-based tool.
182pub struct FunctionTool {
183    name: String,
184    description: String,
185    parameters_schema: Value,
186    read_only: bool,
187    concurrency_safe: bool,
188    long_running: bool,
189    side_effects: SideEffects,
190    approval_required: bool,
191    approval_reason: Option<String>,
192    handler: Box<ToolHandler>,
193}
194
195impl std::fmt::Debug for FunctionTool {
196    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
197        f.debug_struct("FunctionTool")
198            .field("name", &self.name)
199            .field("read_only", &self.read_only)
200            .finish()
201    }
202}
203
204impl FunctionTool {
205    /// Creates a new function-based tool.
206    ///
207    /// The handler is expected to return a JSON [`Value`]; the
208    /// implementation wraps it in a [`ToolOutput`].
209    #[must_use]
210    pub fn new<F, Fut>(
211        name: impl Into<String>,
212        description: impl Into<String>,
213        parameters_schema: Value,
214        handler: F,
215    ) -> Self
216    where
217        F: Fn(Value) -> Fut + Send + Sync + 'static,
218        Fut: std::future::Future<Output = ToolResult<Value>> + Send + 'static,
219    {
220        Self {
221            name: name.into(),
222            description: description.into(),
223            parameters_schema,
224            read_only: false,
225            concurrency_safe: false,
226            long_running: false,
227            side_effects: SideEffects::default(),
228            approval_required: false,
229            approval_reason: None,
230            handler: Box::new(move |args| Box::pin(handler(args))),
231        }
232    }
233
234    /// Marks the tool as read-only.
235    #[must_use]
236    pub fn read_only(mut self) -> Self {
237        self.read_only = true;
238        self.concurrency_safe = true;
239        self.side_effects.read = true;
240        self
241    }
242
243    /// Marks the tool as safe for concurrent execution.
244    #[must_use]
245    pub fn concurrency_safe(mut self) -> Self {
246        self.concurrency_safe = true;
247        self
248    }
249
250    /// Marks the tool as long-running.
251    #[must_use]
252    pub fn long_running(mut self) -> Self {
253        self.long_running = true;
254        self
255    }
256
257    /// Sets the side effects for this tool.
258    #[must_use]
259    pub fn side_effects(mut self, effects: SideEffects) -> Self {
260        self.side_effects = effects;
261        self
262    }
263
264    /// Marks the tool as requiring human approval.
265    #[must_use]
266    pub fn requires_approval(mut self, reason: impl Into<String>) -> Self {
267        self.approval_required = true;
268        self.approval_reason = Some(reason.into());
269        self
270    }
271}
272
273#[async_trait]
274impl Tool for FunctionTool {
275    fn name(&self) -> &str {
276        &self.name
277    }
278
279    fn description(&self) -> &str {
280        &self.description
281    }
282
283    fn parameters_schema(&self) -> Value {
284        self.parameters_schema.clone()
285    }
286
287    async fn execute(&self, args: Value) -> ToolResult<ToolOutput> {
288        let value = (self.handler)(args).await?;
289        Ok(ToolOutput::new(value))
290    }
291
292    fn is_read_only(&self) -> bool {
293        self.read_only
294    }
295
296    fn is_concurrency_safe(&self) -> bool {
297        self.concurrency_safe
298    }
299
300    fn is_long_running(&self) -> bool {
301        self.long_running
302    }
303
304    fn side_effects(&self) -> SideEffects {
305        self.side_effects
306    }
307
308    fn requires_approval(&self) -> bool {
309        self.approval_required
310    }
311
312    fn approval_reason(&self) -> Option<String> {
313        self.approval_reason.clone()
314    }
315}
316
317/// A thread-safe registry of tools.
318pub struct ToolRegistry {
319    tools: std::sync::RwLock<std::collections::HashMap<String, Arc<dyn Tool>>>,
320}
321
322impl std::fmt::Debug for ToolRegistry {
323    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
324        let names = self.names();
325        f.debug_struct("ToolRegistry")
326            .field("tools", &names)
327            .finish()
328    }
329}
330
331impl Default for ToolRegistry {
332    fn default() -> Self {
333        Self::new()
334    }
335}
336
337impl Clone for ToolRegistry {
338    fn clone(&self) -> Self {
339        // `RwLock` doesn't implement `Clone`, but the inner map is
340        // cheaply cloneable. We take a read lock to snapshot.
341        let map = self.read_tools().clone();
342        Self {
343            tools: std::sync::RwLock::new(map),
344        }
345    }
346}
347
348impl ToolRegistry {
349    /// Creates an empty tool registry.
350    #[must_use]
351    pub fn new() -> Self {
352        Self {
353            tools: std::sync::RwLock::new(std::collections::HashMap::new()),
354        }
355    }
356
357    /// Registers a tool, returning any previous tool with the same name.
358    pub fn register<T: Tool + 'static>(&self, tool: T) -> Option<Arc<dyn Tool>> {
359        self.write_tools()
360            .insert(tool.name().to_string(), Arc::new(tool))
361    }
362
363    /// Registers an already-shared tool, returning any previous tool
364    /// with the same name.
365    pub fn register_arc(&self, tool: Arc<dyn Tool>) -> Option<Arc<dyn Tool>> {
366        self.write_tools().insert(tool.name().to_string(), tool)
367    }
368
369    /// Unregisters a tool by name.
370    pub fn unregister(&self, name: &str) -> Option<Arc<dyn Tool>> {
371        self.write_tools().remove(name)
372    }
373
374    /// Gets a tool by name.
375    #[must_use]
376    pub fn get(&self, name: &str) -> Option<Arc<dyn Tool>> {
377        self.read_tools().get(name).cloned()
378    }
379
380    /// Returns all registered tool names.
381    #[must_use]
382    pub fn names(&self) -> Vec<String> {
383        self.read_tools().keys().cloned().collect()
384    }
385
386    /// Returns the number of registered tools.
387    #[must_use]
388    pub fn len(&self) -> usize {
389        self.read_tools().len()
390    }
391
392    /// Returns `true` if no tools are registered.
393    #[must_use]
394    pub fn is_empty(&self) -> bool {
395        self.read_tools().is_empty()
396    }
397
398    /// Generates [`ToolSpec`]s for all registered tools, sorted by name.
399    #[must_use]
400    pub fn specs(&self) -> Vec<ToolSpec> {
401        let mut specs: Vec<_> = self.read_tools().values().map(|t| t.to_spec()).collect();
402        specs.sort_by(|a, b| a.name.cmp(&b.name));
403        specs
404    }
405
406    /// Looks up and executes a tool call.
407    pub async fn execute(&self, ctx: &dyn ToolContext, call: &ToolCall) -> ToolResult<ToolOutput> {
408        let tool = self.get(&call.name).ok_or_else(|| ToolError::NotFound {
409            name: call.name.clone(),
410        })?;
411        tool.execute_with_ctx(ctx, call.arguments.clone()).await
412    }
413
414    fn read_tools(&self) -> RwLockReadGuard<'_, std::collections::HashMap<String, Arc<dyn Tool>>> {
415        match self.tools.read() {
416            Ok(guard) => guard,
417            Err(poisoned) => poisoned.into_inner(),
418        }
419    }
420
421    fn write_tools(
422        &self,
423    ) -> RwLockWriteGuard<'_, std::collections::HashMap<String, Arc<dyn Tool>>> {
424        match self.tools.write() {
425            Ok(guard) => guard,
426            Err(poisoned) => poisoned.into_inner(),
427        }
428    }
429}
430
431#[cfg(test)]
432mod tests {
433    use super::*;
434
435    fn make_tool(name: &str, read_only: bool) -> FunctionTool {
436        let n = name.to_string();
437        FunctionTool::new(
438            name,
439            format!("Tool {name}"),
440            serde_json::json!({"type": "object", "properties": {}}),
441            move |_args| {
442                let n = n.clone();
443                Box::pin(async move { Ok(serde_json::Value::String(format!("{n} done"))) })
444            },
445        )
446        .let_ro(read_only)
447    }
448
449    impl FunctionTool {
450        fn let_ro(mut self, read_only: bool) -> Self {
451            if read_only {
452                self.read_only = true;
453                self.concurrency_safe = true;
454            }
455            self
456        }
457    }
458
459    #[test]
460    fn tool_registry_register_and_get() {
461        let reg = ToolRegistry::new();
462        reg.register(make_tool("echo", true));
463        assert!(reg.get("echo").is_some());
464        assert!(reg.get("nonexistent").is_none());
465        assert_eq!(reg.len(), 1);
466    }
467
468    #[test]
469    fn tool_registry_unregister() {
470        let reg = ToolRegistry::new();
471        reg.register(make_tool("echo", true));
472        assert!(reg.unregister("echo").is_some());
473        assert!(reg.is_empty());
474    }
475
476    #[test]
477    fn tool_specs_sorted() {
478        let reg = ToolRegistry::new();
479        reg.register(make_tool("zulu", true));
480        reg.register(make_tool("alpha", true));
481        let specs = reg.specs();
482        assert_eq!(specs.len(), 2);
483        assert_eq!(specs[0].name, "alpha");
484        assert_eq!(specs[1].name, "zulu");
485    }
486
487    #[test]
488    fn tool_registry_recovers_from_poisoned_lock() {
489        let reg = Arc::new(ToolRegistry::new());
490        reg.register(make_tool("before", true));
491
492        let cloned = Arc::clone(&reg);
493        let _ = std::thread::spawn(move || {
494            let _guard = cloned.tools.write();
495            panic!("poison registry");
496        })
497        .join();
498
499        reg.register(make_tool("after", true));
500
501        assert_eq!(reg.len(), 2);
502        assert!(reg.get("before").is_some());
503        assert!(reg.get("after").is_some());
504    }
505
506    #[test]
507    fn side_effects_concurrency_safety() {
508        let read_only = SideEffects {
509            read: true,
510            ..Default::default()
511        };
512        assert!(read_only.is_concurrency_safe());
513
514        let write = SideEffects {
515            write: true,
516            ..Default::default()
517        };
518        assert!(!write.is_concurrency_safe());
519
520        let destructive = SideEffects {
521            destructive: true,
522            ..Default::default()
523        };
524        assert!(!destructive.is_concurrency_safe());
525    }
526}