fluers_core/event.rs
1//! Run-lifecycle events and the [`EventSink`] seam.
2//!
3//! Mirrors Flue's `observe` / `FlueEventSubscriber`. The [`EventSink`] trait is
4//! the dependency-direction seam that lets [`crate::runner::run_agent`] (in
5//! `fluers-core`) emit events without depending on `fluers-runtime` (which holds
6//! the concrete [`EventBus`] implementation).
7//!
8//! [`EventBus`]: https://docs.rs/fluers-runtime
9
10use uuid::Uuid;
11
12/// An observable run-lifecycle event.
13///
14/// Events carry **no content** — no prompt text, tool arguments, tool outputs,
15/// file contents, or model response text. They carry only structural metadata
16/// (session id, turn number, model id, tool name, call id, success flag) so they
17/// are safe to export to any telemetry backend without leaking user data.
18#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
19pub enum RunEvent {
20 /// A session started (emitted once, before the first turn).
21 SessionStarted {
22 /// The session id.
23 session: Uuid,
24 },
25 /// A model turn began.
26 TurnStarted {
27 /// The session id.
28 session: Uuid,
29 /// The 1-indexed turn number.
30 turn: usize,
31 },
32 /// The model provider was invoked for this turn.
33 ModelStarted {
34 /// The session id.
35 session: Uuid,
36 /// The 1-indexed turn number.
37 turn: usize,
38 /// The model id (e.g. `"minimax/minimax-m3"`).
39 model: String,
40 },
41 /// The model provider returned for this turn.
42 ModelFinished {
43 /// The session id.
44 session: Uuid,
45 /// The 1-indexed turn number.
46 turn: usize,
47 },
48 /// A tool call was dispatched.
49 ToolStarted {
50 /// The session id.
51 session: Uuid,
52 /// The 1-indexed turn number.
53 turn: usize,
54 /// The tool name.
55 tool: String,
56 /// The tool call id (correlates with [`ToolFinished`]).
57 call_id: String,
58 },
59 /// A tool call completed.
60 ToolFinished {
61 /// The session id.
62 session: Uuid,
63 /// The 1-indexed turn number.
64 turn: usize,
65 /// The tool name.
66 tool: String,
67 /// The tool call id (correlates with [`ToolStarted`]).
68 call_id: String,
69 /// Whether the tool succeeded.
70 ok: bool,
71 },
72 /// A turn completed.
73 TurnFinished {
74 /// The session id.
75 session: Uuid,
76 /// The 1-indexed turn number.
77 turn: usize,
78 },
79 /// The run failed.
80 RunFailed {
81 /// The session id.
82 session: Uuid,
83 /// A **bounded** error summary. Truncated to keep user content out of
84 /// telemetry: provider/tool response bodies embedded in error strings
85 /// are capped at [`ERROR_SUMMARY_MAX_CHARS`] characters. Use this for
86 /// debugging signal, not as a source of truth about user data.
87 error: String,
88 },
89}
90
91/// Maximum number of characters retained in a [`RunEvent::RunFailed`] error
92/// summary. Keeps provider/tool response text out of telemetry exports.
93pub const ERROR_SUMMARY_MAX_CHARS: usize = 200;
94
95/// Build a [`RunEvent::RunFailed`] with a bounded error summary, so user-facing
96/// content embedded in error strings does not leak into telemetry.
97#[must_use]
98pub fn run_failed(session: Uuid, error: impl AsRef<str>) -> RunEvent {
99 let error = error.as_ref();
100 let summary = if error.len() > ERROR_SUMMARY_MAX_CHARS {
101 format!("{}…(truncated)", &error[..ERROR_SUMMARY_MAX_CHARS])
102 } else {
103 error.to_string()
104 };
105 RunEvent::RunFailed {
106 session,
107 error: summary,
108 }
109}
110
111/// A sink for [`RunEvent`]s — the dependency-direction seam between
112/// `fluers-core` (which emits) and `fluers-runtime::EventBus` (which fans out).
113///
114/// Implementations must be **non-blocking**: the agent loop calls
115/// [`emit`](EventSink::emit) inline, so a slow sink would stall the run. The
116/// canonical implementation (`fluers_runtime::EventBus`) is backed by a
117/// `tokio::broadcast` channel whose `send` is non-blocking.
118///
119/// This mirrors the [`crate::TurnSink`] pattern, which solved the same
120/// dependency cycle for per-turn persistence.
121pub trait EventSink: Send + Sync {
122 /// Emit `event` to all subscribers. Non-blocking; returns immediately.
123 fn emit(&self, event: RunEvent);
124}
125
126/// A no-op sink that discards every event. Used when no event bus is
127/// configured.
128#[derive(Default)]
129pub struct NullEventSink;
130
131impl EventSink for NullEventSink {
132 fn emit(&self, _event: RunEvent) {}
133}
134
135/// Grouped run hooks passed to [`crate::runner::run_agent`] /
136/// [`crate::runner::run_agent_streaming`].
137///
138/// Bundles the session id (for event correlation), the optional per-turn
139/// [`TurnSink`] (for persistence), and the optional [`EventSink`] (for
140/// observability). Replacing the old `on_turn: Option<&dyn TurnSink>` parameter
141/// with `&RunHooks` keeps the function signature stable while leaving room for
142/// future hooks without API churn.
143///
144/// Use [`RunHooks::default`] for a no-hooks run (no persistence, no events).
145#[derive(Default)]
146pub struct RunHooks<'a> {
147 /// The session id (for event correlation). `None` when the caller has no
148 /// session concept (e.g. a stateless one-shot run).
149 pub session_id: Option<Uuid>,
150 /// Optional per-turn sink (typically `SessionRunner` for persistence).
151 pub turn_sink: Option<&'a dyn crate::TurnSink>,
152 /// Optional event sink (typically `EventBus` for observability).
153 pub event_sink: Option<&'a dyn EventSink>,
154 /// Optional **tool policy hook** (Fae deviation; see the README). When set,
155 /// the agent loop consults it before each tool call and denies/confirms
156 /// per the returned [`crate::policy::PolicyVerdict`]. `None` (the default)
157 /// means allow-all — existing consumers are unaffected.
158 pub policy: Option<&'a dyn crate::policy::ToolPolicy>,
159}
160
161impl<'a> RunHooks<'a> {
162 /// Create hooks with only a turn sink (the pre-4c calling convention).
163 #[must_use]
164 pub fn from_turn_sink(turn_sink: &'a dyn crate::TurnSink) -> Self {
165 Self {
166 session_id: None,
167 turn_sink: Some(turn_sink),
168 event_sink: None,
169 policy: None,
170 }
171 }
172
173 /// Conditionally emit an event, constructed only when both a session id
174 /// and an event sink are configured. The closure receives the session id
175 /// so callers don't need to branch on `Option`:
176 ///
177 /// ```ignore
178 /// hooks.emit_event(|sid| RunEvent::TurnStarted { session: sid, turn: 1 });
179 /// ```
180 ///
181 /// When `session_id` or `event_sink` is `None`, the closure is never
182 /// called (zero cost) — no event is constructed or emitted.
183 pub fn emit_event(&self, make: impl FnOnce(Uuid) -> RunEvent) {
184 if let (Some(sid), Some(sink)) = (self.session_id, self.event_sink) {
185 sink.emit(make(sid));
186 }
187 }
188}
189
190#[cfg(test)]
191mod tests {
192 use super::*;
193
194 #[test]
195 fn run_failed_truncates_long_errors() {
196 let long = "x".repeat(ERROR_SUMMARY_MAX_CHARS * 2);
197 let event = run_failed(Uuid::nil(), &long);
198 match event {
199 RunEvent::RunFailed { error, .. } => {
200 assert!(error.len() < long.len(), "error not truncated");
201 assert!(error.ends_with("…(truncated)"));
202 }
203 _ => panic!("expected RunFailed"),
204 }
205 }
206
207 #[test]
208 fn run_failed_preserves_short_errors() {
209 let event = run_failed(Uuid::nil(), "short error");
210 match event {
211 RunEvent::RunFailed { error, .. } => {
212 assert_eq!(error, "short error");
213 }
214 _ => panic!("expected RunFailed"),
215 }
216 }
217
218 #[test]
219 fn run_failed_accepts_string_and_str() {
220 // Both &str and String should work (impl AsRef<str>).
221 let _ = run_failed(Uuid::nil(), "literal");
222 let _ = run_failed(Uuid::nil(), String::from("owned"));
223 }
224}