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}