Skip to main content

bamboo_engine/runtime/managers/adapters/
tool.rs

1use std::sync::Arc;
2
3use async_trait::async_trait;
4use bamboo_agent_core::tools::{ToolCall, ToolExecutor, ToolSchema};
5use bamboo_agent_core::{AgentError, AgentEvent, Session};
6use bamboo_llm::LLMProvider;
7use bamboo_metrics::MetricsCollector;
8use tokio::sync::mpsc;
9use tokio_util::sync::CancellationToken;
10
11use crate::runtime::config::AgentLoopConfig;
12use crate::runtime::managers::tool::{ToolManager, ToolRoundResult};
13use crate::runtime::task_context::TaskLoopContext;
14
15/// Default tool manager that delegates to existing runner functions.
16pub struct DefaultToolManager {
17    tools: Arc<dyn ToolExecutor>,
18    llm: Arc<dyn LLMProvider>,
19}
20
21impl DefaultToolManager {
22    pub fn new(tools: Arc<dyn ToolExecutor>, llm: Arc<dyn LLMProvider>) -> Self {
23        Self { tools, llm }
24    }
25}
26
27#[async_trait]
28impl ToolManager for DefaultToolManager {
29    fn resolve_tool_schemas(&self, config: &AgentLoopConfig, session: &Session) -> Vec<ToolSchema> {
30        crate::runtime::runner::session_setup::tool_schemas::resolve_available_tool_schemas_for_session(
31            config,
32            self.tools.as_ref(),
33            session,
34        )
35    }
36
37    #[allow(clippy::too_many_arguments)]
38    async fn execute_tool_calls(
39        &self,
40        tool_calls: &[ToolCall],
41        event_tx: &mpsc::Sender<AgentEvent>,
42        metrics_collector: Option<&MetricsCollector>,
43        session_id: &str,
44        round_id: &str,
45        round: usize,
46        session: &mut Session,
47        config: &AgentLoopConfig,
48        task_context: &mut Option<TaskLoopContext>,
49        tool_schemas: &[ToolSchema],
50        cancel: &CancellationToken,
51    ) -> Result<ToolRoundResult, AgentError> {
52        let frame = crate::runtime::runner::round_frame::RoundFrame {
53            session_id,
54            round_id,
55            turn: round,
56            debug_enabled: false,
57            event_tx,
58            metrics_collector,
59            config,
60            llm: &self.llm,
61            tools: &self.tools,
62        };
63        let mut runtime_state = session
64            .agent_runtime_state
65            .clone()
66            .unwrap_or_else(|| bamboo_domain::AgentRuntimeState::new(session_id));
67        let effective_callable_set =
68            crate::runtime::runner::tool_execution::legacy_effective_callable_set(tool_schemas);
69
70        // Mirror the live pipeline's #30 biased-cancel wrap so a cancel issued
71        // DURING tool execution (e.g. a long foreground Bash run) is honored on
72        // this adapter path too, not only between rounds. `biased` checks
73        // cancellation first; on cancel the in-flight tool futures are dropped
74        // (true cancellation — foreground Bash is kill_on_drop). The per-tool
75        // `tokio::time::timeout` inside `execute_round_tool_calls` is preserved;
76        // cancel is strictly an additional early-exit. #104.
77        let result = tokio::select! {
78            biased;
79            _ = cancel.cancelled() => return Err(AgentError::Cancelled),
80            result = crate::runtime::runner::tool_execution::execute_round_tool_calls(
81                crate::runtime::runner::tool_execution::RoundToolExecution {
82                    tool_calls,
83                    frame: &frame,
84                    session,
85                    runtime_state: &mut runtime_state,
86                    task_context,
87                    compression_model_name: config
88                        .summarization_model_name
89                        .as_deref()
90                        .or(config.background_model_name.as_deref()),
91                    compression_model_provider: config
92                        .summarization_model_provider
93                        .as_ref()
94                        .or(config.background_model_provider.as_ref()),
95                    tool_schemas,
96                    effective_callable_set: &effective_callable_set,
97                },
98            ) => result?,
99        };
100        if !config.hook_runner.is_empty() {
101            session.agent_runtime_state = Some(runtime_state);
102        }
103
104        Ok(ToolRoundResult {
105            awaiting_clarification: result.awaiting_clarification,
106            should_break: false,
107            tool_calls_count: tool_calls.len(),
108        })
109    }
110}
111
112#[cfg(test)]
113mod tests {
114    use super::*;
115    use bamboo_agent_core::tools::{FunctionCall, ToolError, ToolExecutionContext, ToolResult};
116    use bamboo_agent_core::Message;
117    use bamboo_llm::provider::LLMStream;
118
119    // Stubs that PANIC if invoked — they prove the biased cancel arm returns BEFORE
120    // any LLM or tool work runs.
121    struct PanicProvider;
122    #[async_trait]
123    impl bamboo_llm::LLMProvider for PanicProvider {
124        async fn chat_stream(
125            &self,
126            _messages: &[Message],
127            _tools: &[ToolSchema],
128            _max_output_tokens: Option<u32>,
129            _model: &str,
130        ) -> bamboo_llm::provider::Result<LLMStream> {
131            panic!("LLM must not be invoked when the run is already cancelled");
132        }
133    }
134
135    struct PanicExecutor;
136    #[async_trait]
137    impl ToolExecutor for PanicExecutor {
138        async fn execute(&self, _call: &ToolCall) -> Result<ToolResult, ToolError> {
139            panic!("tools must not run when the run is already cancelled");
140        }
141        async fn execute_with_context(
142            &self,
143            call: &ToolCall,
144            _ctx: ToolExecutionContext<'_>,
145        ) -> Result<ToolResult, ToolError> {
146            self.execute(call).await
147        }
148        fn list_tools(&self) -> Vec<ToolSchema> {
149            Vec::new()
150        }
151    }
152
153    #[tokio::test]
154    async fn execute_tool_calls_short_circuits_to_cancelled_when_token_already_fired() {
155        let mgr = DefaultToolManager::new(Arc::new(PanicExecutor), Arc::new(PanicProvider));
156        let (event_tx, _rx) = mpsc::channel(8);
157        let mut session = Session::new("s1", "model");
158        let config = AgentLoopConfig::default();
159        let mut task_context = None;
160        // A non-empty tool call: without the cancel short-circuit, execute_round
161        // would route it to PanicExecutor and the test would panic.
162        let tool_calls = vec![ToolCall {
163            id: "c1".to_string(),
164            tool_type: "function".to_string(),
165            function: FunctionCall {
166                name: "anything".to_string(),
167                arguments: "{}".to_string(),
168            },
169        }];
170
171        let cancel = CancellationToken::new();
172        cancel.cancel(); // already cancelled before the call
173
174        let result = mgr
175            .execute_tool_calls(
176                &tool_calls,
177                &event_tx,
178                None,
179                "s1",
180                "r1",
181                0,
182                &mut session,
183                &config,
184                &mut task_context,
185                &[],
186                &cancel,
187            )
188            .await;
189
190        assert!(
191            matches!(result, Err(AgentError::Cancelled)),
192            "a pre-cancelled token returns Cancelled before touching tools/LLM; got {result:?}"
193        );
194    }
195}