flashmind_types/event.rs
1//! Telemetry and control events for the agent turn loop.
2//!
3//! This module defines the event types and input structures that flow through
4//! the agent runtime during each turn.
5//!
6//! # Key types
7//!
8//! | Type | Role |
9//! |------|------|
10//! | [`AgentEvent`] | Stream of events emitted by the agent loop (text deltas, tool results, status…) |
11//! | [`AgentInput`] | Input that starts or resumes an agent session |
12//! | [`TurnStatus`] | Result of one iteration: `Done`, `ToolCalls`, `Continue`, or `Interrupted` |
13//! | [`TurnUsage`] | Token usage reported at the end of an LLM call |
14//! | [`Source`] | Web source attached to a tool result (URL + optional title) |
15//!
16//! # Event flow
17//!
18//! 1. Text arrives incrementally as [`AgentEvent::TextDelta`] (and [`AgentEvent::ReasoningDelta`])
19//! 2. Tool execution produces [`AgentEvent::ToolStart`] → [`AgentEvent::ToolResult`]
20//! 3. File modifications produce [`AgentEvent::FileDiff`]
21//! 4. The turn ends with [`AgentEvent::Done`] or [`AgentEvent::Error`]
22//!
23//! Listeners (REPL, Telegram, Slack, etc.) receive the event stream and render
24//! incrementally. Unknown variants should be silently ignored for forward compatibility.
25
26use std::sync::Arc;
27
28use serde::{Deserialize, Serialize};
29
30use crate::ToolCall;
31use crate::llm::TokenUsage;
32use crate::message::ContentPart;
33use crate::model::Model;
34use crate::tool::BackgroundRequest;
35use crate::tool::InterruptPayload;
36
37/// Input that starts or resumes an agent session.
38///
39/// The default variant is [`Resume`](Self::Resume), which re-processes
40/// the conversation without injecting new content.
41#[derive(Debug, Clone, Default)]
42pub enum AgentInput {
43 /// A new user message to process.
44 User {
45 /// The text content of the user message.
46 content: String,
47 /// Additional system-level context injected before the user message.
48 context: Option<String>,
49 /// Multimodal attachments (images, documents, etc.).
50 parts: Option<Vec<ContentPart>>,
51 },
52 /// Resume a paused conversation without injecting new content.
53 /// Used after tool interrupts or compaction retries.
54 #[default]
55 Resume,
56}
57
58impl AgentInput {
59 /// Short-hand to construct a simple text-only [`AgentInput::User`].
60 pub fn user(content: impl Into<String>) -> Self {
61 Self::User {
62 content: content.into(),
63 context: None,
64 parts: None,
65 }
66 }
67}
68
69/// Token usage reported at the end of an LLM call.
70#[derive(Debug, Clone, Copy, Default)]
71pub struct TurnUsage {
72 /// Number of tokens consumed by the prompt (input messages).
73 pub prompt_tokens: u32,
74 /// Number of tokens generated by the model (output/response).
75 pub completion_tokens: u32,
76 /// Prompt tokens served from the provider's cache.
77 pub cache_read_tokens: u32,
78 /// Prompt tokens written to the provider's cache.
79 pub cache_creation_tokens: u32,
80}
81
82/// Why compaction is needed after a turn.
83#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
84pub enum CompactionReason {
85 /// The model hit max output length without explicit `max_tokens`.
86 OutputLength,
87 /// Prompt tokens exceeded 90% of the context window.
88 ContextThreshold(u32),
89}
90
91/// Result of a single agent turn after the LLM has responded.
92#[derive(Debug)]
93pub enum TurnStatus {
94 /// No tool calls but the turn should continue (e.g. empty response retry).
95 Continue { content: String, usage: TurnUsage },
96 /// No tool calls and no more content expected — this session turn is done.
97 Done { content: String, usage: TurnUsage },
98 /// The LLM returned tool calls — caller is responsible for executing them.
99 ToolCalls {
100 content: String,
101 tool_calls: Vec<ToolCall>,
102 usage: TurnUsage,
103 },
104 /// A tool requested interactive user input. The agent loop has stopped;
105 /// the caller should collect the response, add a `ConversationEntry::tool`
106 /// with the result, and resume with `AgentInput::Resume`.
107 Interrupted {
108 tool_call_id: String,
109 tool_name: String,
110 output: String,
111 payload: Option<Arc<dyn InterruptPayload>>,
112 content: String,
113 usage: TurnUsage,
114 },
115 /// The conversation needs compaction before continuing.
116 CompactionNeeded {
117 content: String,
118 usage: TurnUsage,
119 reason: CompactionReason,
120 },
121}
122
123impl TurnStatus {
124 /// Extract the content and usage from this turn status, if available.
125 pub fn content_and_usage(&self) -> (&str, &TurnUsage) {
126 match self {
127 Self::Continue { content, usage }
128 | Self::Done { content, usage }
129 | Self::ToolCalls { content, usage, .. }
130 | Self::Interrupted { content, usage, .. }
131 | Self::CompactionNeeded { content, usage, .. } => (content, usage),
132 }
133 }
134}
135
136/// Alias for the final result of a turn.
137pub type TurnResult = anyhow::Result<TurnStatus>;
138
139/// A web source attached to a tool result (e.g. search result link).
140///
141/// Used by search tools to provide attribution URLs alongside results.
142#[derive(Debug, Clone, Serialize, Deserialize)]
143pub struct Source {
144 /// URL of the source page.
145 pub url: String,
146 /// Optional human-readable title of the source.
147 pub title: Option<String>,
148}
149
150/// Events emitted by the agent turn loop as a `Stream<Item = AgentEvent>`.
151///
152/// Listeners (REPL, Slack, Telegram, etc.) receive these and render them
153/// incrementally. Consumers should handle all variants; unknown variants
154/// should be silently ignored so forward-compatibility is preserved.
155#[derive(Debug, Clone, Serialize, Deserialize)]
156#[serde(tag = "type", content = "data", rename_all = "snake_case")]
157pub enum AgentEvent {
158 /// Incremental reasoning token (for models that surface internal thinking).
159 ReasoningDelta(String),
160 /// Incremental text token from the assistant message.
161 TextDelta(String),
162 /// A tool call has started execution.
163 ToolStart {
164 name: String,
165 id: String,
166 /// Human-readable summary of the arguments (for display in TUI/cards).
167 humanized: String,
168 },
169 /// A running tool accepts a user request to continue in the background.
170 ToolBackgroundReady {
171 id: String,
172 #[serde(skip)]
173 request: Option<BackgroundRequest>,
174 },
175 /// A tool has finished. The `output` may be truncated for display.
176 ToolResult {
177 name: String,
178 id: String,
179 output: String,
180 success: bool,
181 elapsed_ms: u64,
182 sources: Vec<Source>,
183 },
184 /// Incremental audio output chunk (raw bytes) for real-time playback.
185 AudioChunk { data: Vec<u8>, format: String },
186 /// Informational status message (compaction progress, retry attempts, etc.).
187 Status(String),
188 /// Conversation was compacted; payload is the summary text.
189 Compacted(String),
190 /// A file was modified by a tool.
191 FileDiff {
192 path: String,
193 diff: Vec<crate::tool::DiffLine>,
194 },
195 /// Token usage telemetry from the provider.
196 Usage(TokenUsage),
197 /// Final terminal event — processing complete with `String` as the full response.
198 Done(String),
199 /// The user cancelled the running turn.
200 Cancelled,
201 /// Terminal error event.
202 Error(String),
203 /// Incremental output from a running tool (e.g. stdout lines from bash).
204 ToolProgress { id: String, line: String },
205 /// A spawned agent produced an event while running in parallel.
206 SpawnedEvent {
207 id: String,
208 task: String,
209 model: Option<Model>,
210 event: Box<AgentEvent>,
211 },
212 /// A tool requested interactive input. Emitted when a turn ends with
213 /// `TurnStatus::Interrupted`. The caller should show the appropriate
214 /// picker, inject the tool result, and resume.
215 Interrupted {
216 tool_call_id: String,
217 tool_name: String,
218 output: String,
219 #[serde(skip)]
220 payload: Option<Arc<dyn InterruptPayload>>,
221 },
222 /// The conversation needs compaction but `auto_compact` is off.
223 /// The caller should compact and call `agent.start()` with `AgentInput::Resume`.
224 CompactionNeeded { reason: CompactionReason },
225}
226
227#[cfg(test)]
228mod tests {
229 use super::*;
230
231 #[test]
232 fn recorded_tool_events_do_not_restore_live_background_controls() {
233 let event = AgentEvent::ToolBackgroundReady {
234 id: "command".into(),
235 request: Some(BackgroundRequest::default()),
236 };
237 let serialized = serde_json::to_string(&event).unwrap();
238 let replayed: AgentEvent = serde_json::from_str(&serialized).unwrap();
239 assert!(matches!(
240 replayed,
241 AgentEvent::ToolBackgroundReady { request: None, .. }
242 ));
243 }
244
245 #[test]
246 fn agent_input_user_shorthand() {
247 let input = AgentInput::user("hello");
248 match input {
249 AgentInput::User {
250 content,
251 context,
252 parts,
253 } => {
254 assert_eq!(content, "hello");
255 assert!(context.is_none());
256 assert!(parts.is_none());
257 }
258 _ => panic!("expected User variant"),
259 }
260 }
261
262 #[test]
263 fn agent_input_default_is_resume() {
264 let input = AgentInput::default();
265 assert!(matches!(input, AgentInput::Resume));
266 }
267
268 #[test]
269 fn turn_status_content_and_usage_done() {
270 let status = TurnStatus::Done {
271 content: "response".into(),
272 usage: TurnUsage {
273 prompt_tokens: 10,
274 completion_tokens: 20,
275 ..TurnUsage::default()
276 },
277 };
278 let (content, usage) = status.content_and_usage();
279 assert_eq!(content, "response");
280 assert_eq!(usage.prompt_tokens, 10);
281 assert_eq!(usage.completion_tokens, 20);
282 }
283
284 #[test]
285 fn turn_status_content_and_usage_tool_calls() {
286 let status = TurnStatus::ToolCalls {
287 content: "thinking".into(),
288 tool_calls: vec![],
289 usage: TurnUsage {
290 prompt_tokens: 5,
291 completion_tokens: 15,
292 ..TurnUsage::default()
293 },
294 };
295 let (content, usage) = status.content_and_usage();
296 assert_eq!(content, "thinking");
297 assert_eq!(usage.completion_tokens, 15);
298 }
299
300 #[test]
301 fn turn_usage_default_is_zero() {
302 let usage = TurnUsage::default();
303 assert_eq!(usage.prompt_tokens, 0);
304 assert_eq!(usage.completion_tokens, 0);
305 }
306}