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::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}