Skip to main content

recursive/
event.rs

1//! Event-sink abstraction for consuming agent lifecycle events.
2//!
3//! This module defines [`AgentEvent`] (a serialisable, non-exhaustive enum of
4//! agent lifecycle events), the [`EventSink`] trait (the abstract consumer),
5//! and four concrete implementations:
6//!
7//! * [`ChannelSink`] — delivers events into a `tokio::sync::mpsc` channel.
8//! * [`BroadcastSink`] — delivers events into a `tokio::sync::broadcast` channel.
9//! * [`NullSink`] — discards every event (no-op).
10//! * [`CompositeSink`] — fans out to multiple inner sinks.
11
12use serde::{Deserialize, Serialize};
13use tokio::sync::broadcast;
14use tokio::sync::mpsc;
15
16// ---------------------------------------------------------------------------
17// AgentEvent
18// ---------------------------------------------------------------------------
19
20/// A serialisable, non-exhaustive agent lifecycle event.
21///
22/// Uses `String` for the finish reason to avoid coupling to the
23/// `FinishReason` type.  New variants can be added without a breaking change
24/// (see `#[non_exhaustive]`).
25#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
26#[serde(tag = "type", rename_all = "snake_case")]
27#[non_exhaustive]
28pub enum AgentEvent {
29    /// Model generated text without tool calls.
30    AssistantText { text: String, step: usize },
31    /// Model requested to execute a tool.
32    ToolCall {
33        name: String,
34        id: String,
35        arguments: String,
36        step: usize,
37    },
38    /// Time taken for the LLM request (excluding tool execution), in ms.
39    Latency { step: usize, llm_ms: u64 },
40    /// Result of executing a tool call.
41    ToolResult {
42        id: String,
43        name: String,
44        output: String,
45        step: usize,
46    },
47    /// Token usage statistics from the LLM provider.
48    Usage {
49        input_tokens: u32,
50        output_tokens: u32,
51        step: usize,
52    },
53    /// Partial token from streaming response (if streaming enabled).
54    PartialToken { text: String, step: usize },
55    /// Reasoning / thinking content from a model that exposes an
56    /// explicit reasoning channel (DeepSeek R1, OpenAI o1, etc.).
57    /// Carries the full reasoning text for the current step;
58    /// providers that stream reasoning tokens (DeepSeek's
59    /// `reasoning_content` SSE deltas) accumulate them and emit
60    /// the final, fully-joined string in this event. UI layers
61    /// render it as a `thinking…` block separate from the
62    /// assistant message body. Emitted exactly once per step
63    /// that produced reasoning content; steps without reasoning
64    /// skip the event.
65    Reasoning { text: String, step: usize },
66    /// Transcript was compacted to fit size constraints.
67    Compacted {
68        removed: usize,
69        kept: usize,
70        summary_chars: usize,
71        step: usize,
72    },
73    /// Agent run completed.
74    TurnFinished {
75        /// Human-readable reason for termination (e.g. "no_more_tool_calls").
76        reason: String,
77        /// Number of iterations executed.
78        steps: usize,
79    },
80    /// Goal-202: Agent is requesting permission to enter plan mode.
81    /// Emitted by `RequestPlanModeTool` before any exploration begins.
82    /// The TUI / HTTP surface should prompt the user and call
83    /// `AgentRuntime::approve_plan_mode_request` or `reject_plan_mode_request`.
84    PlanModeRequested { reason: String },
85    /// Goal-202: The user approved the plan-mode entry request.
86    PlanModeApproved,
87    /// Goal-202: The user rejected the plan-mode entry request.
88    PlanModeRejected { reason: String },
89
90    /// Agent has produced a plan and is waiting for confirmation.
91    PlanProposed {
92        plan_text: String,
93        tool_calls: Vec<serde_json::Value>,
94    },
95    /// Plan was confirmed, execution will proceed.
96    PlanConfirmed,
97    /// Plan was rejected with a reason.
98    PlanRejected { reason: String },
99
100    /// A complete message was just appended to the agent transcript.
101    ///
102    /// Fired exactly once per committed message inside the agent runtime:
103    /// once for the user message that starts a turn, once for the compaction
104    /// summary if cross-turn compaction fires, and once per message in the
105    /// kernel's output batch. Carries the full `Message` (role, content,
106    /// tool_calls, tool_call_id, reasoning_content) so persistence consumers
107    /// can write the canonical record without reassembling it from the finer
108    /// `AssistantText` / `ToolCall` / `ToolResult` streaming events.
109    ///
110    /// `parent_uuid` — if `Some`, the emitter wants this message to be written
111    /// as a branch off the given UUID rather than the SessionWriter's internal
112    /// chain pointer. Used by subagent runtimes (g155).
113    ///
114    /// `usage` — token usage for this message (non-None for assistant messages
115    /// produced by an LLM call, g156).
116    ///
117    /// Not emitted for seeded transcript messages loaded from an existing
118    /// session on resume (those are already on disk).
119    MessageAppended {
120        message: crate::message::Message,
121        /// Explicit parent UUID override for subagent branch points (g155).
122        parent_uuid: Option<String>,
123        /// Token usage for this message (g156).
124        usage: Option<crate::session::UsageMeta>,
125    },
126
127    /// Variant of [`MessageAppended`] specifically for `Role::Tool` messages
128    /// that have an associated [`AuditMeta`] (Goal 153). The persistence sink
129    /// handles this identically to `MessageAppended` but populates the
130    /// `audit` field of [`crate::session::TranscriptEntry`].
131    ///
132    /// Emitting a separate variant (rather than `Option<AuditMeta>` on
133    /// `MessageAppended`) keeps the common path zero-cost and avoids
134    /// making audit an optional field on every event.
135    MessageAppendedWithAudit {
136        message: crate::message::Message,
137        audit: crate::tools::AuditMeta,
138    },
139
140    /// Cross-turn compaction just fired; a compact_boundary marker should be
141    /// written to the session JSONL (g157).
142    ///
143    /// `turn` — the turn index when compaction occurred.
144    /// `compacted_count` — how many messages were removed.
145    /// `summary_uuid` — UUID of the compaction summary message that replaced them.
146    CompactionBoundary {
147        turn: u32,
148        compacted_count: usize,
149        summary_uuid: Option<String>,
150    },
151
152    /// Goal-167: emitted when the agent updates its task checklist via
153    /// `todo_write`. Carries the complete replacement list so consumers
154    /// can render the current state without storing diffs.
155    TodoUpdated {
156        todos: Vec<crate::tools::todo::TodoItem>,
157    },
158
159    // ── Goal-168: /goal condition-based autonomous loop ──────────────
160    /// A `/goal` was set for this session.
161    GoalSet {
162        /// The completion condition as written by the user.
163        condition: String,
164        /// Hard cap on autonomous turns.
165        max_turns: u32,
166    },
167    /// The judge evaluated the condition and found it not yet met.
168    /// The loop will continue with another turn.
169    GoalContinuing {
170        /// The judge's explanation for why the condition is not yet met.
171        reason: String,
172        /// Number of turns elapsed so far.
173        turns: u32,
174    },
175    /// The judge evaluated the condition and confirmed it is met.
176    GoalAchieved {
177        /// The original condition string.
178        condition: String,
179        /// Total turns taken to reach the goal.
180        turns: u32,
181    },
182    /// The active goal was cleared — either by `/goal clear`, turn-budget
183    /// exhaustion, or an explicit `DELETE /sessions/:id/goal` call.
184    GoalCleared,
185
186    // ── Goal 210: hook progress events ────────────────────────────
187    /// A hook started executing.
188    HookStarted {
189        hook_event: String,
190        hook_name: String,
191        status_message: Option<String>,
192    },
193    /// A hook produced incremental stdout output.
194    HookProgress {
195        hook_event: String,
196        hook_name: String,
197        last_line: String,
198    },
199    /// A hook finished executing.
200    HookFinished {
201        hook_event: String,
202        hook_name: String,
203        outcome: String,
204        duration_ms: u64,
205    },
206    /// A hook produced a system message to show to the user.
207    HookSystemMessage { text: String },
208}
209
210// ---------------------------------------------------------------------------
211// EventSink trait
212// ---------------------------------------------------------------------------
213
214/// Abstract consumer of [`AgentEvent`]s.
215///
216/// Implementations are free to buffer, forward, filter, or discard events.
217/// The trait is designed to be object-safe so that a single `Box<dyn EventSink>`
218/// can hold any implementation.
219#[async_trait::async_trait]
220pub trait EventSink: Send + Sync + 'static {
221    /// Deliver one event to this sink.
222    ///
223    /// Implementations should not panic. If the sink is full or closed, the
224    /// event may be silently dropped (or an error logged).
225    async fn emit(&self, event: AgentEvent);
226}
227
228// ---------------------------------------------------------------------------
229// ChannelSink
230// ---------------------------------------------------------------------------
231
232/// An [`EventSink`] that sends events into a `tokio::sync::mpsc` channel.
233///
234/// If the channel is full (respects the bounded capacity) the event is
235/// silently dropped.
236pub struct ChannelSink {
237    tx: mpsc::UnboundedSender<AgentEvent>,
238}
239
240impl ChannelSink {
241    /// Create a new `ChannelSink` and return it together with the receiver
242    /// half.
243    pub fn new() -> (Self, mpsc::UnboundedReceiver<AgentEvent>) {
244        let (tx, rx) = mpsc::unbounded_channel();
245        (Self { tx }, rx)
246    }
247}
248
249#[async_trait::async_trait]
250impl EventSink for ChannelSink {
251    async fn emit(&self, event: AgentEvent) {
252        let _ = self.tx.send(event);
253    }
254}
255
256// ---------------------------------------------------------------------------
257// BroadcastSink
258// ---------------------------------------------------------------------------
259
260/// An [`EventSink`] that sends events into a `tokio::sync::broadcast` channel.
261///
262/// If all receivers are lagging behind the channel capacity, the oldest
263/// events are dropped (standard broadcast behaviour).
264pub struct BroadcastSink {
265    tx: broadcast::Sender<AgentEvent>,
266}
267
268impl BroadcastSink {
269    /// Create a new `BroadcastSink` with the given channel capacity.
270    pub fn new(capacity: usize) -> (Self, broadcast::Receiver<AgentEvent>) {
271        let (tx, rx) = broadcast::channel(capacity);
272        (Self { tx }, rx)
273    }
274}
275
276#[async_trait::async_trait]
277impl EventSink for BroadcastSink {
278    async fn emit(&self, event: AgentEvent) {
279        let _ = self.tx.send(event);
280    }
281}
282
283// ---------------------------------------------------------------------------
284// NullSink
285// ---------------------------------------------------------------------------
286
287/// An [`EventSink`] that discards every event.
288pub struct NullSink;
289
290#[async_trait::async_trait]
291impl EventSink for NullSink {
292    async fn emit(&self, _event: AgentEvent) {
293        // no-op
294    }
295}
296
297// ---------------------------------------------------------------------------
298// CompositeSink
299// ---------------------------------------------------------------------------
300
301/// An [`EventSink`] that fans out to multiple inner sinks.
302///
303/// Each inner sink receives every event. If any sink panics, the panic
304/// propagates (i.e. there is no `catch_unwind` barrier).
305pub struct CompositeSink {
306    sinks: Vec<Box<dyn EventSink>>,
307}
308
309impl CompositeSink {
310    /// Create a new `CompositeSink` from an iterator of sinks.
311    pub fn new(sinks: impl IntoIterator<Item = Box<dyn EventSink>>) -> Self {
312        Self {
313            sinks: sinks.into_iter().collect(),
314        }
315    }
316}
317
318#[async_trait::async_trait]
319impl EventSink for CompositeSink {
320    async fn emit(&self, event: AgentEvent) {
321        for sink in &self.sinks {
322            sink.emit(event.clone()).await;
323        }
324    }
325}
326
327// ---------------------------------------------------------------------------
328// Tests
329// ---------------------------------------------------------------------------
330
331#[cfg(test)]
332mod tests {
333    use super::*;
334
335    // -- ChannelSink -------------------------------------------------------
336
337    #[tokio::test]
338    async fn channel_sink_delivers_events() {
339        let (sink, mut rx) = ChannelSink::new();
340        let event = AgentEvent::AssistantText {
341            text: "hello".into(),
342            step: 0,
343        };
344
345        sink.emit(event.clone()).await;
346        let received = rx.recv().await.unwrap();
347        assert_eq!(received, event);
348    }
349
350    // -- BroadcastSink -----------------------------------------------------
351
352    #[tokio::test]
353    async fn broadcast_sink_delivers_to_multiple() {
354        let (sink, mut rx1) = BroadcastSink::new(16);
355        let mut rx2 = sink.tx.subscribe();
356
357        let event = AgentEvent::PlanConfirmed;
358        sink.emit(event.clone()).await;
359
360        assert_eq!(rx1.recv().await.unwrap(), event);
361        assert_eq!(rx2.recv().await.unwrap(), event);
362    }
363
364    // -- NullSink ----------------------------------------------------------
365
366    #[tokio::test]
367    async fn null_sink_does_not_panic() {
368        let sink = NullSink;
369        sink.emit(AgentEvent::PlanConfirmed).await;
370        // If we reach here, the test passes.
371    }
372
373    // -- CompositeSink -----------------------------------------------------
374
375    #[tokio::test]
376    async fn composite_sink_fans_out() {
377        let (sink1, mut rx1) = ChannelSink::new();
378        let (sink2, mut rx2) = ChannelSink::new();
379
380        let composite = CompositeSink::new(vec![
381            Box::new(sink1) as Box<dyn EventSink>,
382            Box::new(sink2) as Box<dyn EventSink>,
383        ]);
384
385        let event = AgentEvent::PlanConfirmed;
386        composite.emit(event.clone()).await;
387
388        assert_eq!(rx1.recv().await.unwrap(), event);
389        assert_eq!(rx2.recv().await.unwrap(), event);
390    }
391
392    // -- Serialisation round-trip ------------------------------------------
393
394    #[test]
395    fn agent_event_serialization_round_trip() {
396        let events = vec![
397            AgentEvent::AssistantText {
398                text: "hello".into(),
399                step: 0,
400            },
401            AgentEvent::ToolCall {
402                name: "foo".into(),
403                id: "1".into(),
404                arguments: "{}".into(),
405                step: 1,
406            },
407            AgentEvent::Latency {
408                step: 2,
409                llm_ms: 100,
410            },
411            AgentEvent::ToolResult {
412                id: "1".into(),
413                name: "foo".into(),
414                output: "ok".into(),
415                step: 3,
416            },
417            AgentEvent::Usage {
418                input_tokens: 10,
419                output_tokens: 20,
420                step: 4,
421            },
422            AgentEvent::PartialToken {
423                text: "hel".into(),
424                step: 5,
425            },
426            AgentEvent::Compacted {
427                removed: 5,
428                kept: 3,
429                summary_chars: 200,
430                step: 6,
431            },
432            AgentEvent::TurnFinished {
433                reason: "done".into(),
434                steps: 7,
435            },
436            AgentEvent::PlanProposed {
437                plan_text: "plan".into(),
438                tool_calls: vec![],
439            },
440            AgentEvent::PlanConfirmed,
441            AgentEvent::PlanRejected {
442                reason: "nope".into(),
443            },
444        ];
445
446        for event in &events {
447            let json = serde_json::to_string(event).unwrap();
448            let deserialized: AgentEvent = serde_json::from_str(&json).unwrap();
449            assert_eq!(*event, deserialized, "round-trip failed for {json}");
450        }
451    }
452
453    // -- MessageAppended unit tests ----------------------------------------
454
455    /// `MessageAppended` carries all three fields (content, tool_calls,
456    /// reasoning_content) through a JSON round-trip intact.
457    #[test]
458    fn message_appended_round_trips_through_event() {
459        use crate::llm::ToolCall as LlmToolCall;
460        use crate::message::Message;
461
462        let tc = LlmToolCall {
463            id: "call_1".into(),
464            name: "read_file".into(),
465            arguments: serde_json::json!({"path": "/tmp/foo"}),
466        };
467        let msg = Message {
468            role: crate::message::Role::Assistant,
469            content: "some text".into(),
470            tool_calls: vec![tc.clone()],
471            tool_call_id: None,
472            reasoning_content: Some("my reasoning".into()),
473        };
474        let event = AgentEvent::MessageAppended {
475            message: msg.clone(),
476            parent_uuid: None,
477            usage: None,
478        };
479        let json = serde_json::to_string(&event).unwrap();
480        let deserialized: AgentEvent = serde_json::from_str(&json).unwrap();
481        assert_eq!(event, deserialized);
482        if let AgentEvent::MessageAppended { message: m, .. } = deserialized {
483            assert_eq!(m.content, "some text");
484            assert_eq!(m.reasoning_content.as_deref(), Some("my reasoning"));
485            assert_eq!(m.tool_calls.len(), 1);
486            assert_eq!(m.tool_calls[0].name, "read_file");
487        } else {
488            panic!("deserialized to wrong variant");
489        }
490    }
491
492    /// `CompositeSink` fans out `MessageAppended` to both inner sinks.
493    #[tokio::test]
494    async fn composite_sink_preserves_message_appended() {
495        use crate::message::Message;
496
497        let (sink1, mut rx1) = ChannelSink::new();
498        let (sink2, mut rx2) = ChannelSink::new();
499        let composite = CompositeSink::new(vec![
500            Box::new(sink1) as Box<dyn EventSink>,
501            Box::new(sink2) as Box<dyn EventSink>,
502        ]);
503
504        let msg = Message::user("hello");
505        let event = AgentEvent::MessageAppended {
506            message: msg.clone(),
507            parent_uuid: None,
508            usage: None,
509        };
510        composite.emit(event.clone()).await;
511
512        let got1 = rx1.recv().await.unwrap();
513        let got2 = rx2.recv().await.unwrap();
514        assert_eq!(got1, event);
515        assert_eq!(got2, event);
516
517        // Other variant also propagates.
518        composite.emit(AgentEvent::PlanConfirmed).await;
519        assert_eq!(rx1.recv().await.unwrap(), AgentEvent::PlanConfirmed);
520        assert_eq!(rx2.recv().await.unwrap(), AgentEvent::PlanConfirmed);
521    }
522}