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