Skip to main content

aether_core/events/
agent_event.rs

1use llm::{ToolCallError, ToolCallRequest, ToolCallResult};
2use schemars::JsonSchema;
3use serde::{Deserialize, Serialize};
4
5use super::{ContextEvent, MessageEvent, ModelEvent, StreamState, ToolEvent, TurnEvent, TurnOutcome};
6
7/// A canonical event on the agent's output stream.
8#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
9#[serde(tag = "category", content = "event", rename_all = "snake_case")]
10#[allow(clippy::large_enum_variant)]
11pub enum AgentEvent {
12    Message(MessageEvent),
13    Tool(ToolEvent),
14    Turn(TurnEvent),
15    Context(ContextEvent),
16    Model(ModelEvent),
17    SessionUsage(llm::SessionUsageEvent),
18}
19
20impl AgentEvent {
21    pub fn text(message_id: &str, chunk: &str, state: StreamState) -> Self {
22        Self::Message(MessageEvent::Text {
23            message_id: message_id.into(),
24            chunk: chunk.into(),
25            is_complete: state.is_complete(),
26        })
27    }
28
29    pub fn thought(message_id: &str, chunk: &str, state: StreamState) -> Self {
30        Self::Message(MessageEvent::Thought {
31            message_id: message_id.into(),
32            chunk: chunk.into(),
33            is_complete: state.is_complete(),
34        })
35    }
36
37    pub fn turn_ended(outcome: TurnOutcome) -> Self {
38        Self::Turn(TurnEvent::Ended { outcome })
39    }
40
41    pub fn tool_call(id: &str, name: &str, arguments: &str) -> Self {
42        Self::Tool(ToolEvent::Call {
43            request: ToolCallRequest { id: id.into(), name: name.into(), arguments: arguments.into() },
44        })
45    }
46
47    pub fn tool_result(id: &str, name: &str, arguments: &str, result: &str) -> Self {
48        Self::Tool(ToolEvent::Result {
49            result: ToolCallResult {
50                id: id.into(),
51                name: name.into(),
52                arguments: arguments.into(),
53                result: result.into(),
54            },
55            result_meta: None,
56        })
57    }
58
59    pub fn tool_error(id: &str, name: &str, arguments: &str, error: &str) -> Self {
60        Self::Tool(ToolEvent::Error {
61            error: ToolCallError {
62                id: id.into(),
63                name: name.into(),
64                arguments: Some(arguments.into()),
65                error: error.into(),
66            },
67        })
68    }
69
70    /// Human-readable text content of this event, if any.
71    pub fn content(&self) -> Option<String> {
72        match self {
73            Self::Message(MessageEvent::Text { chunk, .. } | MessageEvent::Thought { chunk, .. }) => {
74                Some(chunk.clone())
75            }
76            Self::Tool(ToolEvent::Result { result, .. } | ToolEvent::TaskCompleted { result, .. }) => {
77                Some(result.result.clone())
78            }
79            Self::Tool(ToolEvent::Error { error } | ToolEvent::TaskFailed { error, .. }) => Some(error.error.clone()),
80            Self::Tool(ToolEvent::TaskCreated { task_id, .. }) => Some(task_id.clone()),
81            Self::Tool(ToolEvent::TaskStatus { task_id, status, status_message, .. }) => {
82                Some(status_message.as_ref().map_or_else(
83                    || format!("{task_id}: {status}"),
84                    |message| format!("{task_id}: {status} - {message}"),
85                ))
86            }
87            Self::Tool(ToolEvent::TaskCancelled { task_id, .. }) => Some(format!("{task_id}: cancelled")),
88            Self::Context(ContextEvent::CompactionResult { summary, .. }) => Some(summary.clone()),
89            _ => None,
90        }
91    }
92
93    /// The turn's terminal outcome, if this event ends a turn.
94    pub fn turn_outcome(&self) -> Option<&TurnOutcome> {
95        match self {
96            Self::Turn(TurnEvent::Ended { outcome }) => Some(outcome),
97            _ => None,
98        }
99    }
100
101    pub fn is_stream_delta(&self) -> bool {
102        matches!(
103            self,
104            Self::Message(
105                MessageEvent::Text { is_complete: false, .. } | MessageEvent::Thought { is_complete: false, .. }
106            ) | Self::Tool(ToolEvent::InputDelta { .. })
107        )
108    }
109}
110
111#[cfg(test)]
112mod tests {
113    use super::*;
114    use crate::events::{CompactionOutcome, LlmCallOutcome};
115    use llm::{ContextUsage, LlmCallPurpose};
116    use utils::display_meta::ToolDisplayMeta;
117
118    #[test]
119    fn serializes_nested_event_contract() {
120        let event = AgentEvent::text("m1", "hello", StreamState::Complete);
121        assert_eq!(
122            serde_json::to_value(event).unwrap(),
123            serde_json::json!({"category":"message","event":{"type":"text","message_id":"m1","chunk":"hello","is_complete":true}})
124        );
125    }
126
127    #[test]
128    fn nested_events_roundtrip() {
129        let events = [
130            AgentEvent::text("m", "text", StreamState::Complete),
131            AgentEvent::Tool(ToolEvent::DefinitionsUpdated { tools: vec![] }),
132            AgentEvent::Turn(TurnEvent::LlmCallEnded {
133                purpose: LlmCallPurpose::Chat,
134                outcome: LlmCallOutcome::Cancelled,
135            }),
136            AgentEvent::Context(ContextEvent::UsageUpdated { usage: ContextUsage::default() }),
137            AgentEvent::Context(ContextEvent::CompactionEnded {
138                compaction_id: "compaction".into(),
139                outcome: CompactionOutcome::Completed,
140            }),
141            AgentEvent::Model(ModelEvent::Switched { previous: "a".into(), new: "b".into() }),
142            AgentEvent::SessionUsage(llm::testing::session_usage_event(1, llm::TokenUsage::new(1, 2))),
143            AgentEvent::Tool(ToolEvent::DisplayUpdate {
144                request: llm::ToolCallRequest { id: "call".into(), name: "read".into(), arguments: "{}".into() },
145                meta: ToolDisplayMeta::new("Read file", "main.rs").into(),
146            }),
147            AgentEvent::Tool(ToolEvent::SubAgentProgress {
148                request: llm::ToolCallRequest { id: "call".into(), name: "spawn".into(), arguments: "{}".into() },
149                payload: Box::new(crate::events::SubAgentProgressPayload {
150                    task_id: "task_0".into(),
151                    agent_name: "explorer".into(),
152                    event: AgentEvent::turn_ended(TurnOutcome::Completed),
153                }),
154            }),
155        ];
156        for event in events {
157            let json = serde_json::to_string(&event).unwrap();
158            assert_eq!(serde_json::from_str::<AgentEvent>(&json).unwrap(), event);
159        }
160    }
161
162    #[test]
163    fn failed_call_ended_serializes_diagnostics_and_omits_absent_fields() {
164        let event = AgentEvent::Turn(TurnEvent::LlmCallEnded {
165            purpose: LlmCallPurpose::Chat,
166            outcome: LlmCallOutcome::Failed {
167                error: "Server error: boom (status 200, code server_error, request_id req-1)".into(),
168                will_retry: true,
169                http_status: Some(200),
170                provider_request_id: Some("req-1".into()),
171                provider_error_code: Some("server_error".into()),
172            },
173        });
174        assert_eq!(
175            serde_json::to_value(&event).unwrap(),
176            serde_json::json!({
177                "category": "turn",
178                "event": {
179                    "type": "llm_call_ended",
180                    "purpose": "chat",
181                    "outcome": {
182                        "status": "failed",
183                        "error": "Server error: boom (status 200, code server_error, request_id req-1)",
184                        "will_retry": true,
185                        "http_status": 200,
186                        "provider_request_id": "req-1",
187                        "provider_error_code": "server_error"
188                    }
189                }
190            })
191        );
192
193        let minimal = AgentEvent::Turn(TurnEvent::LlmCallEnded {
194            purpose: LlmCallPurpose::Chat,
195            outcome: LlmCallOutcome::failed("boom", false),
196        });
197        let value = serde_json::to_value(&minimal).unwrap();
198        let outcome = &value["event"]["outcome"];
199        assert_eq!(outcome["status"], "failed");
200        assert!(outcome.get("http_status").is_none());
201        assert!(outcome.get("provider_request_id").is_none());
202        assert!(outcome.get("provider_error_code").is_none());
203        assert_eq!(serde_json::from_value::<AgentEvent>(value).unwrap(), minimal);
204    }
205
206    #[test]
207    fn from_llm_error_copies_provider_diagnostics() {
208        let provider = llm::ProviderError::server("boom")
209            .with_http_status(503)
210            .with_request_id(Some("req-9".into()))
211            .with_code(Some("server_error".into()));
212        let outcome = LlmCallOutcome::from_llm_error(&llm::LlmError::from(provider), true);
213        match outcome {
214            LlmCallOutcome::Failed { http_status, provider_request_id, provider_error_code, will_retry, .. } => {
215                assert!(will_retry);
216                assert_eq!(http_status, Some(503));
217                assert_eq!(provider_request_id.as_deref(), Some("req-9"));
218                assert_eq!(provider_error_code.as_deref(), Some("server_error"));
219            }
220            _ => panic!("expected failed outcome"),
221        }
222        let terminal = LlmCallOutcome::from_llm_error(&llm::LlmError::InvalidArgument("bad".into()), false);
223        match terminal {
224            LlmCallOutcome::Failed { http_status, provider_request_id, provider_error_code, .. } => {
225                assert_eq!(http_status, None);
226                assert_eq!(provider_request_id, None);
227                assert_eq!(provider_error_code, None);
228            }
229            _ => panic!("expected failed outcome"),
230        }
231    }
232
233    #[test]
234    fn turn_outcome_returns_outcome_only_for_turn_end() {
235        assert_eq!(AgentEvent::turn_ended(TurnOutcome::Completed).turn_outcome(), Some(&TurnOutcome::Completed));
236        assert_eq!(AgentEvent::text("m", "text", StreamState::Complete).turn_outcome(), None);
237        assert_eq!(AgentEvent::Turn(TurnEvent::Started { content: vec![] }).turn_outcome(), None);
238    }
239
240    #[test]
241    fn only_partial_chunks_and_tool_input_deltas_are_stream_deltas() {
242        let deltas = [
243            AgentEvent::text("m", "par", StreamState::Partial),
244            AgentEvent::thought("m", "par", StreamState::Partial),
245            AgentEvent::Tool(ToolEvent::InputDelta { id: "call".into(), chunk: "{".into() }),
246        ];
247        let complete = [
248            AgentEvent::text("m", "partial", StreamState::Complete),
249            AgentEvent::thought("m", "partial", StreamState::Complete),
250            AgentEvent::Tool(ToolEvent::InputStarted { id: "call".into(), name: "read".into() }),
251            AgentEvent::tool_call("call", "read", "{}"),
252            AgentEvent::turn_ended(TurnOutcome::Completed),
253        ];
254        assert!(deltas.iter().all(AgentEvent::is_stream_delta));
255        assert!(!complete.iter().any(AgentEvent::is_stream_delta));
256    }
257}