Skip to main content

bamboo_agent_core/agent/
events.rs

1//! Agent event system for real-time streaming.
2//!
3//! This module defines the event types emitted during agent execution,
4//! which are streamed to clients via Server-Sent Events (SSE).
5//!
6//! # Event Types
7//!
8//! - [`AgentEvent`] - All possible agent execution events
9//! - [`TokenUsage`] - Token consumption statistics
10//! - [`TokenBudgetUsage`] - Detailed token budget information
11//!
12//! # Event Flow
13//!
14//! 1. **Token** events stream generated text
15//! 2. **ToolStart/ToolComplete** track tool execution
16//! 3. **TaskListUpdated** tracks progress
17//! 4. **TokenBudgetUpdated** reports context management
18//! 5. **Complete** or **Error** ends the stream
19//!
20//! # Example
21//!
22//! ```javascript
23//! const eventSource = new EventSource('/api/v1/events/session-id');
24//! eventSource.onmessage = (event) => {
25//!   const data = JSON.parse(event.data);
26//!   switch (data.type) {
27//!     case 'token':
28//!       console.log('Token:', data.content);
29//!       break;
30//!     case 'complete':
31//!       console.log('Done!');
32//!       eventSource.close();
33//!       break;
34//!   }
35//! };
36//! ```
37
38use crate::tools::ToolResult;
39use bamboo_domain::{TaskItemStatus, TaskList};
40use chrono::{DateTime, Utc};
41use serde::{Deserialize, Serialize};
42
43/// Represents events emitted during agent execution.
44///
45/// These events are streamed to clients via SSE to provide real-time
46/// feedback on agent progress, tool execution, and completion.
47///
48/// # Variants
49///
50/// ## Text Generation
51/// - `Token` - Streaming text token
52/// - `ReasoningToken` - Streaming reasoning/thinking token (separate channel)
53///
54/// ## Tool Execution
55/// - `ToolStart` - Tool execution started
56/// - `ToolComplete` - Tool finished successfully
57/// - `ToolError` - Tool execution failed
58///
59/// ## User Interaction
60/// - `NeedClarification` - Agent needs user input
61///
62/// ## Progress Tracking
63/// - `TaskListUpdated` - Task list created or modified
64/// - `TaskListItemProgress` - Individual item progress
65/// - `TaskListCompleted` - All items completed
66/// - `TaskEvaluationStarted` - Task evaluation began
67/// - `TaskEvaluationCompleted` - Task evaluation finished
68///
69/// ## Context Management
70/// - `TokenBudgetUpdated` - Context budget changed
71/// - `ContextCompressionStatus` - Context compression lifecycle progress
72/// - `ContextSummarized` - Old messages summarized
73///
74/// ## Sub-sessions (Async Spawn)
75/// - `SubSessionStarted` - A child session is created and scheduled to run
76/// - `SubSessionEvent` - Forwarded raw child event (full fidelity)
77/// - `SubSessionHeartbeat` - Periodic heartbeat while the child is running
78/// - `SubSessionCompleted` - Child session finished (completed/cancelled/error)
79///
80/// ## Terminal Events
81/// - `Complete` - Execution finished successfully
82/// - `Error` - Execution failed
83///
84/// # Serialization
85///
86/// Events are serialized as JSON with a `type` field for discrimination:
87/// ```json
88/// {"type": "token", "content": "Hello"}
89/// {"type": "complete", "usage": {"prompt_tokens": 10, "completion_tokens": 5, "total_tokens": 15}}
90/// ```
91#[derive(Debug, Clone, Serialize, Deserialize)]
92#[serde(tag = "type", rename_all = "snake_case")]
93pub enum AgentEvent {
94    /// Text token generated by the LLM.
95    Token {
96        /// Generated text content
97        content: String,
98    },
99
100    /// Reasoning/thinking token generated by the LLM.
101    ///
102    /// This is streamed separately from assistant answer tokens so the UI can
103    /// choose whether and how to display model reasoning traces.
104    ReasoningToken {
105        /// Generated reasoning content
106        content: String,
107    },
108
109    /// Streaming output emitted while a specific tool call is running.
110    ///
111    /// This is used to render "live output" inside a tool-call card in the UI
112    /// without mixing tool output into the assistant's main token stream.
113    ToolToken {
114        /// Tool call identifier that this output belongs to.
115        tool_call_id: String,
116        /// Output chunk.
117        content: String,
118    },
119
120    /// Tool execution started.
121    ToolStart {
122        /// Unique tool call identifier
123        tool_call_id: String,
124        /// Name of the tool being executed
125        tool_name: String,
126        /// Tool arguments (JSON)
127        arguments: serde_json::Value,
128    },
129
130    /// Tool execution completed successfully.
131    ToolComplete {
132        /// Tool call identifier
133        tool_call_id: String,
134        /// Tool execution result
135        result: ToolResult,
136    },
137
138    /// Tool execution failed.
139    ToolError {
140        /// Tool call identifier
141        tool_call_id: String,
142        /// Error message
143        error: String,
144    },
145
146    /// Structured lifecycle event for tool execution tracking.
147    ///
148    /// These events complement `ToolStart`/`ToolComplete`/`ToolError` with
149    /// richer metadata (mutability, auto-approval, wall-clock timing) and
150    /// are emitted by `ToolEmitter` (in `bamboo-agent-tools`).
151    ToolLifecycle {
152        /// Tool call identifier
153        tool_call_id: String,
154        /// Canonical tool name
155        tool_name: String,
156        /// Lifecycle phase: "begin", "finished", "error", "cancelled"
157        phase: String,
158        /// Wall-clock milliseconds since the call began (None for begin)
159        #[serde(skip_serializing_if = "Option::is_none")]
160        elapsed_ms: Option<u64>,
161        /// Whether the tool mutates state (writes files, runs commands)
162        is_mutating: bool,
163        /// Whether execution was auto-approved (no user prompt needed)
164        auto_approved: bool,
165        /// Human-readable summary
166        #[serde(skip_serializing_if = "Option::is_none")]
167        summary: Option<String>,
168        /// Error message (if phase == "error")
169        #[serde(skip_serializing_if = "Option::is_none")]
170        error: Option<String>,
171    },
172
173    /// Agent needs clarification from the user.
174    NeedClarification {
175        /// Question to ask the user
176        question: String,
177        /// Optional predefined options
178        options: Option<Vec<String>>,
179    },
180
181    /// Emitted when task list is created or updated.
182    TaskListUpdated {
183        /// Current task list state.
184        task_list: TaskList,
185    },
186
187    /// Emitted when a task item makes progress (delta update).
188    TaskListItemProgress {
189        /// Session identifier
190        session_id: String,
191        /// Item identifier
192        item_id: String,
193        /// New item status
194        status: TaskItemStatus,
195        /// Number of tool calls made
196        tool_calls_count: usize,
197        /// Item version (for optimistic concurrency)
198        version: u64,
199    },
200
201    /// Emitted when all task items are completed.
202    TaskListCompleted {
203        /// Session identifier
204        session_id: String,
205        /// Completion timestamp
206        completed_at: DateTime<Utc>,
207        /// Total agent rounds executed
208        total_rounds: u32,
209        /// Total tool calls made
210        total_tool_calls: usize,
211    },
212
213    /// Emitted when task evaluation starts.
214    TaskEvaluationStarted {
215        /// Session identifier
216        session_id: String,
217        /// Number of items to evaluate
218        items_count: usize,
219    },
220
221    /// Emitted when task evaluation completes.
222    TaskEvaluationCompleted {
223        /// Session identifier
224        session_id: String,
225        /// Number of items updated
226        updates_count: usize,
227        /// Evaluation reasoning
228        reasoning: String,
229    },
230
231    /// Emitted when token budget is prepared (after context truncation)
232    TokenBudgetUpdated {
233        /// Token budget details
234        usage: TokenBudgetUsage,
235    },
236
237    /// Emitted when host-side context compression lifecycle changes.
238    ContextCompressionStatus {
239        /// Compression phase label (for example: pre-turn, mid-turn).
240        phase: String,
241        /// Compression status: started | completed | failed | skipped
242        status: String,
243    },
244
245    /// Emitted when conversation context is summarized
246    ContextSummarized {
247        /// Generated summary text
248        summary: String,
249        /// Number of old messages summarized
250        messages_summarized: usize,
251        /// Tokens saved by summarization
252        tokens_saved: u32,
253        /// Context usage percentage before compression
254        #[serde(default)]
255        usage_before_percent: f64,
256        /// Context usage percentage after compression
257        #[serde(default)]
258        usage_after_percent: f64,
259        /// What triggered the compression: "auto" | "manual" | "critical"
260        #[serde(default)]
261        trigger_type: String,
262    },
263
264    /// Emitted when context pressure reaches warning or critical levels.
265    /// Frontend should display this to the user as a proactive notification.
266    ContextPressureNotification {
267        /// Context usage as a percentage of the context window.
268        percent: f64,
269        /// Severity level: "warning" (70%) or "critical" (90%).
270        level: String,
271        /// Human-readable message describing the pressure state.
272        message: String,
273    },
274
275    /// A child session was spawned from a parent session (async background job).
276    SubSessionStarted {
277        parent_session_id: String,
278        child_session_id: String,
279        /// Optional title (useful for UI lists).
280        #[serde(default, skip_serializing_if = "Option::is_none")]
281        title: Option<String>,
282    },
283
284    /// Forwarded raw child event to the parent session stream.
285    ///
286    /// Child sessions are not allowed to spawn further sessions, so this should not nest.
287    SubSessionEvent {
288        parent_session_id: String,
289        child_session_id: String,
290        event: Box<AgentEvent>,
291    },
292
293    /// Heartbeat emitted while a child session is running.
294    SubSessionHeartbeat {
295        parent_session_id: String,
296        child_session_id: String,
297        timestamp: DateTime<Utc>,
298    },
299
300    /// Child session finished (completed/cancelled/error).
301    SubSessionCompleted {
302        parent_session_id: String,
303        child_session_id: String,
304        /// One of: "completed" | "cancelled" | "error" | "skipped"
305        status: String,
306        #[serde(default, skip_serializing_if = "Option::is_none")]
307        error: Option<String>,
308    },
309
310    /// Agent execution completed successfully.
311    Complete {
312        /// Final token usage statistics
313        usage: TokenUsage,
314    },
315
316    /// Agent execution failed.
317    Error {
318        /// Error message
319        message: String,
320    },
321}
322
323/// Re-exported shared token usage type.
324///
325/// See [`bamboo_domain::TokenUsage`] for the canonical definition.
326pub use bamboo_domain::TokenUsage;
327
328pub use bamboo_domain::budget_types::TokenBudgetUsage;
329
330#[cfg(test)]
331mod tests {
332    use super::*;
333    use bamboo_domain::{TaskItem, TaskItemStatus, TaskList};
334
335    fn sample_task_list() -> TaskList {
336        TaskList {
337            session_id: "session-1".to_string(),
338            title: "Task List".to_string(),
339            items: vec![TaskItem {
340                id: "task_1".to_string(),
341                description: "Implement event rename".to_string(),
342                status: TaskItemStatus::InProgress,
343                depends_on: Vec::new(),
344                notes: "Implementing".to_string(),
345                ..TaskItem::default()
346            }],
347            created_at: Utc::now(),
348            updated_at: Utc::now(),
349        }
350    }
351
352    #[test]
353    fn task_list_updated_serializes_with_task_names() {
354        let event = AgentEvent::TaskListUpdated {
355            task_list: sample_task_list(),
356        };
357
358        let value = serde_json::to_value(event).expect("event should serialize");
359        assert_eq!(value["type"], "task_list_updated");
360        assert!(value.get("task_list").is_some());
361        assert!(value.get("todo_list").is_none());
362    }
363
364    #[test]
365    fn task_evaluation_completed_serializes_with_task_type() {
366        let event = AgentEvent::TaskEvaluationCompleted {
367            session_id: "session-1".to_string(),
368            updates_count: 2,
369            reasoning: "Updated statuses".to_string(),
370        };
371
372        let value = serde_json::to_value(event).expect("event should serialize");
373        assert_eq!(value["type"], "task_evaluation_completed");
374    }
375
376    #[test]
377    fn context_compression_status_serializes_with_phase_and_status() {
378        let event = AgentEvent::ContextCompressionStatus {
379            phase: "mid-turn".to_string(),
380            status: "started".to_string(),
381        };
382
383        let value = serde_json::to_value(event).expect("event should serialize");
384        assert_eq!(value["type"], "context_compression_status");
385        assert_eq!(value["phase"], "mid-turn");
386        assert_eq!(value["status"], "started");
387    }
388}