Skip to main content

aether_core/events/
agent_message.rs

1use acp_utils::notifications::{
2    SubAgentEvent, SubAgentToolCallUpdate, SubAgentToolError, SubAgentToolRequest, SubAgentToolResult,
3};
4use llm::{ToolCallError, ToolCallRequest, ToolCallResult};
5use mcp_utils::display_meta::ToolResultMeta;
6use serde::{Deserialize, Serialize};
7
8/// Message from the agent to the user.
9#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
10pub enum AgentMessage {
11    Text {
12        message_id: String,
13        chunk: String,
14        is_complete: bool,
15        model_name: String,
16    },
17
18    Thought {
19        message_id: String,
20        chunk: String,
21        is_complete: bool,
22        model_name: String,
23    },
24
25    ToolCall {
26        request: ToolCallRequest,
27        model_name: String,
28    },
29
30    ToolCallUpdate {
31        tool_call_id: String,
32        chunk: String,
33        model_name: String,
34    },
35
36    ToolProgress {
37        request: ToolCallRequest,
38        progress: f64,
39        total: Option<f64>,
40        message: Option<String>,
41    },
42
43    ToolResult {
44        result: ToolCallResult,
45        result_meta: Option<ToolResultMeta>,
46        model_name: String,
47    },
48
49    ToolError {
50        error: ToolCallError,
51        model_name: String,
52    },
53
54    Error {
55        message: String,
56    },
57
58    Cancelled {
59        message: String,
60    },
61
62    /// Context compaction has been triggered.
63    ContextCompactionStarted {
64        message_count: usize,
65    },
66
67    /// Context was compacted to reduce token usage.
68    ContextCompactionResult {
69        summary: String,
70        messages_removed: usize,
71    },
72
73    /// Context usage update for UI display.
74    ContextUsageUpdate {
75        /// Current usage ratio (0.0 - 1.0), if context window is known.
76        usage_ratio: Option<f64>,
77        /// Maximum context limit, if known.
78        context_limit: Option<u32>,
79        /// Input tokens on the most recent API call (the current context size).
80        input_tokens: u32,
81        /// Output tokens on the most recent API call.
82        output_tokens: u32,
83        /// Prompt tokens served from cache on the most recent API call.
84        cache_read_tokens: Option<u32>,
85        /// Prompt tokens written to cache on the most recent API call.
86        cache_creation_tokens: Option<u32>,
87        /// Reasoning tokens spent on the most recent API call.
88        reasoning_tokens: Option<u32>,
89        /// Cumulative input tokens since the agent started.
90        total_input_tokens: u64,
91        /// Cumulative output tokens since the agent started.
92        total_output_tokens: u64,
93        /// Cumulative cache-read tokens since the agent started.
94        total_cache_read_tokens: u64,
95        /// Cumulative cache-creation tokens since the agent started.
96        total_cache_creation_tokens: u64,
97        /// Cumulative reasoning tokens since the agent started.
98        total_reasoning_tokens: u64,
99    },
100
101    /// Agent is auto-continuing because LLM stopped with a resumable stop reason.
102    AutoContinue {
103        /// Current attempt number (1-indexed).
104        attempt: u32,
105        /// Maximum allowed attempts.
106        max_attempts: u32,
107    },
108
109    /// Agent is retrying after a transient LLM provider failure
110    Retrying {
111        /// Current retry attempt number (1-indexed).
112        attempt: u32,
113        /// Maximum allowed attempts.
114        max_attempts: u32,
115        /// Backoff delay in milliseconds before the retry fires.
116        delay_ms: u64,
117        /// The error that triggered the retry, formatted for display.
118        error: String,
119    },
120
121    /// The model was successfully switched.
122    ModelSwitched {
123        previous: String,
124        new: String,
125    },
126
127    /// The agent context was cleared and reset to its blank state.
128    ContextCleared,
129
130    Done,
131}
132
133impl From<&AgentMessage> for SubAgentEvent {
134    fn from(msg: &AgentMessage) -> Self {
135        match msg {
136            AgentMessage::ToolCall { request, .. } => SubAgentEvent::ToolCall {
137                request: SubAgentToolRequest {
138                    id: request.id.clone(),
139                    name: request.name.clone(),
140                    arguments: request.arguments.clone(),
141                },
142            },
143            AgentMessage::ToolCallUpdate { tool_call_id, chunk, .. } => SubAgentEvent::ToolCallUpdate {
144                update: SubAgentToolCallUpdate { id: tool_call_id.clone(), chunk: chunk.clone() },
145            },
146            AgentMessage::ToolResult { result, result_meta, .. } => SubAgentEvent::ToolResult {
147                result: SubAgentToolResult {
148                    id: result.id.clone(),
149                    name: result.name.clone(),
150                    result_meta: result_meta.clone(),
151                },
152            },
153            AgentMessage::ToolError { error, .. } => {
154                SubAgentEvent::ToolError { error: SubAgentToolError { id: error.id.clone(), name: error.name.clone() } }
155            }
156            AgentMessage::Done => SubAgentEvent::Done,
157            _ => SubAgentEvent::Other,
158        }
159    }
160}
161
162impl AgentMessage {
163    pub fn text(message_id: &str, chunk: &str, is_complete: bool, model_name: &str) -> Self {
164        AgentMessage::Text {
165            message_id: message_id.to_string(),
166            chunk: chunk.to_string(),
167            is_complete,
168            model_name: model_name.to_string(),
169        }
170    }
171
172    pub fn thought(message_id: &str, chunk: &str, is_complete: bool, model_name: &str) -> Self {
173        AgentMessage::Thought {
174            message_id: message_id.to_string(),
175            chunk: chunk.to_string(),
176            is_complete,
177            model_name: model_name.to_string(),
178        }
179    }
180}
181
182#[cfg(test)]
183mod tests {
184    use super::AgentMessage;
185    use acp_utils::notifications::SubAgentEvent;
186    use llm::ToolCallResult;
187    use mcp_utils::display_meta::ToolDisplayMeta;
188
189    #[test]
190    fn test_model_switched_serde_roundtrip() {
191        let msg = AgentMessage::ModelSwitched {
192            previous: "anthropic:claude-3.5-sonnet".to_string(),
193            new: "ollama:llama3.2".to_string(),
194        };
195        let json = serde_json::to_string(&msg).unwrap();
196        let parsed: AgentMessage = serde_json::from_str(&json).unwrap();
197        assert_eq!(parsed, msg);
198    }
199
200    #[test]
201    fn test_thought_serde_roundtrip() {
202        let msg = AgentMessage::Thought {
203            message_id: "msg_1".to_string(),
204            chunk: "thinking".to_string(),
205            is_complete: false,
206            model_name: "test-model".to_string(),
207        };
208        let json = serde_json::to_string(&msg).unwrap();
209        let parsed: AgentMessage = serde_json::from_str(&json).unwrap();
210        assert_eq!(parsed, msg);
211    }
212
213    #[test]
214    fn test_thought_complete_serde_roundtrip() {
215        let msg = AgentMessage::Thought {
216            message_id: "msg_1".to_string(),
217            chunk: "full reasoning".to_string(),
218            is_complete: true,
219            model_name: "test-model".to_string(),
220        };
221        let json = serde_json::to_string(&msg).unwrap();
222        let parsed: AgentMessage = serde_json::from_str(&json).unwrap();
223        assert_eq!(parsed, msg);
224    }
225
226    #[test]
227    fn test_tool_result_serializes_result_meta() {
228        let msg = AgentMessage::ToolResult {
229            result: ToolCallResult {
230                id: "call_1".to_string(),
231                name: "coding__read_file".to_string(),
232                arguments: r#"{"filePath":"Cargo.toml"}"#.to_string(),
233                result: "ok".to_string(),
234            },
235            result_meta: Some(ToolDisplayMeta::new("Read file", "Cargo.toml, 156 lines").into()),
236            model_name: "test-model".to_string(),
237        };
238
239        let json = serde_json::to_value(&msg).unwrap();
240        let tool_result = &json["ToolResult"];
241        assert_eq!(tool_result["result_meta"]["display"]["title"], "Read file");
242        assert_eq!(tool_result["result_meta"]["display"]["value"], "Cargo.toml, 156 lines");
243
244        let parsed: AgentMessage = serde_json::from_value(json).unwrap();
245        assert_eq!(parsed, msg);
246    }
247
248    #[test]
249    fn test_sub_agent_tool_result_includes_display_fields() {
250        let msg = AgentMessage::ToolResult {
251            result: ToolCallResult {
252                id: "call_1".to_string(),
253                name: "coding__read_file".to_string(),
254                arguments: r#"{"filePath":"Cargo.toml"}"#.to_string(),
255                result: "ok".to_string(),
256            },
257            result_meta: Some(ToolDisplayMeta::new("Read file", "Cargo.toml, 156 lines").into()),
258            model_name: "test-model".to_string(),
259        };
260
261        let event: SubAgentEvent = (&msg).into();
262        match event {
263            SubAgentEvent::ToolResult { result } => {
264                assert_eq!(result.id, "call_1");
265                assert_eq!(result.name, "coding__read_file");
266                let result_meta = result.result_meta.expect("result_meta should be present");
267                assert_eq!(result_meta.display.title, "Read file");
268                assert_eq!(result_meta.display.value, "Cargo.toml, 156 lines");
269            }
270            other => panic!("Expected ToolResult, got {other:?}"),
271        }
272    }
273
274    #[test]
275    fn test_sub_agent_tool_call_update_includes_updated_fields() {
276        let msg = AgentMessage::ToolCallUpdate {
277            tool_call_id: "call_1".to_string(),
278            chunk: r#"{"filePath":"Cargo.toml"}"#.to_string(),
279            model_name: "test-model".to_string(),
280        };
281
282        let event: SubAgentEvent = (&msg).into();
283        match event {
284            SubAgentEvent::ToolCallUpdate { update } => {
285                assert_eq!(update.id, "call_1");
286                assert_eq!(update.chunk, r#"{"filePath":"Cargo.toml"}"#);
287            }
288            other => panic!("Expected ToolCallUpdate, got {other:?}"),
289        }
290    }
291}