Skip to main content

bamboo_tools/
events.rs

1//! Structured event tracking for tool executions.
2//!
3//! Inspired by Codex's `ToolEmitter` pattern, this module provides a way to
4//! trace tool calls through their lifecycle: begin → execute → finish.
5//!
6//! Events are emitted via the existing `AgentEvent` channel so they integrate
7//! seamlessly with the agent loop's event pipeline.
8
9use std::time::{Duration, Instant};
10
11use serde::{Deserialize, Serialize};
12
13use bamboo_agent_core::AgentEvent;
14
15/// Phase of a tool execution lifecycle.
16#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
17#[serde(rename_all = "snake_case")]
18pub enum ToolEventPhase {
19    Begin,
20    Executing,
21    Finished,
22    Error,
23    Cancelled,
24}
25
26/// A structured event emitted during tool execution.
27#[derive(Debug, Clone, Serialize, Deserialize)]
28pub struct ToolEvent {
29    /// Tool call ID (matches `ToolCall.id`)
30    pub call_id: String,
31    /// Canonical tool name
32    pub tool_name: String,
33    /// Current lifecycle phase
34    pub phase: ToolEventPhase,
35    /// Wall-clock duration since the call began (None for Begin phase)
36    pub elapsed_ms: Option<u64>,
37    /// Whether the tool is mutating (writes files, runs commands, etc.)
38    pub is_mutating: bool,
39    /// Whether the execution was auto-approved (no user prompt)
40    pub auto_approved: bool,
41    /// Human-readable summary (e.g. file paths affected, command preview)
42    pub summary: Option<String>,
43    /// Error message if phase == Error
44    pub error: Option<String>,
45}
46
47impl ToolEvent {
48    /// Convert this event into an [`AgentEvent::ToolLifecycle`] for streaming
49    /// through the agent event channel to the UI.
50    pub fn into_agent_event(self) -> AgentEvent {
51        let phase_str = match self.phase {
52            ToolEventPhase::Begin => "begin",
53            ToolEventPhase::Executing => "executing",
54            ToolEventPhase::Finished => "finished",
55            ToolEventPhase::Error => "error",
56            ToolEventPhase::Cancelled => "cancelled",
57        };
58        AgentEvent::ToolLifecycle {
59            tool_call_id: self.call_id,
60            tool_name: self.tool_name,
61            phase: phase_str.to_string(),
62            elapsed_ms: self.elapsed_ms,
63            is_mutating: self.is_mutating,
64            auto_approved: self.auto_approved,
65            summary: self.summary,
66            error: self.error,
67        }
68    }
69}
70
71/// Emitter that tracks a single tool call through its lifecycle.
72///
73/// Usage pattern:
74/// ```ignore
75/// let emitter = ToolEmitter::new("call_123", "Edit", true);
76/// emitter.begin();
77/// // ... do work ...
78/// emitter.finish(Some("Edited 2 files"));
79/// ```
80#[derive(Debug)]
81pub struct ToolEmitter {
82    call_id: String,
83    tool_name: String,
84    is_mutating: bool,
85    auto_approved: bool,
86    started_at: Instant,
87    events: Vec<ToolEvent>,
88}
89
90impl ToolEmitter {
91    /// Create a new emitter for a tool call.
92    pub fn new(call_id: &str, tool_name: &str, is_mutating: bool) -> Self {
93        Self {
94            call_id: call_id.to_string(),
95            tool_name: tool_name.to_string(),
96            is_mutating,
97            auto_approved: false,
98            started_at: Instant::now(),
99            events: Vec::new(),
100        }
101    }
102
103    /// Mark the call as auto-approved (no user approval needed).
104    pub fn set_auto_approved(&mut self, auto_approved: bool) {
105        self.auto_approved = auto_approved;
106    }
107
108    /// Emit a "begin" event.
109    pub fn begin(&mut self) -> &ToolEvent {
110        let event = ToolEvent {
111            call_id: self.call_id.clone(),
112            tool_name: self.tool_name.clone(),
113            phase: ToolEventPhase::Begin,
114            elapsed_ms: None,
115            is_mutating: self.is_mutating,
116            auto_approved: self.auto_approved,
117            summary: None,
118            error: None,
119        };
120        self.events.push(event);
121        self.events.last().unwrap()
122    }
123
124    /// Emit a "finished" event with an optional summary.
125    pub fn finish(&mut self, summary: Option<String>) -> &ToolEvent {
126        let elapsed = self.started_at.elapsed();
127        let event = ToolEvent {
128            call_id: self.call_id.clone(),
129            tool_name: self.tool_name.clone(),
130            phase: ToolEventPhase::Finished,
131            elapsed_ms: Some(elapsed.as_millis() as u64),
132            is_mutating: self.is_mutating,
133            auto_approved: self.auto_approved,
134            summary,
135            error: None,
136        };
137        self.events.push(event);
138        self.events.last().unwrap()
139    }
140
141    /// Emit an "error" event.
142    pub fn error(&mut self, error: String) -> &ToolEvent {
143        let elapsed = self.started_at.elapsed();
144        let event = ToolEvent {
145            call_id: self.call_id.clone(),
146            tool_name: self.tool_name.clone(),
147            phase: ToolEventPhase::Error,
148            elapsed_ms: Some(elapsed.as_millis() as u64),
149            is_mutating: self.is_mutating,
150            auto_approved: self.auto_approved,
151            summary: None,
152            error: Some(error),
153        };
154        self.events.push(event);
155        self.events.last().unwrap()
156    }
157
158    /// Emit a "cancelled" event (e.g. user denied approval).
159    pub fn cancelled(&mut self, reason: Option<String>) -> &ToolEvent {
160        let elapsed = self.started_at.elapsed();
161        let event = ToolEvent {
162            call_id: self.call_id.clone(),
163            tool_name: self.tool_name.clone(),
164            phase: ToolEventPhase::Cancelled,
165            elapsed_ms: Some(elapsed.as_millis() as u64),
166            is_mutating: self.is_mutating,
167            auto_approved: self.auto_approved,
168            summary: reason,
169            error: None,
170        };
171        self.events.push(event);
172        self.events.last().unwrap()
173    }
174
175    /// Get the elapsed time since the emitter was created.
176    pub fn elapsed(&self) -> Duration {
177        self.started_at.elapsed()
178    }
179
180    /// Get all recorded events.
181    pub fn events(&self) -> &[ToolEvent] {
182        &self.events
183    }
184
185    /// Get the tool call ID.
186    pub fn call_id(&self) -> &str {
187        &self.call_id
188    }
189
190    /// Get the tool name.
191    pub fn tool_name(&self) -> &str {
192        &self.tool_name
193    }
194}
195
196#[cfg(test)]
197mod tests {
198    use super::*;
199
200    #[test]
201    fn test_emitter_lifecycle() {
202        let mut emitter = ToolEmitter::new("call_1", "Edit", true);
203        emitter.set_auto_approved(true);
204
205        let begin = emitter.begin();
206        assert_eq!(begin.phase, ToolEventPhase::Begin);
207        assert!(begin.is_mutating);
208        assert!(begin.auto_approved);
209        assert!(begin.elapsed_ms.is_none());
210
211        let finish = emitter.finish(Some("Updated 2 files".to_string()));
212        assert_eq!(finish.phase, ToolEventPhase::Finished);
213        assert!(finish.elapsed_ms.is_some());
214        assert_eq!(finish.summary.as_deref(), Some("Updated 2 files"));
215
216        assert_eq!(emitter.events().len(), 2);
217    }
218
219    #[test]
220    fn test_emitter_error() {
221        let mut emitter = ToolEmitter::new("call_2", "Bash", true);
222        emitter.begin();
223        let err = emitter.error("Permission denied".to_string());
224        assert_eq!(err.phase, ToolEventPhase::Error);
225        assert_eq!(err.error.as_deref(), Some("Permission denied"));
226        assert_eq!(emitter.events().len(), 2);
227    }
228
229    #[test]
230    fn test_emitter_cancelled() {
231        let mut emitter = ToolEmitter::new("call_3", "Write", true);
232        emitter.begin();
233        let cancel = emitter.cancelled(Some("User denied".to_string()));
234        assert_eq!(cancel.phase, ToolEventPhase::Cancelled);
235        assert_eq!(cancel.summary.as_deref(), Some("User denied"));
236    }
237
238    #[test]
239    fn test_non_mutating_tool() {
240        let mut emitter = ToolEmitter::new("call_4", "Read", false);
241        let begin = emitter.begin();
242        assert!(!begin.is_mutating);
243        assert!(!begin.auto_approved);
244    }
245
246    #[test]
247    fn test_serialization() {
248        let event = ToolEvent {
249            call_id: "c1".to_string(),
250            tool_name: "Bash".to_string(),
251            phase: ToolEventPhase::Finished,
252            elapsed_ms: Some(150),
253            is_mutating: true,
254            auto_approved: false,
255            summary: Some("Ran command".to_string()),
256            error: None,
257        };
258        let json = serde_json::to_string(&event).unwrap();
259        assert!(json.contains("\"phase\":\"finished\""));
260        assert!(json.contains("\"elapsed_ms\":150"));
261        let deserialized: ToolEvent = serde_json::from_str(&json).unwrap();
262        assert_eq!(deserialized.phase, ToolEventPhase::Finished);
263    }
264
265    #[test]
266    fn test_into_agent_event_begin() {
267        let mut emitter = ToolEmitter::new("call_99", "Read", false);
268        let event = emitter.begin().clone();
269        let agent_event = event.into_agent_event();
270
271        match agent_event {
272            AgentEvent::ToolLifecycle {
273                tool_call_id,
274                tool_name,
275                phase,
276                elapsed_ms,
277                is_mutating,
278                auto_approved,
279                ..
280            } => {
281                assert_eq!(tool_call_id, "call_99");
282                assert_eq!(tool_name, "Read");
283                assert_eq!(phase, "begin");
284                assert!(elapsed_ms.is_none());
285                assert!(!is_mutating);
286                assert!(!auto_approved);
287            }
288            _ => panic!("Expected ToolLifecycle variant"),
289        }
290    }
291
292    #[test]
293    fn test_into_agent_event_finished() {
294        let mut emitter = ToolEmitter::new("call_100", "Bash", true);
295        emitter.set_auto_approved(false);
296        emitter.begin();
297        std::thread::sleep(std::time::Duration::from_millis(5));
298        let event = emitter.finish(Some("done".to_string())).clone();
299        let agent_event = event.into_agent_event();
300
301        match agent_event {
302            AgentEvent::ToolLifecycle {
303                phase,
304                elapsed_ms,
305                is_mutating,
306                summary,
307                ..
308            } => {
309                assert_eq!(phase, "finished");
310                assert!(elapsed_ms.unwrap() >= 5);
311                assert!(is_mutating);
312                assert_eq!(summary.as_deref(), Some("done"));
313            }
314            _ => panic!("Expected ToolLifecycle variant"),
315        }
316    }
317
318    #[test]
319    fn test_into_agent_event_error() {
320        let mut emitter = ToolEmitter::new("call_101", "Write", true);
321        emitter.begin();
322        let event = emitter.error("Permission denied".to_string()).clone();
323        let agent_event = event.into_agent_event();
324
325        match agent_event {
326            AgentEvent::ToolLifecycle { phase, error, .. } => {
327                assert_eq!(phase, "error");
328                assert_eq!(error.as_deref(), Some("Permission denied"));
329            }
330            _ => panic!("Expected ToolLifecycle variant"),
331        }
332    }
333
334    #[test]
335    fn test_into_agent_event_cancelled() {
336        let mut emitter = ToolEmitter::new("call_102", "Edit", true);
337        emitter.begin();
338        let event = emitter.cancelled(Some("User denied".to_string())).clone();
339        let agent_event = event.into_agent_event();
340
341        match agent_event {
342            AgentEvent::ToolLifecycle { phase, summary, .. } => {
343                assert_eq!(phase, "cancelled");
344                assert_eq!(summary.as_deref(), Some("User denied"));
345            }
346            _ => panic!("Expected ToolLifecycle variant"),
347        }
348    }
349
350    #[test]
351    fn test_agent_event_serialization_roundtrip() {
352        let event = ToolEvent {
353            call_id: "c1".to_string(),
354            tool_name: "Bash".to_string(),
355            phase: ToolEventPhase::Finished,
356            elapsed_ms: Some(42),
357            is_mutating: true,
358            auto_approved: false,
359            summary: Some("ok".to_string()),
360            error: None,
361        };
362        let agent_event = event.into_agent_event();
363        let json = serde_json::to_string(&agent_event).unwrap();
364        assert!(json.contains("\"type\":\"tool_lifecycle\""));
365        assert!(json.contains("\"phase\":\"finished\""));
366        assert!(json.contains("\"elapsed_ms\":42"));
367    }
368}