Skip to main content

aether_core/events/
agent_event.rs

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