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        /// Tool call identifier that triggered this clarification
180        #[serde(default, skip_serializing_if = "Option::is_none")]
181        tool_call_id: Option<String>,
182        /// Whether the user can provide a free-text response
183        #[serde(default = "default_allow_custom")]
184        allow_custom: bool,
185    },
186
187    /// Emitted when task list is created or updated.
188    TaskListUpdated {
189        /// Current task list state.
190        task_list: TaskList,
191    },
192
193    /// Emitted when a task item makes progress (delta update).
194    TaskListItemProgress {
195        /// Session identifier
196        session_id: String,
197        /// Item identifier
198        item_id: String,
199        /// New item status
200        status: TaskItemStatus,
201        /// Number of tool calls made
202        tool_calls_count: usize,
203        /// Item version (for optimistic concurrency)
204        version: u64,
205    },
206
207    /// Emitted when all task items are completed.
208    TaskListCompleted {
209        /// Session identifier
210        session_id: String,
211        /// Completion timestamp
212        completed_at: DateTime<Utc>,
213        /// Total agent rounds executed
214        total_rounds: u32,
215        /// Total tool calls made
216        total_tool_calls: usize,
217    },
218
219    /// Emitted when task evaluation starts.
220    TaskEvaluationStarted {
221        /// Session identifier
222        session_id: String,
223        /// Number of items to evaluate
224        items_count: usize,
225    },
226
227    /// Emitted when task evaluation completes.
228    TaskEvaluationCompleted {
229        /// Session identifier
230        session_id: String,
231        /// Number of items updated
232        updates_count: usize,
233        /// Evaluation reasoning
234        reasoning: String,
235    },
236
237    /// Emitted when token budget is prepared (after context truncation)
238    TokenBudgetUpdated {
239        /// Token budget details
240        usage: TokenBudgetUsage,
241    },
242
243    /// Emitted when host-side context compression lifecycle changes.
244    ContextCompressionStatus {
245        /// Compression phase label (for example: pre-turn, mid-turn).
246        phase: String,
247        /// Compression status: started | completed | failed | skipped
248        status: String,
249    },
250
251    /// Emitted when conversation context is summarized
252    ContextSummarized {
253        /// Generated summary text
254        summary: String,
255        /// Number of old messages summarized
256        messages_summarized: usize,
257        /// Tokens saved by summarization
258        tokens_saved: u32,
259        /// Context usage percentage before compression
260        #[serde(default)]
261        usage_before_percent: f64,
262        /// Context usage percentage after compression
263        #[serde(default)]
264        usage_after_percent: f64,
265        /// What triggered the compression: "auto" | "manual" | "critical"
266        #[serde(default)]
267        trigger_type: String,
268    },
269
270    /// Emitted when context pressure reaches warning or critical levels.
271    /// Frontend should display this to the user as a proactive notification.
272    ContextPressureNotification {
273        /// Context usage as a percentage of the context window.
274        percent: f64,
275        /// Severity level: "warning" (70%) or "critical" (90%).
276        level: String,
277        /// Human-readable message describing the pressure state.
278        message: String,
279    },
280
281    /// A child session was spawned from a parent session (async background job).
282    SubSessionStarted {
283        parent_session_id: String,
284        child_session_id: String,
285        /// Optional title (useful for UI lists).
286        #[serde(default, skip_serializing_if = "Option::is_none")]
287        title: Option<String>,
288    },
289
290    /// Forwarded raw child event to the parent session stream.
291    ///
292    /// Child sessions are not allowed to spawn further sessions, so this should not nest.
293    SubSessionEvent {
294        parent_session_id: String,
295        child_session_id: String,
296        event: Box<AgentEvent>,
297    },
298
299    /// Heartbeat emitted while a child session is running.
300    SubSessionHeartbeat {
301        parent_session_id: String,
302        child_session_id: String,
303        timestamp: DateTime<Utc>,
304    },
305
306    /// Child session finished (completed/cancelled/error).
307    SubSessionCompleted {
308        parent_session_id: String,
309        child_session_id: String,
310        /// One of: "completed" | "cancelled" | "error" | "skipped"
311        status: String,
312        #[serde(default, skip_serializing_if = "Option::is_none")]
313        error: Option<String>,
314    },
315
316    /// Plan mode was entered.
317    PlanModeEntered {
318        /// Session identifier
319        session_id: String,
320        /// Optional reason for entering plan mode
321        #[serde(default, skip_serializing_if = "Option::is_none")]
322        reason: Option<String>,
323        /// Previous permission mode before entering plan mode
324        pre_permission_mode: String,
325    },
326
327    /// Plan mode was exited.
328    PlanModeExited {
329        /// Session identifier
330        session_id: String,
331        /// Whether the exit was approved by the user
332        approved: bool,
333        /// The permission mode restored after exiting
334        restored_mode: String,
335        /// Plan content that was reviewed, if any
336        #[serde(default, skip_serializing_if = "Option::is_none")]
337        plan: Option<String>,
338    },
339
340    /// Plan file was updated.
341    PlanFileUpdated {
342        /// Session identifier
343        session_id: String,
344        /// Path to the plan file
345        file_path: String,
346        /// Summary of the plan content (truncated)
347        content_summary: String,
348    },
349
350    /// Runner progress update emitted at the start of each agent turn.
351    ///
352    /// Used to track live execution progress (round count, current activity)
353    /// for diagnostic visibility, especially for child sessions.
354    RunnerProgress {
355        /// Session identifier
356        session_id: String,
357        /// Current turn/round count
358        round_count: u32,
359    },
360
361    /// Session title was updated (auto-generated by backend or manually renamed via PATCH).
362    SessionTitleUpdated {
363        session_id: String,
364        title: String,
365        title_version: u64,
366        source: TitleSource,
367        updated_at: chrono::DateTime<chrono::Utc>,
368    },
369
370    /// Session pinned flag was toggled via PATCH.
371    ///
372    /// Replayable metadata event. `pinned` is an idempotent boolean so the
373    /// latest event wins; `updated_at` is used by the frontend to suppress
374    /// stale replays.
375    SessionPinnedUpdated {
376        session_id: String,
377        pinned: bool,
378        updated_at: chrono::DateTime<chrono::Utc>,
379    },
380
381    /// Execution run has started and the runner is now active.
382    ///
383    /// Emitted as the first event after a runner reservation succeeds,
384    /// before any token or tool events. Carries the `run_id` so the
385    /// frontend can correlate subsequent SSE events across reconnects.
386    ExecutionStarted {
387        /// Unique identifier for this execution run.
388        run_id: String,
389        /// Session identifier.
390        session_id: String,
391        /// ISO 8601 timestamp when the run started.
392        started_at: String,
393    },
394
395    /// Tool execution requires user approval before proceeding.
396    ///
397    /// Emitted when a permission checker determines that a tool call needs
398    /// explicit user confirmation (e.g., mutating operations in restricted
399    /// permission mode). The frontend should present the approval request and
400    /// either grant or deny it.
401    ToolApprovalRequested {
402        /// Unique identifier for the tool call awaiting approval.
403        tool_call_id: String,
404        /// Name of the tool being executed.
405        tool_name: String,
406        /// Parameters that were passed to the tool.
407        parameters: serde_json::Value,
408    },
409
410    /// Agent execution completed successfully.
411    Complete {
412        /// Final token usage statistics
413        usage: TokenUsage,
414    },
415
416    /// Agent execution failed.
417    Error {
418        /// Error message
419        message: String,
420    },
421}
422
423fn default_allow_custom() -> bool {
424    true
425}
426
427/// Source that triggered a session title update.
428#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
429#[serde(rename_all = "snake_case")]
430pub enum TitleSource {
431    Auto,
432    Manual,
433    Fallback,
434}
435
436/// Re-exported shared token usage type.
437///
438/// See [`bamboo_domain::TokenUsage`] for the canonical definition.
439pub use bamboo_domain::TokenUsage;
440
441pub use bamboo_domain::budget_types::TokenBudgetUsage;
442
443#[cfg(test)]
444mod tests {
445    use super::*;
446    use bamboo_domain::{TaskItem, TaskItemStatus, TaskList};
447
448    fn sample_task_list() -> TaskList {
449        TaskList {
450            session_id: "session-1".to_string(),
451            title: "Task List".to_string(),
452            items: vec![TaskItem {
453                id: "task_1".to_string(),
454                description: "Implement event rename".to_string(),
455                status: TaskItemStatus::InProgress,
456                depends_on: Vec::new(),
457                notes: "Implementing".to_string(),
458                ..TaskItem::default()
459            }],
460            created_at: Utc::now(),
461            updated_at: Utc::now(),
462        }
463    }
464
465    #[test]
466    fn task_list_updated_serializes_with_task_names() {
467        let event = AgentEvent::TaskListUpdated {
468            task_list: sample_task_list(),
469        };
470
471        let value = serde_json::to_value(event).expect("event should serialize");
472        assert_eq!(value["type"], "task_list_updated");
473        assert!(value.get("task_list").is_some());
474        assert!(value.get("todo_list").is_none());
475    }
476
477    #[test]
478    fn task_evaluation_completed_serializes_with_task_type() {
479        let event = AgentEvent::TaskEvaluationCompleted {
480            session_id: "session-1".to_string(),
481            updates_count: 2,
482            reasoning: "Updated statuses".to_string(),
483        };
484
485        let value = serde_json::to_value(event).expect("event should serialize");
486        assert_eq!(value["type"], "task_evaluation_completed");
487    }
488
489    #[test]
490    fn context_compression_status_serializes_with_phase_and_status() {
491        let event = AgentEvent::ContextCompressionStatus {
492            phase: "mid-turn".to_string(),
493            status: "started".to_string(),
494        };
495
496        let value = serde_json::to_value(event).expect("event should serialize");
497        assert_eq!(value["type"], "context_compression_status");
498        assert_eq!(value["phase"], "mid-turn");
499        assert_eq!(value["status"], "started");
500    }
501
502    #[test]
503    fn need_clarification_serializes_with_new_fields() {
504        let event = AgentEvent::NeedClarification {
505            question: "Continue?".to_string(),
506            options: Some(vec!["Yes".to_string(), "No".to_string()]),
507            tool_call_id: Some("tool-1".to_string()),
508            allow_custom: false,
509        };
510
511        let value = serde_json::to_value(event).expect("event should serialize");
512        assert_eq!(value["type"], "need_clarification");
513        assert_eq!(value["question"], "Continue?");
514        assert_eq!(value["options"], serde_json::json!(["Yes", "No"]));
515        assert_eq!(value["tool_call_id"], "tool-1");
516        assert_eq!(value["allow_custom"], false);
517    }
518
519    #[test]
520    fn need_clarification_deserializes_from_old_format_without_new_fields() {
521        let json = serde_json::json!({
522            "type": "need_clarification",
523            "question": "Continue?",
524            "options": ["Yes", "No"]
525        });
526
527        let event: AgentEvent =
528            serde_json::from_value(json).expect("should deserialize old format");
529        match event {
530            AgentEvent::NeedClarification {
531                question,
532                options,
533                tool_call_id,
534                allow_custom,
535            } => {
536                assert_eq!(question, "Continue?");
537                assert_eq!(options, Some(vec!["Yes".to_string(), "No".to_string()]));
538                assert_eq!(tool_call_id, None);
539                assert!(allow_custom); // default_allow_custom returns true
540            }
541            other => panic!("unexpected event: {other:?}"),
542        }
543    }
544
545    #[test]
546    fn need_clarification_deserializes_with_allow_custom_false() {
547        let json = serde_json::json!({
548            "type": "need_clarification",
549            "question": "Pick one",
550            "allow_custom": false
551        });
552
553        let event: AgentEvent = serde_json::from_value(json).expect("should deserialize");
554        match event {
555            AgentEvent::NeedClarification {
556                question,
557                options,
558                tool_call_id,
559                allow_custom,
560            } => {
561                assert_eq!(question, "Pick one");
562                assert_eq!(options, None);
563                assert_eq!(tool_call_id, None);
564                assert!(!allow_custom);
565            }
566            other => panic!("unexpected event: {other:?}"),
567        }
568    }
569
570    #[test]
571    fn plan_mode_entered_serializes_correctly() {
572        let event = AgentEvent::PlanModeEntered {
573            session_id: "sess-1".to_string(),
574            reason: Some("Complex refactor".to_string()),
575            pre_permission_mode: "default".to_string(),
576        };
577
578        let value = serde_json::to_value(event).expect("event should serialize");
579        assert_eq!(value["type"], "plan_mode_entered");
580        assert_eq!(value["session_id"], "sess-1");
581        assert_eq!(value["reason"], "Complex refactor");
582        assert_eq!(value["pre_permission_mode"], "default");
583    }
584
585    #[test]
586    fn plan_mode_exited_serializes_correctly() {
587        let event = AgentEvent::PlanModeExited {
588            session_id: "sess-1".to_string(),
589            approved: true,
590            restored_mode: "accept_edits".to_string(),
591            plan: Some("# Plan\n1. Step one".to_string()),
592        };
593
594        let value = serde_json::to_value(event).expect("event should serialize");
595        assert_eq!(value["type"], "plan_mode_exited");
596        assert_eq!(value["session_id"], "sess-1");
597        assert_eq!(value["approved"], true);
598        assert_eq!(value["restored_mode"], "accept_edits");
599        assert_eq!(value["plan"], "# Plan\n1. Step one");
600    }
601
602    #[test]
603    fn plan_file_updated_serializes_correctly() {
604        let event = AgentEvent::PlanFileUpdated {
605            session_id: "sess-1".to_string(),
606            file_path: "/tmp/plans/sess-1.md".to_string(),
607            content_summary: "Implementation plan for feature X".to_string(),
608        };
609
610        let value = serde_json::to_value(event).expect("event should serialize");
611        assert_eq!(value["type"], "plan_file_updated");
612        assert_eq!(value["session_id"], "sess-1");
613        assert_eq!(value["file_path"], "/tmp/plans/sess-1.md");
614        assert_eq!(
615            value["content_summary"],
616            "Implementation plan for feature X"
617        );
618    }
619
620    #[test]
621    fn tool_approval_requested_serializes_correctly() {
622        let event = AgentEvent::ToolApprovalRequested {
623            tool_call_id: "call-abc".to_string(),
624            tool_name: "Write".to_string(),
625            parameters: serde_json::json!({"file_path": "/tmp/test.txt"}),
626        };
627
628        let value = serde_json::to_value(event).expect("event should serialize");
629        assert_eq!(value["type"], "tool_approval_requested");
630        assert_eq!(value["tool_call_id"], "call-abc");
631        assert_eq!(value["tool_name"], "Write");
632        assert_eq!(
633            value["parameters"],
634            serde_json::json!({"file_path": "/tmp/test.txt"})
635        );
636    }
637
638    #[test]
639    fn tool_approval_requested_deserializes_correctly() {
640        let json = serde_json::json!({
641            "type": "tool_approval_requested",
642            "tool_call_id": "call-xyz",
643            "tool_name": "Bash",
644            "parameters": {"command": "ls -la"}
645        });
646
647        let event: AgentEvent = serde_json::from_value(json).expect("should deserialize");
648        match event {
649            AgentEvent::ToolApprovalRequested {
650                tool_call_id,
651                tool_name,
652                parameters,
653            } => {
654                assert_eq!(tool_call_id, "call-xyz");
655                assert_eq!(tool_name, "Bash");
656                assert_eq!(parameters, serde_json::json!({"command": "ls -la"}));
657            }
658            other => panic!("unexpected event: {other:?}"),
659        }
660    }
661
662    #[test]
663    fn session_title_updated_round_trips_with_source_variants() {
664        use chrono::Utc;
665        let event = AgentEvent::SessionTitleUpdated {
666            session_id: "sess-1".to_string(),
667            title: "My title".to_string(),
668            title_version: 3,
669            source: TitleSource::Auto,
670            updated_at: Utc::now(),
671        };
672        let json = serde_json::to_string(&event).unwrap();
673        assert!(
674            json.contains("\"type\":\"session_title_updated\""),
675            "json: {json}"
676        );
677        assert!(json.contains("\"source\":\"auto\""), "json: {json}");
678        let _decoded: AgentEvent = serde_json::from_str(&json).unwrap();
679    }
680
681    #[test]
682    fn plan_mode_events_deserialize_without_optional_fields() {
683        let json = serde_json::json!({
684            "type": "plan_mode_entered",
685            "session_id": "sess-1",
686            "pre_permission_mode": "default"
687        });
688
689        let event: AgentEvent = serde_json::from_value(json).expect("should deserialize");
690        match event {
691            AgentEvent::PlanModeEntered {
692                session_id,
693                reason,
694                pre_permission_mode,
695            } => {
696                assert_eq!(session_id, "sess-1");
697                assert_eq!(reason, None);
698                assert_eq!(pre_permission_mode, "default");
699            }
700            other => panic!("unexpected event: {other:?}"),
701        }
702    }
703}