rpi_agent/events.rs
1//! Mirrors `packages/agent/src/types.ts::AgentEvent` — the lifecycle events the
2//! agent loop emits. Carried over a `broadcast::Sender<AgentEvent>` so multiple
3//! subscribers each get their own copy; payloads are `Arc`-wrapped where they
4//! are large to keep broadcast clones cheap.
5//!
6//! The TS `AgentEvent` is a discriminated union on `type`. We model it as a
7//! tagged enum. It is `Debug + Clone` only — `AgentEvent` is not serialized
8//! across the wire (session persistence stores `AgentMessage`s, not events);
9//! tests compare event sequences via `Debug`. This avoids requiring
10//! `Deserialize` on `AssistantMessageEvent` (which is stream-protocol-only).
11
12use rpi_ai::types::{AssistantMessage, AssistantMessageEvent, ToolResultMessage};
13use std::sync::Arc;
14
15use crate::message::AgentMessage;
16use crate::types::AgentToolResult;
17
18/// An event emitted by the agent loop. Mirrors TS `AgentEvent` (tagged on
19/// `type`). `AgentMessage` payloads are owned (cloned) so broadcast subscribers
20/// get independent copies — except [`AgentEvent::MessageUpdate`], which carries
21/// the streaming partial as a cheap `Arc` because it fires once per delta.
22#[derive(Debug, Clone)]
23pub enum AgentEvent {
24 /// Emitted once at the start of a run, before `turn_start`.
25 AgentStart,
26 /// Emitted once at the end of a run; carries the new messages produced.
27 AgentEnd { messages: Vec<AgentMessage> },
28 /// Emitted after a retryable provider failure, before the backoff wait.
29 RetryScheduled {
30 /// One-based retry number (the initial request is not counted).
31 attempt: u32,
32 /// Maximum number of retries allowed for this request.
33 max_retries: u32,
34 /// Backoff duration before the next provider request.
35 delay_ms: u64,
36 /// Diagnostic from the failed provider request.
37 error: String,
38 },
39 /// Emitted at the start of each turn (a turn = one assistant response + its
40 /// tool calls/results).
41 TurnStart,
42 /// Emitted after a turn's assistant message + tool results settle. Carries
43 /// the assistant message (as an `AgentMessage`) and the tool-result
44 /// messages produced this turn, in source/ordinal order.
45 TurnEnd {
46 message: AgentMessage,
47 tool_results: Vec<ToolResultMessage>,
48 },
49 /// Emitted when any message (user prompt, assistant response, tool result,
50 /// custom) is appended to the transcript.
51 MessageStart { message: AgentMessage },
52 /// Emitted only for assistant messages, on each streaming delta. Carries
53 /// the underlying `AssistantMessageEvent` plus a **shared** snapshot of the
54 /// partial assistant message.
55 ///
56 /// The snapshot is an `Arc`, not an owned message: a delta used to deep-clone
57 /// the whole growing message here, again in the loop's context update, and
58 /// again in each consumer — quadratic in the output length. Consumers that
59 /// need it as JSON use [`crate::message::assistant_json`] to keep the wire
60 /// shape identical without copying.
61 MessageUpdate {
62 message: Arc<AssistantMessage>,
63 assistant_message_event: AssistantMessageEvent,
64 },
65 /// Emitted when a message finishes (complement of `MessageStart`).
66 MessageEnd { message: AgentMessage },
67 /// Emitted before a tool call starts executing.
68 ToolExecutionStart {
69 tool_call_id: String,
70 tool_name: String,
71 args: serde_json::Value,
72 },
73 /// Emitted on a partial tool result pushed via `on_update`.
74 ToolExecutionUpdate {
75 tool_call_id: String,
76 tool_name: String,
77 args: serde_json::Value,
78 partial_result: Arc<AgentToolResult>,
79 },
80 /// Emitted when a tool call finishes (success or error). `is_error` marks
81 /// the error path; `result` carries the final tool result.
82 ToolExecutionEnd {
83 tool_call_id: String,
84 tool_name: String,
85 result: AgentToolResult,
86 is_error: bool,
87 },
88}
89
90impl AgentEvent {
91 pub fn type_tag(&self) -> &'static str {
92 match self {
93 AgentEvent::AgentStart => "agent_start",
94 AgentEvent::AgentEnd { .. } => "agent_end",
95 AgentEvent::RetryScheduled { .. } => "retry_scheduled",
96 AgentEvent::TurnStart => "turn_start",
97 AgentEvent::TurnEnd { .. } => "turn_end",
98 AgentEvent::MessageStart { .. } => "message_start",
99 AgentEvent::MessageUpdate { .. } => "message_update",
100 AgentEvent::MessageEnd { .. } => "message_end",
101 AgentEvent::ToolExecutionStart { .. } => "tool_execution_start",
102 AgentEvent::ToolExecutionUpdate { .. } => "tool_execution_update",
103 AgentEvent::ToolExecutionEnd { .. } => "tool_execution_end",
104 }
105 }
106
107 /// True for the terminal `AgentEnd` event.
108 pub fn is_terminal(&self) -> bool {
109 matches!(self, AgentEvent::AgentEnd { .. })
110 }
111}
112
113/// Sink the loop pushes events into. Mirrors TS `AgentEventSink =
114/// (event: AgentEvent) => Promise<void> | void`. Implementations:
115/// [`CollectorEmitter`] (tests/drain), [`BroadcastEmitter`] (live `Agent`).
116///
117/// Two emit surfaces:
118/// - [`AgentEmitter::emit`] — async, awaited in event order by the loop.
119/// - [`AgentEmitter::try_emit`] — sync, non-blocking, used by tool `on_update`
120/// callbacks (which are `&dyn Fn` and cannot await). For `CollectorEmitter`
121/// this pushes under the mutex; for `BroadcastEmitter` it's `tx.send`.
122/// Order is preserved because `try_emit` is only ever called from within a
123/// single tool's `execute`, and `emit` is awaited between tool phases.
124pub trait AgentEmitter: Send + Sync {
125 /// Async emit — awaited by the loop so event order matches emit call order.
126 fn emit(&self, event: AgentEvent) -> futures::future::BoxFuture<'static, ()>;
127
128 /// Sync non-blocking emit for the tool `on_update` path. Must never panic
129 /// and must never block (it's called from a `&dyn Fn` closure inside
130 /// `execute`). Default impl is a no-op so custom emitters opt in.
131 fn try_emit(&self, _event: AgentEvent) {}
132}
133
134/// An emitter that fans out to `broadcast::Sender<AgentEvent>` subscribers.
135pub struct BroadcastEmitter {
136 tx: tokio::sync::broadcast::Sender<AgentEvent>,
137}
138
139impl BroadcastEmitter {
140 pub fn new(buffer: usize) -> (Self, tokio::sync::broadcast::Receiver<AgentEvent>) {
141 let (tx, rx) = tokio::sync::broadcast::channel(buffer);
142 (Self { tx }, rx)
143 }
144
145 pub fn from_sender(tx: tokio::sync::broadcast::Sender<AgentEvent>) -> Self {
146 Self { tx }
147 }
148
149 pub fn subscribe(&self) -> tokio::sync::broadcast::Receiver<AgentEvent> {
150 self.tx.subscribe()
151 }
152
153 pub fn try_emit(&self, event: AgentEvent) {
154 let _ = self.tx.send(event);
155 }
156}
157
158impl AgentEmitter for BroadcastEmitter {
159 fn emit(&self, event: AgentEvent) -> futures::future::BoxFuture<'static, ()> {
160 let _ = self.tx.send(event);
161 Box::pin(async {})
162 }
163 fn try_emit(&self, event: AgentEvent) {
164 let _ = self.tx.send(event);
165 }
166}
167
168/// An emitter that collects every event into a `Mutex<Vec<AgentEvent>>`. Used
169/// by tests and by `run_agent_loop` callers that just want the sequence.
170pub struct CollectorEmitter {
171 events: std::sync::Arc<std::sync::Mutex<Vec<AgentEvent>>>,
172}
173
174impl CollectorEmitter {
175 pub fn new() -> (Self, std::sync::Arc<std::sync::Mutex<Vec<AgentEvent>>>) {
176 let events = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
177 (
178 Self {
179 events: Arc::clone(&events),
180 },
181 events,
182 )
183 }
184}
185
186impl Default for CollectorEmitter {
187 fn default() -> Self {
188 let (s, _) = Self::new();
189 s
190 }
191}
192
193impl AgentEmitter for CollectorEmitter {
194 fn emit(&self, event: AgentEvent) -> futures::future::BoxFuture<'static, ()> {
195 self.events.lock().expect("events lock").push(event);
196 Box::pin(async {})
197 }
198 fn try_emit(&self, event: AgentEvent) {
199 self.events.lock().expect("events lock").push(event);
200 }
201}