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