Skip to main content

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}