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 super::*;
89
90    struct SleepyToolPort;
91
92    #[async_trait::async_trait]
93    impl ToolPort for SleepyToolPort {
94        async fn list_tools(&self) -> Result<Vec<ToolDescriptor>, ToolError> {
95            Ok(vec![
96                ToolDescriptor::new("local/sleep", "sleep")
97                    .with_input_schema(serde_json::json!({"type":"object"})),
98            ])
99        }
100
101        async fn call_tool(&self, call: ToolCall) -> Result<ToolResult, ToolError> {
102            tokio::time::sleep(std::time::Duration::from_millis(20)).await;
103            Ok(ToolResult {
104                name: call.name,
105                output: serde_json::json!({"ok": true}),
106                is_error: false,
107            })
108        }
109    }
110
111    #[tokio::test]
112    async fn timeout_layer_times_out_slow_calls() {
113        let layer = TimeoutToolLayer::new(5);
114        let wrapped = layer.wrap(Arc::new(SleepyToolPort));
115        let result = wrapped.call_tool(ToolCall::new("local/sleep", serde_json::json!({}))).await;
116        assert!(matches!(result, Err(ToolError::Timeout { .. })));
117    }
118}