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}