Skip to main content

adk_core/
event.rs

1use crate::context::{ToolConfirmationDecision, ToolConfirmationRequest};
2use crate::model::LlmResponse;
3use crate::types::Content;
4use chrono::{DateTime, Utc};
5use serde::{Deserialize, Serialize};
6use std::collections::HashMap;
7use uuid::Uuid;
8
9// State scope prefixes
10/// Key prefix for application-scoped state (persists across sessions).
11pub const KEY_PREFIX_APP: &str = "app:";
12/// Key prefix for temporary state (cleared each turn).
13pub const KEY_PREFIX_TEMP: &str = "temp:";
14/// Key prefix for user-scoped state (persists across sessions).
15pub const KEY_PREFIX_USER: &str = "user:";
16
17/// Event represents a single interaction in a conversation.
18/// This struct embeds LlmResponse to match ADK-Go's design pattern.
19#[derive(Debug, Clone, Serialize, Deserialize)]
20pub struct Event {
21    /// Unique identifier for this event.
22    pub id: String,
23    /// When this event was created.
24    pub timestamp: DateTime<Utc>,
25    /// The invocation that produced this event.
26    pub invocation_id: String,
27    /// The conversation branch this event belongs to.
28    pub branch: String,
29    /// The agent or role that authored this event.
30    pub author: String,
31    /// The LLM response containing content and metadata.
32    /// Access content via `event.llm_response.content`.
33    #[serde(flatten)]
34    pub llm_response: LlmResponse,
35    /// Actions to apply (state changes, transfers, confirmations).
36    pub actions: EventActions,
37    /// IDs of long-running tools associated with this event.
38    #[serde(default)]
39    pub long_running_tool_ids: Vec<String>,
40    /// LLM request data for UI display (JSON string)
41    #[serde(skip_serializing_if = "Option::is_none")]
42    pub llm_request: Option<String>,
43    /// Provider-specific metadata (e.g., GCP Vertex, Azure OpenAI).
44    /// Keeps the core Event struct provider-agnostic.
45    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
46    pub provider_metadata: HashMap<String, String>,
47}
48
49/// Metadata for a compacted (summarized) event.
50/// When context compaction is enabled, older events are summarized into a single
51/// compacted event containing this metadata.
52#[derive(Debug, Clone, Serialize, Deserialize)]
53pub struct EventCompaction {
54    /// Timestamp of the earliest event that was compacted.
55    pub start_timestamp: DateTime<Utc>,
56    /// Timestamp of the latest event that was compacted.
57    pub end_timestamp: DateTime<Utc>,
58    /// The summarized content replacing the original events.
59    pub compacted_content: Content,
60}
61
62/// Actions to apply as side effects of an event.
63#[derive(Debug, Clone, Default, Serialize, Deserialize)]
64pub struct EventActions {
65    /// State key-value changes to apply.
66    pub state_delta: HashMap<String, serde_json::Value>,
67    /// Artifact version changes.
68    pub artifact_delta: HashMap<String, i64>,
69    /// Whether to skip summarization for this event.
70    pub skip_summarization: bool,
71    /// Agent name to transfer control to.
72    pub transfer_to_agent: Option<String>,
73    /// Whether to escalate to a human operator.
74    pub escalate: bool,
75    /// Tool confirmation request awaiting human approval.
76    #[serde(skip_serializing_if = "Option::is_none")]
77    pub tool_confirmation: Option<ToolConfirmationRequest>,
78    /// Decision for a pending tool confirmation.
79    #[serde(skip_serializing_if = "Option::is_none")]
80    pub tool_confirmation_decision: Option<ToolConfirmationDecision>,
81    /// Present when this event is a compaction summary replacing older events.
82    #[serde(skip_serializing_if = "Option::is_none")]
83    pub compaction: Option<EventCompaction>,
84}
85
86impl Event {
87    /// Creates a new event with a generated UUID and current timestamp.
88    pub fn new(invocation_id: impl Into<String>) -> Self {
89        Self {
90            id: Uuid::new_v4().to_string(),
91            timestamp: Utc::now(),
92            invocation_id: invocation_id.into(),
93            branch: String::new(),
94            author: String::new(),
95            llm_response: LlmResponse::default(),
96            actions: EventActions::default(),
97            long_running_tool_ids: Vec::new(),
98            llm_request: None,
99            provider_metadata: HashMap::new(),
100        }
101    }
102
103    /// Create an event with a specific ID.
104    /// Use this for streaming events where all chunks should share the same event ID.
105    pub fn with_id(id: impl Into<String>, invocation_id: impl Into<String>) -> Self {
106        Self {
107            id: id.into(),
108            timestamp: Utc::now(),
109            invocation_id: invocation_id.into(),
110            branch: String::new(),
111            author: String::new(),
112            llm_response: LlmResponse::default(),
113            actions: EventActions::default(),
114            long_running_tool_ids: Vec::new(),
115            llm_request: None,
116            provider_metadata: HashMap::new(),
117        }
118    }
119
120    /// Convenience method to access content directly.
121    pub fn content(&self) -> Option<&Content> {
122        self.llm_response.content.as_ref()
123    }
124
125    /// Convenience method to set content directly.
126    pub fn set_content(&mut self, content: Content) {
127        self.llm_response.content = Some(content);
128    }
129
130    /// Returns whether the event is the final response of an agent.
131    ///
132    /// An event is considered final if:
133    /// - It has skip_summarization set, OR
134    /// - It has long_running_tool_ids (indicating async operations), OR
135    /// - It has no function calls, no function responses, is not partial,
136    ///   and has no trailing code execution results.
137    ///
138    /// Note: When multiple agents participate in one invocation, there could be
139    /// multiple events with is_final_response() as true, for each participating agent.
140    pub fn is_final_response(&self) -> bool {
141        // If skip_summarization is set or we have long-running tools, it's final
142        if self.actions.skip_summarization || !self.long_running_tool_ids.is_empty() {
143            return true;
144        }
145
146        // Check content for function calls/responses
147        let has_function_calls = self.has_function_calls();
148        let has_function_responses = self.has_function_responses();
149        let is_partial = self.llm_response.partial;
150        let has_trailing_code_result = self.has_trailing_code_execution_result();
151
152        !has_function_calls && !has_function_responses && !is_partial && !has_trailing_code_result
153    }
154
155    /// Returns true if the event content contains function calls.
156    fn has_function_calls(&self) -> bool {
157        if let Some(content) = &self.llm_response.content {
158            for part in &content.parts {
159                if matches!(part, crate::Part::FunctionCall { .. }) {
160                    return true;
161                }
162            }
163        }
164        false
165    }
166
167    /// Returns true if the event content contains function responses.
168    fn has_function_responses(&self) -> bool {
169        if let Some(content) = &self.llm_response.content {
170            for part in &content.parts {
171                if matches!(part, crate::Part::FunctionResponse { .. }) {
172                    return true;
173                }
174            }
175        }
176        false
177    }
178
179    /// Returns true if the event has a trailing code execution result.
180    #[allow(clippy::match_like_matches_macro)]
181    fn has_trailing_code_execution_result(&self) -> bool {
182        if let Some(content) = &self.llm_response.content {
183            if let Some(last_part) = content.parts.last() {
184                // FunctionResponse as the last part indicates a code execution result
185                // that the model still needs to process.
186                return matches!(last_part, crate::Part::FunctionResponse { .. });
187            }
188        }
189        false
190    }
191
192    /// Extracts function call IDs from this event's content.
193    /// Used to identify which function calls are associated with long-running tools.
194    pub fn function_call_ids(&self) -> Vec<String> {
195        let mut ids = Vec::new();
196        if let Some(content) = &self.llm_response.content {
197            for part in &content.parts {
198                if let crate::Part::FunctionCall { name, id, .. } = part {
199                    // Use the actual call ID when available (OpenAI-style),
200                    // fall back to name for providers that don't emit IDs (Gemini).
201                    ids.push(id.as_deref().unwrap_or(name).to_string());
202                }
203            }
204        }
205        ids
206    }
207}
208
209#[cfg(test)]
210mod tests {
211    use super::*;
212    use crate::Part;
213
214    #[test]
215    fn test_event_creation() {
216        let event = Event::new("inv-123");
217        assert_eq!(event.invocation_id, "inv-123");
218        assert!(!event.id.is_empty());
219    }
220
221    #[test]
222    fn test_event_actions_default() {
223        let actions = EventActions::default();
224        assert!(actions.state_delta.is_empty());
225        assert!(!actions.skip_summarization);
226        assert!(actions.tool_confirmation.is_none());
227        assert!(actions.tool_confirmation_decision.is_none());
228    }
229
230    #[test]
231    fn test_state_prefixes() {
232        assert_eq!(KEY_PREFIX_APP, "app:");
233        assert_eq!(KEY_PREFIX_TEMP, "temp:");
234        assert_eq!(KEY_PREFIX_USER, "user:");
235    }
236
237    #[test]
238    fn test_is_final_response_no_content() {
239        let event = Event::new("inv-123");
240        // No content, no function calls -> final
241        assert!(event.is_final_response());
242    }
243
244    #[test]
245    fn test_is_final_response_text_only() {
246        let mut event = Event::new("inv-123");
247        event.llm_response.content = Some(Content {
248            role: "model".to_string(),
249            parts: vec![Part::Text { text: "Hello!".to_string() }],
250        });
251        // Text only, no function calls -> final
252        assert!(event.is_final_response());
253    }
254
255    #[test]
256    fn test_is_final_response_with_function_call() {
257        let mut event = Event::new("inv-123");
258        event.llm_response.content = Some(Content {
259            role: "model".to_string(),
260            parts: vec![Part::FunctionCall {
261                name: "get_weather".to_string(),
262                args: serde_json::json!({"city": "NYC"}),
263                id: Some("call_123".to_string()),
264                thought_signature: None,
265            }],
266        });
267        // Has function call -> NOT final (need to execute it)
268        assert!(!event.is_final_response());
269    }
270
271    #[test]
272    fn test_is_final_response_with_function_response() {
273        let mut event = Event::new("inv-123");
274        event.llm_response.content = Some(Content {
275            role: "function".to_string(),
276            parts: vec![Part::FunctionResponse {
277                function_response: crate::FunctionResponseData::new(
278                    "get_weather",
279                    serde_json::json!({"temp": 72}),
280                ),
281                id: Some("call_123".to_string()),
282            }],
283        });
284        // Has function response -> NOT final (model needs to respond)
285        assert!(!event.is_final_response());
286    }
287
288    #[test]
289    fn test_is_final_response_partial() {
290        let mut event = Event::new("inv-123");
291        event.llm_response.partial = true;
292        event.llm_response.content = Some(Content {
293            role: "model".to_string(),
294            parts: vec![Part::Text { text: "Hello...".to_string() }],
295        });
296        // Partial response -> NOT final
297        assert!(!event.is_final_response());
298    }
299
300    #[test]
301    fn test_is_final_response_skip_summarization() {
302        let mut event = Event::new("inv-123");
303        event.actions.skip_summarization = true;
304        event.llm_response.content = Some(Content {
305            role: "function".to_string(),
306            parts: vec![Part::FunctionResponse {
307                function_response: crate::FunctionResponseData::new(
308                    "tool",
309                    serde_json::json!({"result": "done"}),
310                ),
311                id: Some("call_tool".to_string()),
312            }],
313        });
314        // Even with function response, skip_summarization makes it final
315        assert!(event.is_final_response());
316    }
317
318    #[test]
319    fn test_is_final_response_long_running_tool_ids() {
320        let mut event = Event::new("inv-123");
321        event.long_running_tool_ids = vec!["process_video".to_string()];
322        event.llm_response.content = Some(Content {
323            role: "model".to_string(),
324            parts: vec![Part::FunctionCall {
325                name: "process_video".to_string(),
326                args: serde_json::json!({"file": "video.mp4"}),
327                id: Some("call_process".to_string()),
328                thought_signature: None,
329            }],
330        });
331        // Has long_running_tool_ids -> final (async operation started)
332        assert!(event.is_final_response());
333    }
334
335    #[test]
336    fn test_function_call_ids() {
337        let mut event = Event::new("inv-123");
338        event.llm_response.content = Some(Content {
339            role: "model".to_string(),
340            parts: vec![
341                Part::FunctionCall {
342                    name: "get_weather".to_string(),
343                    args: serde_json::json!({}),
344                    id: Some("call_1".to_string()),
345                    thought_signature: None,
346                },
347                Part::Text { text: "I'll check the weather".to_string() },
348                Part::FunctionCall {
349                    name: "get_time".to_string(),
350                    args: serde_json::json!({}),
351                    id: Some("call_2".to_string()),
352                    thought_signature: None,
353                },
354            ],
355        });
356
357        let ids = event.function_call_ids();
358        assert_eq!(ids.len(), 2);
359        // Should use actual call IDs, not function names
360        assert!(ids.contains(&"call_1".to_string()));
361        assert!(ids.contains(&"call_2".to_string()));
362    }
363
364    #[test]
365    fn test_function_call_ids_falls_back_to_name() {
366        let mut event = Event::new("inv-123");
367        event.llm_response.content = Some(Content {
368            role: "model".to_string(),
369            parts: vec![Part::FunctionCall {
370                name: "get_weather".to_string(),
371                args: serde_json::json!({}),
372                id: None, // Gemini-style: no explicit ID
373                thought_signature: None,
374            }],
375        });
376
377        let ids = event.function_call_ids();
378        assert_eq!(ids, vec!["get_weather".to_string()]);
379    }
380
381    #[test]
382    fn test_function_call_ids_empty() {
383        let event = Event::new("inv-123");
384        let ids = event.function_call_ids();
385        assert!(ids.is_empty());
386    }
387
388    #[test]
389    fn test_is_final_response_trailing_function_response() {
390        // Text followed by a function response as the last part —
391        // has_trailing_code_execution_result should catch this even though
392        // has_function_responses also catches it.
393        let mut event = Event::new("inv-123");
394        event.llm_response.content = Some(Content {
395            role: "model".to_string(),
396            parts: vec![
397                Part::Text { text: "Running code...".to_string() },
398                Part::FunctionResponse {
399                    function_response: crate::FunctionResponseData::new(
400                        "code_exec",
401                        serde_json::json!({"output": "42"}),
402                    ),
403                    id: Some("call_exec".to_string()),
404                },
405            ],
406        });
407        // Trailing function response -> NOT final
408        assert!(!event.is_final_response());
409    }
410
411    #[test]
412    fn test_is_final_response_text_after_function_response() {
413        // Function response followed by text — the trailing part is text,
414        // so has_trailing_code_execution_result is false, but
415        // has_function_responses is still true.
416        let mut event = Event::new("inv-123");
417        event.llm_response.content = Some(Content {
418            role: "model".to_string(),
419            parts: vec![
420                Part::FunctionResponse {
421                    function_response: crate::FunctionResponseData::new(
422                        "tool",
423                        serde_json::json!({}),
424                    ),
425                    id: Some("call_1".to_string()),
426                },
427                Part::Text { text: "Done".to_string() },
428            ],
429        });
430        // Still has function responses -> NOT final
431        assert!(!event.is_final_response());
432    }
433}