Skip to main content

bob_runtime/
tooling.rs

1//! Tooling helpers for runtime composition.
2
3use std::sync::Arc;
4
5use bob_core::{
6    error::ToolError,
7    ports::ToolPort,
8    types::{ToolCall, ToolDescriptor, ToolResult},
9};
10
11/// Decorator-style wrapper for [`ToolPort`] implementations.
12pub trait ToolLayer: Send + Sync {
13    /// Wraps an existing [`ToolPort`] with additional behavior.
14    fn wrap(&self, inner: Arc<dyn ToolPort>) -> Arc<dyn ToolPort>;
15}
16
17/// A no-op tool port that advertises no tools and rejects all calls.
18#[derive(Debug, Clone, Copy, Default)]
19pub struct NoOpToolPort;
20
21#[async_trait::async_trait]
22impl ToolPort for NoOpToolPort {
23    async fn list_tools(&self) -> Result<Vec<ToolDescriptor>, ToolError> {
24        Ok(vec![])
25    }
26
27    async fn call_tool(&self, call: ToolCall) -> Result<ToolResult, ToolError> {
28        Err(ToolError::Execution(format!("no tool port configured, cannot call '{}'", call.name)))
29    }
30}
31
32/// A [`ToolLayer`] that applies a timeout to tool calls.
33#[derive(Debug, Clone, Copy)]
34pub struct TimeoutToolLayer {
35    timeout_ms: u64,
36}
37
38impl TimeoutToolLayer {
39    #[must_use]
40    pub fn new(timeout_ms: u64) -> Self {
41        Self { timeout_ms }
42    }
43}
44
45impl ToolLayer for TimeoutToolLayer {
46    fn wrap(&self, inner: Arc<dyn ToolPort>) -> Arc<dyn ToolPort> {
47        Arc::new(TimeoutToolPort { inner, timeout_ms: self.timeout_ms })
48    }
49}
50
51struct TimeoutToolPort {
52    inner: Arc<dyn ToolPort>,
53    timeout_ms: u64,
54}
55
56impl std::fmt::Debug for TimeoutToolPort {
57    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
58        f.debug_struct("TimeoutToolPort")
59            .field("timeout_ms", &self.timeout_ms)
60            .finish_non_exhaustive()
61    }
62}
63
64#[async_trait::async_trait]
65impl ToolPort for TimeoutToolPort {
66    async fn list_tools(&self) -> Result<Vec<ToolDescriptor>, ToolError> {
67        self.inner.list_tools().await
68    }
69
70    async fn call_tool(&self, call: ToolCall) -> Result<ToolResult, ToolError> {
71        let tool_name = call.name.clone();
72        match tokio::time::timeout(
73            std::time::Duration::from_millis(self.timeout_ms),
74            self.inner.call_tool(call),
75        )
76        .await
77        {
78            Ok(result) => result,
79            Err(_) => Err(ToolError::Timeout { name: tool_name }),
80        }
81    }
82}
83
84#[cfg(test)]
85mod tests {
86    use std::sync::Arc;
87
88    use bob_core::types::ToolSource;
89
90    use super::*;
91
92    struct SleepyToolPort;
93
94    #[async_trait::async_trait]
95    impl ToolPort for SleepyToolPort {
96        async fn list_tools(&self) -> Result<Vec<ToolDescriptor>, ToolError> {
97            Ok(vec![ToolDescriptor {
98                id: "local/sleep".to_string(),
99                description: "sleep".to_string(),
100                input_schema: serde_json::json!({"type":"object"}),
101                source: ToolSource::Local,
102            }])
103        }
104
105        async fn call_tool(&self, call: ToolCall) -> Result<ToolResult, ToolError> {
106            tokio::time::sleep(std::time::Duration::from_millis(20)).await;
107            Ok(ToolResult {
108                name: call.name,
109                output: serde_json::json!({"ok": true}),
110                is_error: false,
111            })
112        }
113    }
114
115    #[tokio::test]
116    async fn timeout_layer_times_out_slow_calls() {
117        let layer = TimeoutToolLayer::new(5);
118        let wrapped = layer.wrap(Arc::new(SleepyToolPort));
119        let result = wrapped
120            .call_tool(ToolCall {
121                name: "local/sleep".to_string(),
122                arguments: serde_json::json!({}),
123            })
124            .await;
125        assert!(matches!(result, Err(ToolError::Timeout { .. })));
126    }
127}