Skip to main content

ri_agent_graph/
event_sink.rs

1//! Structured event pipeline for graph execution.
2//!
3//! [`EventSink`] is the trait for emitting runtime events. Implementations
4//! must be non-blocking — use channels or fire-and-forget internally.
5
6#![allow(deprecated)] // Internal code constructs/destructures GraphEvent legacy fields
7
8use serde::{Deserialize, Serialize};
9use serde_json::Value;
10use std::collections::HashMap;
11use std::sync::Arc;
12
13use crate::stream::StreamEvent;
14
15/// Structured runtime event emitted during graph execution.
16///
17/// Every variant carries both legacy and canonical trace/retry fields:
18///
19/// - `trace_id: String` — legacy correlation ID.
20/// - `trace_ctx: Option<stack_ids::TraceCtx>` — canonical trace context.
21/// - `attempt: u32` / `attempt_id: Option<stack_ids::AttemptId>` — retry family.
22/// - `trial_id: Option<stack_ids::TrialId>` — individual execution trial (on
23///   variants representing a single execution attempt).
24///
25/// ## Legacy field phase status: compatibility / migration-only
26///
27/// The `trace_id: String` and `attempt: u32` fields use primitive types for
28/// backward compatibility. The canonical replacements are `stack_ids::TraceCtx`,
29/// `stack_ids::AttemptId`, and `stack_ids::TrialId`.
30///
31/// **Removal condition**: legacy fields removed when all event consumers migrate.
32#[derive(Debug, Clone, Serialize, Deserialize)]
33pub enum GraphEvent {
34    /// A graph run started.
35    RunStart {
36        run_id: String,
37        /// Phase status: compatibility / migration-only.
38        /// Removal condition: all consumers migrate to `trace_ctx`/`attempt_id`/`trial_id`.
39        #[deprecated(
40            note = "Use trace_ctx/attempt_id/trial_id instead. Will be removed when all consumers migrate."
41        )]
42        trace_id: String,
43        #[serde(skip_serializing_if = "Option::is_none", default)]
44        trace_ctx: Option<stack_ids::TraceCtx>,
45        graph_name: Option<String>,
46    },
47    /// A graph run ended.
48    RunEnd {
49        run_id: String,
50        /// Phase status: compatibility / migration-only.
51        /// Removal condition: all consumers migrate to `trace_ctx`/`attempt_id`/`trial_id`.
52        #[deprecated(
53            note = "Use trace_ctx/attempt_id/trial_id instead. Will be removed when all consumers migrate."
54        )]
55        trace_id: String,
56        #[serde(skip_serializing_if = "Option::is_none", default)]
57        trace_ctx: Option<stack_ids::TraceCtx>,
58    },
59    /// A node execution attempt started.
60    NodeStart {
61        run_id: String,
62        /// Phase status: compatibility / migration-only.
63        /// Removal condition: all consumers migrate to `trace_ctx`/`attempt_id`/`trial_id`.
64        #[deprecated(
65            note = "Use trace_ctx/attempt_id/trial_id instead. Will be removed when all consumers migrate."
66        )]
67        trace_id: String,
68        #[serde(skip_serializing_if = "Option::is_none", default)]
69        trace_ctx: Option<stack_ids::TraceCtx>,
70        node_id: String,
71        /// Phase status: compatibility / migration-only.
72        /// Removal condition: all consumers migrate to `trace_ctx`/`attempt_id`/`trial_id`.
73        #[deprecated(
74            note = "Use trace_ctx/attempt_id/trial_id instead. Will be removed when all consumers migrate."
75        )]
76        attempt: u32,
77        #[serde(skip_serializing_if = "Option::is_none", default)]
78        attempt_id: Option<stack_ids::AttemptId>,
79        #[serde(skip_serializing_if = "Option::is_none", default)]
80        trial_id: Option<stack_ids::TrialId>,
81    },
82    /// A node execution attempt ended.
83    NodeEnd {
84        run_id: String,
85        /// Phase status: compatibility / migration-only.
86        /// Removal condition: all consumers migrate to `trace_ctx`/`attempt_id`/`trial_id`.
87        #[deprecated(
88            note = "Use trace_ctx/attempt_id/trial_id instead. Will be removed when all consumers migrate."
89        )]
90        trace_id: String,
91        #[serde(skip_serializing_if = "Option::is_none", default)]
92        trace_ctx: Option<stack_ids::TraceCtx>,
93        node_id: String,
94        outcome: NodeOutcomeKind,
95        #[serde(skip_serializing_if = "Option::is_none", default)]
96        attempt_id: Option<stack_ids::AttemptId>,
97        #[serde(skip_serializing_if = "Option::is_none", default)]
98        trial_id: Option<stack_ids::TrialId>,
99    },
100    /// A streaming token from a payload node.
101    Token {
102        run_id: String,
103        /// Phase status: compatibility / migration-only.
104        /// Removal condition: all consumers migrate to `trace_ctx`/`attempt_id`/`trial_id`.
105        #[deprecated(
106            note = "Use trace_ctx/attempt_id/trial_id instead. Will be removed when all consumers migrate."
107        )]
108        trace_id: String,
109        #[serde(skip_serializing_if = "Option::is_none", default)]
110        trace_ctx: Option<stack_ids::TraceCtx>,
111        node_id: String,
112        token: String,
113    },
114    /// A checkpoint was written.
115    CheckpointWritten {
116        run_id: String,
117        /// Phase status: compatibility / migration-only.
118        /// Removal condition: all consumers migrate to `trace_ctx`/`attempt_id`/`trial_id`.
119        #[deprecated(
120            note = "Use trace_ctx/attempt_id/trial_id instead. Will be removed when all consumers migrate."
121        )]
122        trace_id: String,
123        #[serde(skip_serializing_if = "Option::is_none", default)]
124        trace_ctx: Option<stack_ids::TraceCtx>,
125        /// The checkpoint-level attempt ID (distinct from `stack_ids::AttemptId`).
126        #[serde(alias = "attempt_id")]
127        checkpoint_attempt_id: String,
128    },
129    /// An interrupt was raised by a node.
130    InterruptRaised {
131        run_id: String,
132        /// Phase status: compatibility / migration-only.
133        /// Removal condition: all consumers migrate to `trace_ctx`/`attempt_id`/`trial_id`.
134        #[deprecated(
135            note = "Use trace_ctx/attempt_id/trial_id instead. Will be removed when all consumers migrate."
136        )]
137        trace_id: String,
138        #[serde(skip_serializing_if = "Option::is_none", default)]
139        trace_ctx: Option<stack_ids::TraceCtx>,
140        node_id: String,
141        kind: String,
142        payload: Value,
143    },
144    /// State was updated by a node.
145    StateUpdate {
146        run_id: String,
147        /// Phase status: compatibility / migration-only.
148        /// Removal condition: all consumers migrate to `trace_ctx`/`attempt_id`/`trial_id`.
149        #[deprecated(
150            note = "Use trace_ctx/attempt_id/trial_id instead. Will be removed when all consumers migrate."
151        )]
152        trace_id: String,
153        #[serde(skip_serializing_if = "Option::is_none", default)]
154        trace_ctx: Option<stack_ids::TraceCtx>,
155        node_id: String,
156        updates: HashMap<String, Value>,
157    },
158    /// A parallel superstep started.
159    SuperstepStart {
160        run_id: String,
161        /// Phase status: compatibility / migration-only.
162        /// Removal condition: all consumers migrate to `trace_ctx`/`attempt_id`/`trial_id`.
163        #[deprecated(
164            note = "Use trace_ctx/attempt_id/trial_id instead. Will be removed when all consumers migrate."
165        )]
166        trace_id: String,
167        #[serde(skip_serializing_if = "Option::is_none", default)]
168        trace_ctx: Option<stack_ids::TraceCtx>,
169        step: usize,
170        nodes: Vec<String>,
171    },
172    /// A parallel superstep ended.
173    SuperstepEnd {
174        run_id: String,
175        /// Phase status: compatibility / migration-only.
176        /// Removal condition: all consumers migrate to `trace_ctx`/`attempt_id`/`trial_id`.
177        #[deprecated(
178            note = "Use trace_ctx/attempt_id/trial_id instead. Will be removed when all consumers migrate."
179        )]
180        trace_id: String,
181        #[serde(skip_serializing_if = "Option::is_none", default)]
182        trace_ctx: Option<stack_ids::TraceCtx>,
183        step: usize,
184    },
185    /// Remaining parallel branches were cancelled after a sibling failed.
186    ParallelCancellation {
187        run_id: String,
188        #[deprecated(note = "Use trace_ctx instead.")]
189        trace_id: String,
190        #[serde(skip_serializing_if = "Option::is_none", default)]
191        trace_ctx: Option<stack_ids::TraceCtx>,
192        /// Cancellation cannot undo effects already handed to external systems.
193        external_effects_may_have_escaped: bool,
194    },
195}
196
197/// Summary of a node's outcome (for event reporting).
198#[derive(Debug, Clone, Serialize, Deserialize)]
199pub enum NodeOutcomeKind {
200    Success,
201    Failed,
202    Interrupted,
203}
204
205/// Trait for emitting structured runtime events.
206///
207/// Implementations must be non-blocking. If the downstream consumer
208/// is slow, implementations should drop events rather than block.
209pub trait EventSink: Send + Sync {
210    /// Emit a structured event. Must not block.
211    fn emit(&self, event: GraphEvent);
212}
213
214/// No-op event sink that discards all events.
215pub struct NoopEventSink;
216
217impl EventSink for NoopEventSink {
218    fn emit(&self, _event: GraphEvent) {}
219}
220
221/// Event sink that forwards to a `tokio::sync::mpsc::Sender<StreamEvent>`.
222///
223/// Bridges the new [`GraphEvent`] system to the legacy [`StreamEvent`] channel
224/// used by [`AgentGraph::stream()`](crate::graph::AgentGraph::stream).
225pub struct ChannelEventSink {
226    sender: tokio::sync::mpsc::Sender<StreamEvent>,
227}
228
229impl ChannelEventSink {
230    pub fn new(sender: tokio::sync::mpsc::Sender<StreamEvent>) -> Self {
231        Self { sender }
232    }
233}
234
235impl EventSink for ChannelEventSink {
236    fn emit(&self, event: GraphEvent) {
237        let stream_event = match event {
238            GraphEvent::RunStart { graph_name, .. } => StreamEvent::GraphStart { graph_name },
239            GraphEvent::RunEnd { .. } => StreamEvent::GraphEnd { graph_name: None },
240            GraphEvent::NodeStart { node_id, .. } => StreamEvent::NodeStart { node: node_id },
241            GraphEvent::NodeEnd { node_id, .. } => StreamEvent::NodeEnd { node: node_id },
242            GraphEvent::Token {
243                run_id,
244                node_id,
245                token,
246                ..
247            } => StreamEvent::Custom(serde_json::json!({
248                "type": "token",
249                "run_id": run_id,
250                "node": node_id,
251                "token": token,
252            })),
253            GraphEvent::InterruptRaised {
254                node_id, payload, ..
255            } => StreamEvent::Interrupt {
256                node: node_id,
257                value: Some(payload),
258            },
259            GraphEvent::StateUpdate {
260                node_id, updates, ..
261            } => StreamEvent::StateUpdate {
262                node: node_id,
263                updates,
264            },
265            GraphEvent::SuperstepStart { step, nodes, .. } => {
266                StreamEvent::SuperstepStart { step, nodes }
267            }
268            GraphEvent::SuperstepEnd { step, .. } => StreamEvent::SuperstepEnd { step },
269            GraphEvent::ParallelCancellation { .. } => return,
270            GraphEvent::CheckpointWritten { .. } => return, // no legacy equivalent
271        };
272        // try_send: non-blocking, drops if channel full
273        let _ = self.sender.try_send(stream_event);
274    }
275}
276
277/// Event sink that calls a user-provided closure for each event.
278pub struct CallbackEventSink<F: Fn(GraphEvent) + Send + Sync> {
279    callback: F,
280}
281
282impl<F: Fn(GraphEvent) + Send + Sync> CallbackEventSink<F> {
283    pub fn new(callback: F) -> Self {
284        Self { callback }
285    }
286}
287
288impl<F: Fn(GraphEvent) + Send + Sync> EventSink for CallbackEventSink<F> {
289    fn emit(&self, event: GraphEvent) {
290        (self.callback)(event);
291    }
292}
293
294/// Event sink that fans out to multiple sinks.
295pub struct CompositeEventSink {
296    sinks: Vec<Arc<dyn EventSink>>,
297}
298
299impl CompositeEventSink {
300    pub fn new(sinks: Vec<Arc<dyn EventSink>>) -> Self {
301        Self { sinks }
302    }
303}
304
305impl EventSink for CompositeEventSink {
306    fn emit(&self, event: GraphEvent) {
307        for sink in &self.sinks {
308            sink.emit(event.clone());
309        }
310    }
311}