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