bamboo-agent-core 2026.4.30

Core agent abstractions and execution primitives for the Bamboo agent framework
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
//! Agent event system for real-time streaming.
//!
//! This module defines the event types emitted during agent execution,
//! which are streamed to clients via Server-Sent Events (SSE).
//!
//! # Event Types
//!
//! - [`AgentEvent`] - All possible agent execution events
//! - [`TokenUsage`] - Token consumption statistics
//! - [`TokenBudgetUsage`] - Detailed token budget information
//!
//! # Event Flow
//!
//! 1. **Token** events stream generated text
//! 2. **ToolStart/ToolComplete** track tool execution
//! 3. **TaskListUpdated** tracks progress
//! 4. **TokenBudgetUpdated** reports context management
//! 5. **Complete** or **Error** ends the stream
//!
//! # Example
//!
//! ```javascript
//! const eventSource = new EventSource('/api/v1/events/session-id');
//! eventSource.onmessage = (event) => {
//!   const data = JSON.parse(event.data);
//!   switch (data.type) {
//!     case 'token':
//!       console.log('Token:', data.content);
//!       break;
//!     case 'complete':
//!       console.log('Done!');
//!       eventSource.close();
//!       break;
//!   }
//! };
//! ```

use crate::tools::ToolResult;
use bamboo_domain::{TaskItemStatus, TaskList};
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};

/// Represents events emitted during agent execution.
///
/// These events are streamed to clients via SSE to provide real-time
/// feedback on agent progress, tool execution, and completion.
///
/// # Variants
///
/// ## Text Generation
/// - `Token` - Streaming text token
/// - `ReasoningToken` - Streaming reasoning/thinking token (separate channel)
///
/// ## Tool Execution
/// - `ToolStart` - Tool execution started
/// - `ToolComplete` - Tool finished successfully
/// - `ToolError` - Tool execution failed
///
/// ## User Interaction
/// - `NeedClarification` - Agent needs user input
///
/// ## Progress Tracking
/// - `TaskListUpdated` - Task list created or modified
/// - `TaskListItemProgress` - Individual item progress
/// - `TaskListCompleted` - All items completed
/// - `TaskEvaluationStarted` - Task evaluation began
/// - `TaskEvaluationCompleted` - Task evaluation finished
///
/// ## Context Management
/// - `TokenBudgetUpdated` - Context budget changed
/// - `ContextCompressionStatus` - Context compression lifecycle progress
/// - `ContextSummarized` - Old messages summarized
///
/// ## Sub-sessions (Async Spawn)
/// - `SubSessionStarted` - A child session is created and scheduled to run
/// - `SubSessionEvent` - Forwarded raw child event (full fidelity)
/// - `SubSessionHeartbeat` - Periodic heartbeat while the child is running
/// - `SubSessionCompleted` - Child session finished (completed/cancelled/error)
///
/// ## Terminal Events
/// - `Complete` - Execution finished successfully
/// - `Error` - Execution failed
///
/// # Serialization
///
/// Events are serialized as JSON with a `type` field for discrimination:
/// ```json
/// {"type": "token", "content": "Hello"}
/// {"type": "complete", "usage": {"prompt_tokens": 10, "completion_tokens": 5, "total_tokens": 15}}
/// ```
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum AgentEvent {
    /// Text token generated by the LLM.
    Token {
        /// Generated text content
        content: String,
    },

    /// Reasoning/thinking token generated by the LLM.
    ///
    /// This is streamed separately from assistant answer tokens so the UI can
    /// choose whether and how to display model reasoning traces.
    ReasoningToken {
        /// Generated reasoning content
        content: String,
    },

    /// Streaming output emitted while a specific tool call is running.
    ///
    /// This is used to render "live output" inside a tool-call card in the UI
    /// without mixing tool output into the assistant's main token stream.
    ToolToken {
        /// Tool call identifier that this output belongs to.
        tool_call_id: String,
        /// Output chunk.
        content: String,
    },

    /// Tool execution started.
    ToolStart {
        /// Unique tool call identifier
        tool_call_id: String,
        /// Name of the tool being executed
        tool_name: String,
        /// Tool arguments (JSON)
        arguments: serde_json::Value,
    },

    /// Tool execution completed successfully.
    ToolComplete {
        /// Tool call identifier
        tool_call_id: String,
        /// Tool execution result
        result: ToolResult,
    },

    /// Tool execution failed.
    ToolError {
        /// Tool call identifier
        tool_call_id: String,
        /// Error message
        error: String,
    },

    /// Structured lifecycle event for tool execution tracking.
    ///
    /// These events complement `ToolStart`/`ToolComplete`/`ToolError` with
    /// richer metadata (mutability, auto-approval, wall-clock timing) and
    /// are emitted by `ToolEmitter` (in `bamboo-agent-tools`).
    ToolLifecycle {
        /// Tool call identifier
        tool_call_id: String,
        /// Canonical tool name
        tool_name: String,
        /// Lifecycle phase: "begin", "finished", "error", "cancelled"
        phase: String,
        /// Wall-clock milliseconds since the call began (None for begin)
        #[serde(skip_serializing_if = "Option::is_none")]
        elapsed_ms: Option<u64>,
        /// Whether the tool mutates state (writes files, runs commands)
        is_mutating: bool,
        /// Whether execution was auto-approved (no user prompt needed)
        auto_approved: bool,
        /// Human-readable summary
        #[serde(skip_serializing_if = "Option::is_none")]
        summary: Option<String>,
        /// Error message (if phase == "error")
        #[serde(skip_serializing_if = "Option::is_none")]
        error: Option<String>,
    },

    /// Agent needs clarification from the user.
    NeedClarification {
        /// Question to ask the user
        question: String,
        /// Optional predefined options
        options: Option<Vec<String>>,
        /// Tool call identifier that triggered this clarification
        #[serde(default, skip_serializing_if = "Option::is_none")]
        tool_call_id: Option<String>,
        /// Whether the user can provide a free-text response
        #[serde(default = "default_allow_custom")]
        allow_custom: bool,
    },

    /// Emitted when task list is created or updated.
    TaskListUpdated {
        /// Current task list state.
        task_list: TaskList,
    },

    /// Emitted when a task item makes progress (delta update).
    TaskListItemProgress {
        /// Session identifier
        session_id: String,
        /// Item identifier
        item_id: String,
        /// New item status
        status: TaskItemStatus,
        /// Number of tool calls made
        tool_calls_count: usize,
        /// Item version (for optimistic concurrency)
        version: u64,
    },

    /// Emitted when all task items are completed.
    TaskListCompleted {
        /// Session identifier
        session_id: String,
        /// Completion timestamp
        completed_at: DateTime<Utc>,
        /// Total agent rounds executed
        total_rounds: u32,
        /// Total tool calls made
        total_tool_calls: usize,
    },

    /// Emitted when task evaluation starts.
    TaskEvaluationStarted {
        /// Session identifier
        session_id: String,
        /// Number of items to evaluate
        items_count: usize,
    },

    /// Emitted when task evaluation completes.
    TaskEvaluationCompleted {
        /// Session identifier
        session_id: String,
        /// Number of items updated
        updates_count: usize,
        /// Evaluation reasoning
        reasoning: String,
    },

    /// Emitted when token budget is prepared (after context truncation)
    TokenBudgetUpdated {
        /// Token budget details
        usage: TokenBudgetUsage,
    },

    /// Emitted when host-side context compression lifecycle changes.
    ContextCompressionStatus {
        /// Compression phase label (for example: pre-turn, mid-turn).
        phase: String,
        /// Compression status: started | completed | failed | skipped
        status: String,
    },

    /// Emitted when conversation context is summarized
    ContextSummarized {
        /// Generated summary text
        summary: String,
        /// Number of old messages summarized
        messages_summarized: usize,
        /// Tokens saved by summarization
        tokens_saved: u32,
        /// Context usage percentage before compression
        #[serde(default)]
        usage_before_percent: f64,
        /// Context usage percentage after compression
        #[serde(default)]
        usage_after_percent: f64,
        /// What triggered the compression: "auto" | "manual" | "critical"
        #[serde(default)]
        trigger_type: String,
    },

    /// Emitted when context pressure reaches warning or critical levels.
    /// Frontend should display this to the user as a proactive notification.
    ContextPressureNotification {
        /// Context usage as a percentage of the context window.
        percent: f64,
        /// Severity level: "warning" (70%) or "critical" (90%).
        level: String,
        /// Human-readable message describing the pressure state.
        message: String,
    },

    /// A child session was spawned from a parent session (async background job).
    SubSessionStarted {
        parent_session_id: String,
        child_session_id: String,
        /// Optional title (useful for UI lists).
        #[serde(default, skip_serializing_if = "Option::is_none")]
        title: Option<String>,
    },

    /// Forwarded raw child event to the parent session stream.
    ///
    /// Child sessions are not allowed to spawn further sessions, so this should not nest.
    SubSessionEvent {
        parent_session_id: String,
        child_session_id: String,
        event: Box<AgentEvent>,
    },

    /// Heartbeat emitted while a child session is running.
    SubSessionHeartbeat {
        parent_session_id: String,
        child_session_id: String,
        timestamp: DateTime<Utc>,
    },

    /// Child session finished (completed/cancelled/error).
    SubSessionCompleted {
        parent_session_id: String,
        child_session_id: String,
        /// One of: "completed" | "cancelled" | "error" | "skipped"
        status: String,
        #[serde(default, skip_serializing_if = "Option::is_none")]
        error: Option<String>,
    },

    /// Plan mode was entered.
    PlanModeEntered {
        /// Session identifier
        session_id: String,
        /// Optional reason for entering plan mode
        #[serde(default, skip_serializing_if = "Option::is_none")]
        reason: Option<String>,
        /// Previous permission mode before entering plan mode
        pre_permission_mode: String,
    },

    /// Plan mode was exited.
    PlanModeExited {
        /// Session identifier
        session_id: String,
        /// Whether the exit was approved by the user
        approved: bool,
        /// The permission mode restored after exiting
        restored_mode: String,
        /// Plan content that was reviewed, if any
        #[serde(default, skip_serializing_if = "Option::is_none")]
        plan: Option<String>,
    },

    /// Plan file was updated.
    PlanFileUpdated {
        /// Session identifier
        session_id: String,
        /// Path to the plan file
        file_path: String,
        /// Summary of the plan content (truncated)
        content_summary: String,
    },

    /// Runner progress update emitted at the start of each agent turn.
    ///
    /// Used to track live execution progress (round count, current activity)
    /// for diagnostic visibility, especially for child sessions.
    RunnerProgress {
        /// Session identifier
        session_id: String,
        /// Current turn/round count
        round_count: u32,
    },

    /// Agent execution completed successfully.
    Complete {
        /// Final token usage statistics
        usage: TokenUsage,
    },

    /// Agent execution failed.
    Error {
        /// Error message
        message: String,
    },
}

fn default_allow_custom() -> bool {
    true
}

/// Re-exported shared token usage type.
///
/// See [`bamboo_domain::TokenUsage`] for the canonical definition.
pub use bamboo_domain::TokenUsage;

pub use bamboo_domain::budget_types::TokenBudgetUsage;

#[cfg(test)]
mod tests {
    use super::*;
    use bamboo_domain::{TaskItem, TaskItemStatus, TaskList};

    fn sample_task_list() -> TaskList {
        TaskList {
            session_id: "session-1".to_string(),
            title: "Task List".to_string(),
            items: vec![TaskItem {
                id: "task_1".to_string(),
                description: "Implement event rename".to_string(),
                status: TaskItemStatus::InProgress,
                depends_on: Vec::new(),
                notes: "Implementing".to_string(),
                ..TaskItem::default()
            }],
            created_at: Utc::now(),
            updated_at: Utc::now(),
        }
    }

    #[test]
    fn task_list_updated_serializes_with_task_names() {
        let event = AgentEvent::TaskListUpdated {
            task_list: sample_task_list(),
        };

        let value = serde_json::to_value(event).expect("event should serialize");
        assert_eq!(value["type"], "task_list_updated");
        assert!(value.get("task_list").is_some());
        assert!(value.get("todo_list").is_none());
    }

    #[test]
    fn task_evaluation_completed_serializes_with_task_type() {
        let event = AgentEvent::TaskEvaluationCompleted {
            session_id: "session-1".to_string(),
            updates_count: 2,
            reasoning: "Updated statuses".to_string(),
        };

        let value = serde_json::to_value(event).expect("event should serialize");
        assert_eq!(value["type"], "task_evaluation_completed");
    }

    #[test]
    fn context_compression_status_serializes_with_phase_and_status() {
        let event = AgentEvent::ContextCompressionStatus {
            phase: "mid-turn".to_string(),
            status: "started".to_string(),
        };

        let value = serde_json::to_value(event).expect("event should serialize");
        assert_eq!(value["type"], "context_compression_status");
        assert_eq!(value["phase"], "mid-turn");
        assert_eq!(value["status"], "started");
    }

    #[test]
    fn need_clarification_serializes_with_new_fields() {
        let event = AgentEvent::NeedClarification {
            question: "Continue?".to_string(),
            options: Some(vec!["Yes".to_string(), "No".to_string()]),
            tool_call_id: Some("tool-1".to_string()),
            allow_custom: false,
        };

        let value = serde_json::to_value(event).expect("event should serialize");
        assert_eq!(value["type"], "need_clarification");
        assert_eq!(value["question"], "Continue?");
        assert_eq!(value["options"], serde_json::json!(["Yes", "No"]));
        assert_eq!(value["tool_call_id"], "tool-1");
        assert_eq!(value["allow_custom"], false);
    }

    #[test]
    fn need_clarification_deserializes_from_old_format_without_new_fields() {
        let json = serde_json::json!({
            "type": "need_clarification",
            "question": "Continue?",
            "options": ["Yes", "No"]
        });

        let event: AgentEvent =
            serde_json::from_value(json).expect("should deserialize old format");
        match event {
            AgentEvent::NeedClarification {
                question,
                options,
                tool_call_id,
                allow_custom,
            } => {
                assert_eq!(question, "Continue?");
                assert_eq!(options, Some(vec!["Yes".to_string(), "No".to_string()]));
                assert_eq!(tool_call_id, None);
                assert!(allow_custom); // default_allow_custom returns true
            }
            other => panic!("unexpected event: {other:?}"),
        }
    }

    #[test]
    fn need_clarification_deserializes_with_allow_custom_false() {
        let json = serde_json::json!({
            "type": "need_clarification",
            "question": "Pick one",
            "allow_custom": false
        });

        let event: AgentEvent = serde_json::from_value(json).expect("should deserialize");
        match event {
            AgentEvent::NeedClarification {
                question,
                options,
                tool_call_id,
                allow_custom,
            } => {
                assert_eq!(question, "Pick one");
                assert_eq!(options, None);
                assert_eq!(tool_call_id, None);
                assert!(!allow_custom);
            }
            other => panic!("unexpected event: {other:?}"),
        }
    }

    #[test]
    fn plan_mode_entered_serializes_correctly() {
        let event = AgentEvent::PlanModeEntered {
            session_id: "sess-1".to_string(),
            reason: Some("Complex refactor".to_string()),
            pre_permission_mode: "default".to_string(),
        };

        let value = serde_json::to_value(event).expect("event should serialize");
        assert_eq!(value["type"], "plan_mode_entered");
        assert_eq!(value["session_id"], "sess-1");
        assert_eq!(value["reason"], "Complex refactor");
        assert_eq!(value["pre_permission_mode"], "default");
    }

    #[test]
    fn plan_mode_exited_serializes_correctly() {
        let event = AgentEvent::PlanModeExited {
            session_id: "sess-1".to_string(),
            approved: true,
            restored_mode: "accept_edits".to_string(),
            plan: Some("# Plan\n1. Step one".to_string()),
        };

        let value = serde_json::to_value(event).expect("event should serialize");
        assert_eq!(value["type"], "plan_mode_exited");
        assert_eq!(value["session_id"], "sess-1");
        assert_eq!(value["approved"], true);
        assert_eq!(value["restored_mode"], "accept_edits");
        assert_eq!(value["plan"], "# Plan\n1. Step one");
    }

    #[test]
    fn plan_file_updated_serializes_correctly() {
        let event = AgentEvent::PlanFileUpdated {
            session_id: "sess-1".to_string(),
            file_path: "/tmp/plans/sess-1.md".to_string(),
            content_summary: "Implementation plan for feature X".to_string(),
        };

        let value = serde_json::to_value(event).expect("event should serialize");
        assert_eq!(value["type"], "plan_file_updated");
        assert_eq!(value["session_id"], "sess-1");
        assert_eq!(value["file_path"], "/tmp/plans/sess-1.md");
        assert_eq!(
            value["content_summary"],
            "Implementation plan for feature X"
        );
    }

    #[test]
    fn plan_mode_events_deserialize_without_optional_fields() {
        let json = serde_json::json!({
            "type": "plan_mode_entered",
            "session_id": "sess-1",
            "pre_permission_mode": "default"
        });

        let event: AgentEvent = serde_json::from_value(json).expect("should deserialize");
        match event {
            AgentEvent::PlanModeEntered {
                session_id,
                reason,
                pre_permission_mode,
            } => {
                assert_eq!(session_id, "sess-1");
                assert_eq!(reason, None);
                assert_eq!(pre_permission_mode, "default");
            }
            other => panic!("unexpected event: {other:?}"),
        }
    }
}