Skip to main content

adk_managed/
event_mapping.rs

1//! Provider-neutral event mapping from Runner output to SessionEvent.
2//!
3//! This module defines the [`RunnerOutput`] enum representing raw Runner events
4//! and the [`map_runner_output`] function that maps them uniformly to
5//! [`SessionEvent`] variants — guaranteeing identical type sequences regardless
6//! of which LLM provider powered the turn.
7//!
8//! # Provider Parity
9//!
10//! The mapping is the key enforcement point for Requirement 5.1 and 5.5:
11//! an identical `ManagedAgentDef` run against any provider MUST produce
12//! byte-identical `SessionEvent` type sequences. The provider-specific
13//! differences (tool call format, stop reasons, streaming deltas) are
14//! normalized here before entering the session event stream.
15//!
16//! # Architecture
17//!
18//! ```text
19//! Runner (provider-specific events)
20//!   │
21//!   ▼
22//! RunnerOutput (normalized intermediate)
23//!   │
24//!   ▼
25//! map_runner_output(output, seq) → SessionEvent (provider-neutral)
26//! ```
27//!
28//! The session loop calls [`map_runner_output`] for each event emitted by the
29//! Runner, producing a uniform stream that is then checkpointed and broadcast.
30
31use serde::{Deserialize, Serialize};
32use serde_json::Value;
33
34use crate::types::{ContentBlock, SessionEvent, StopReason};
35
36/// Represents a Runner output event that needs to be mapped to a SessionEvent.
37///
38/// This is the internal representation that normalizes provider-specific event
39/// formats before they become provider-neutral session events. The session loop
40/// constructs `RunnerOutput` values from the raw Runner event stream.
41///
42/// # Variants
43///
44/// - [`TextContent`](RunnerOutput::TextContent): LLM generated text → `agent.message`
45/// - [`BuiltinToolCall`](RunnerOutput::BuiltinToolCall): Built-in tool invocation → `agent.tool_use`
46/// - [`CustomToolCall`](RunnerOutput::CustomToolCall): Custom (client-executed) tool → `agent.custom_tool_use`
47/// - [`McpToolCall`](RunnerOutput::McpToolCall): MCP tool invocation → `agent.mcp_tool_use`
48/// - [`TurnComplete`](RunnerOutput::TurnComplete): Turn finished with a stop reason → `status.idle`
49#[derive(Debug, Clone, Serialize, Deserialize)]
50#[non_exhaustive]
51pub enum RunnerOutput {
52    /// LLM generated text content.
53    TextContent {
54        /// The generated text.
55        text: String,
56    },
57
58    /// LLM requested a built-in tool call (executes server-side in sandbox).
59    BuiltinToolCall {
60        /// Unique identifier for this tool invocation.
61        tool_use_id: String,
62        /// Name of the built-in tool.
63        name: String,
64        /// Tool input parameters as JSON.
65        input: Value,
66    },
67
68    /// LLM requested a custom (client-executed) tool call.
69    /// The session loop will park until the client delivers a result.
70    CustomToolCall {
71        /// Unique identifier for this custom tool invocation.
72        custom_tool_use_id: String,
73        /// Name of the custom tool.
74        name: String,
75        /// Tool input parameters as JSON.
76        input: Value,
77    },
78
79    /// LLM requested an MCP tool call.
80    McpToolCall {
81        /// Unique identifier for this MCP tool invocation.
82        tool_use_id: String,
83        /// Name of the MCP tool.
84        name: String,
85        /// Tool input parameters as JSON.
86        input: Value,
87    },
88
89    /// Turn completed with a stop reason.
90    TurnComplete {
91        /// Why the turn ended.
92        stop_reason: StopReason,
93    },
94}
95
96/// Tool classification used to determine which `RunnerOutput` variant to produce.
97///
98/// The session loop uses this to classify a tool call from the Runner before
99/// constructing the appropriate `RunnerOutput` variant.
100#[derive(Debug, Clone, Copy, PartialEq, Eq)]
101pub enum ToolKind {
102    /// Built-in tool (bash, filesystem, web_search, etc.) — executes server-side.
103    Builtin,
104    /// Custom tool — client-executed, requires parking.
105    Custom,
106    /// MCP tool — executed via MCP server.
107    Mcp,
108}
109
110/// Maps a [`RunnerOutput`] to a [`SessionEvent`] with the given sequence number.
111///
112/// This function is the provider parity enforcement point. Regardless of which
113/// LLM provider produced the raw events, this mapping produces identical
114/// `SessionEvent` variants with identical structure.
115///
116/// # Arguments
117///
118/// * `output` - The normalized Runner output event.
119/// * `seq` - The monotonically increasing sequence number to assign.
120///
121/// # Returns
122///
123/// A `SessionEvent` ready for checkpointing and broadcast.
124///
125/// # Example
126///
127/// ```rust
128/// use adk_managed::event_mapping::{RunnerOutput, map_runner_output};
129/// use serde_json::json;
130///
131/// let output = RunnerOutput::TextContent {
132///     text: "Hello, world!".to_string(),
133/// };
134/// let event = map_runner_output(output, 42);
135/// // event is SessionEvent::Message { content: [...], seq: 42 }
136/// ```
137pub fn map_runner_output(output: RunnerOutput, seq: u64) -> SessionEvent {
138    match output {
139        RunnerOutput::TextContent { text } => {
140            SessionEvent::Message { content: vec![ContentBlock::Text { text }], seq }
141        }
142        RunnerOutput::BuiltinToolCall { tool_use_id, name, input } => {
143            SessionEvent::ToolUse { tool_use_id, name, input, seq }
144        }
145        RunnerOutput::CustomToolCall { custom_tool_use_id, name, input } => {
146            SessionEvent::CustomToolUse { custom_tool_use_id, name, input, seq }
147        }
148        RunnerOutput::McpToolCall { tool_use_id, name, input } => {
149            SessionEvent::McpToolUse { tool_use_id, name, input, seq }
150        }
151        RunnerOutput::TurnComplete { stop_reason } => {
152            SessionEvent::StatusIdle { seq, stop_reason: Some(stop_reason), usage: None }
153        }
154    }
155}
156
157/// Returns `true` if the given `RunnerOutput` represents a custom tool call
158/// that requires parking (waiting for client response).
159///
160/// This helper is used by the session loop to determine whether to park after
161/// emitting the corresponding `SessionEvent`.
162pub fn requires_parking(output: &RunnerOutput) -> bool {
163    matches!(output, RunnerOutput::CustomToolCall { .. })
164}
165
166/// Extracts the custom tool use ID from a `RunnerOutput`, if it's a custom tool call.
167///
168/// Returns `None` for all other variants.
169pub fn custom_tool_use_id(output: &RunnerOutput) -> Option<&str> {
170    match output {
171        RunnerOutput::CustomToolCall { custom_tool_use_id, .. } => Some(custom_tool_use_id),
172        _ => None,
173    }
174}
175
176#[cfg(test)]
177mod tests {
178    use super::*;
179    use serde_json::json;
180
181    #[test]
182    fn test_text_content_maps_to_agent_message() {
183        let output = RunnerOutput::TextContent { text: "Hello from the model".to_string() };
184        let event = map_runner_output(output, 5);
185
186        match event {
187            SessionEvent::Message { content, seq } => {
188                assert_eq!(seq, 5);
189                assert_eq!(content.len(), 1);
190                match &content[0] {
191                    ContentBlock::Text { text } => {
192                        assert_eq!(text, "Hello from the model");
193                    }
194                    _ => panic!("expected Text content block"),
195                }
196            }
197            _ => panic!("expected Message event"),
198        }
199    }
200
201    #[test]
202    fn test_builtin_tool_call_maps_to_tool_use() {
203        let output = RunnerOutput::BuiltinToolCall {
204            tool_use_id: "tu_001".to_string(),
205            name: "web_search".to_string(),
206            input: json!({"query": "rust async"}),
207        };
208        let event = map_runner_output(output, 10);
209
210        match event {
211            SessionEvent::ToolUse { tool_use_id, name, input, seq } => {
212                assert_eq!(seq, 10);
213                assert_eq!(tool_use_id, "tu_001");
214                assert_eq!(name, "web_search");
215                assert_eq!(input["query"], "rust async");
216            }
217            _ => panic!("expected ToolUse event"),
218        }
219    }
220
221    #[test]
222    fn test_custom_tool_call_maps_to_custom_tool_use() {
223        let output = RunnerOutput::CustomToolCall {
224            custom_tool_use_id: "ctu_002".to_string(),
225            name: "deploy".to_string(),
226            input: json!({"target": "production"}),
227        };
228        let event = map_runner_output(output, 20);
229
230        match event {
231            SessionEvent::CustomToolUse { custom_tool_use_id, name, input, seq } => {
232                assert_eq!(seq, 20);
233                assert_eq!(custom_tool_use_id, "ctu_002");
234                assert_eq!(name, "deploy");
235                assert_eq!(input["target"], "production");
236            }
237            _ => panic!("expected CustomToolUse event"),
238        }
239    }
240
241    #[test]
242    fn test_mcp_tool_call_maps_to_mcp_tool_use() {
243        let output = RunnerOutput::McpToolCall {
244            tool_use_id: "mcp_003".to_string(),
245            name: "file_read".to_string(),
246            input: json!({"path": "/tmp/data.txt"}),
247        };
248        let event = map_runner_output(output, 30);
249
250        match event {
251            SessionEvent::McpToolUse { tool_use_id, name, input, seq } => {
252                assert_eq!(seq, 30);
253                assert_eq!(tool_use_id, "mcp_003");
254                assert_eq!(name, "file_read");
255                assert_eq!(input["path"], "/tmp/data.txt");
256            }
257            _ => panic!("expected McpToolUse event"),
258        }
259    }
260
261    #[test]
262    fn test_turn_complete_maps_to_status_idle() {
263        let output = RunnerOutput::TurnComplete { stop_reason: StopReason::EndTurn };
264        let event = map_runner_output(output, 40);
265
266        match event {
267            SessionEvent::StatusIdle { seq, stop_reason, .. } => {
268                assert_eq!(seq, 40);
269                assert!(matches!(stop_reason, Some(StopReason::EndTurn)));
270            }
271            _ => panic!("expected StatusIdle event"),
272        }
273    }
274
275    #[test]
276    fn test_turn_complete_requires_action() {
277        let output = RunnerOutput::TurnComplete {
278            stop_reason: StopReason::RequiresAction {
279                event_ids: vec!["evt_1".to_string(), "evt_2".to_string()],
280            },
281        };
282        let event = map_runner_output(output, 50);
283
284        match event {
285            SessionEvent::StatusIdle { seq, stop_reason, .. } => {
286                assert_eq!(seq, 50);
287                match stop_reason {
288                    Some(StopReason::RequiresAction { event_ids }) => {
289                        assert_eq!(event_ids, vec!["evt_1", "evt_2"]);
290                    }
291                    _ => panic!("expected RequiresAction stop reason"),
292                }
293            }
294            _ => panic!("expected StatusIdle event"),
295        }
296    }
297
298    #[test]
299    fn test_turn_complete_max_tokens() {
300        let output = RunnerOutput::TurnComplete { stop_reason: StopReason::MaxTokens };
301        let event = map_runner_output(output, 60);
302
303        match event {
304            SessionEvent::StatusIdle { seq, stop_reason, .. } => {
305                assert_eq!(seq, 60);
306                assert!(matches!(stop_reason, Some(StopReason::MaxTokens)));
307            }
308            _ => panic!("expected StatusIdle event"),
309        }
310    }
311
312    #[test]
313    fn test_requires_parking_custom_tool() {
314        let output = RunnerOutput::CustomToolCall {
315            custom_tool_use_id: "ctu_park".to_string(),
316            name: "deploy".to_string(),
317            input: json!({}),
318        };
319        assert!(requires_parking(&output));
320    }
321
322    #[test]
323    fn test_requires_parking_other_variants() {
324        let text = RunnerOutput::TextContent { text: "hi".to_string() };
325        let builtin = RunnerOutput::BuiltinToolCall {
326            tool_use_id: "tu".to_string(),
327            name: "search".to_string(),
328            input: json!({}),
329        };
330        let mcp = RunnerOutput::McpToolCall {
331            tool_use_id: "mcp".to_string(),
332            name: "read".to_string(),
333            input: json!({}),
334        };
335        let complete = RunnerOutput::TurnComplete { stop_reason: StopReason::EndTurn };
336
337        assert!(!requires_parking(&text));
338        assert!(!requires_parking(&builtin));
339        assert!(!requires_parking(&mcp));
340        assert!(!requires_parking(&complete));
341    }
342
343    #[test]
344    fn test_custom_tool_use_id_extraction() {
345        let output = RunnerOutput::CustomToolCall {
346            custom_tool_use_id: "ctu_extract".to_string(),
347            name: "deploy".to_string(),
348            input: json!({}),
349        };
350        assert_eq!(custom_tool_use_id(&output), Some("ctu_extract"));
351
352        let text = RunnerOutput::TextContent { text: "hi".to_string() };
353        assert_eq!(custom_tool_use_id(&text), None);
354    }
355
356    #[test]
357    fn test_provider_parity_identical_inputs_produce_identical_outputs() {
358        // Simulate the same tool call from different providers — all should
359        // map to the exact same SessionEvent.
360        let from_gemini = RunnerOutput::BuiltinToolCall {
361            tool_use_id: "tu_parity".to_string(),
362            name: "web_search".to_string(),
363            input: json!({"query": "weather"}),
364        };
365        let from_openai = RunnerOutput::BuiltinToolCall {
366            tool_use_id: "tu_parity".to_string(),
367            name: "web_search".to_string(),
368            input: json!({"query": "weather"}),
369        };
370        let from_anthropic = RunnerOutput::BuiltinToolCall {
371            tool_use_id: "tu_parity".to_string(),
372            name: "web_search".to_string(),
373            input: json!({"query": "weather"}),
374        };
375
376        let ev1 = map_runner_output(from_gemini, 0);
377        let ev2 = map_runner_output(from_openai, 0);
378        let ev3 = map_runner_output(from_anthropic, 0);
379
380        // Byte-identical JSON serialization.
381        let json1 = serde_json::to_string(&ev1).unwrap();
382        let json2 = serde_json::to_string(&ev2).unwrap();
383        let json3 = serde_json::to_string(&ev3).unwrap();
384
385        assert_eq!(json1, json2);
386        assert_eq!(json2, json3);
387    }
388
389    #[test]
390    fn test_mapping_preserves_seq_exactly() {
391        // Verify that the seq value is passed through without modification.
392        let outputs = vec![
393            RunnerOutput::TextContent { text: "a".to_string() },
394            RunnerOutput::BuiltinToolCall {
395                tool_use_id: "t".to_string(),
396                name: "n".to_string(),
397                input: json!({}),
398            },
399            RunnerOutput::CustomToolCall {
400                custom_tool_use_id: "c".to_string(),
401                name: "n".to_string(),
402                input: json!({}),
403            },
404            RunnerOutput::McpToolCall {
405                tool_use_id: "m".to_string(),
406                name: "n".to_string(),
407                input: json!({}),
408            },
409            RunnerOutput::TurnComplete { stop_reason: StopReason::EndTurn },
410        ];
411
412        for (i, output) in outputs.into_iter().enumerate() {
413            let seq = (i as u64) * 100 + 7;
414            let event = map_runner_output(output, seq);
415
416            let event_seq = match &event {
417                SessionEvent::Message { seq, .. } => *seq,
418                SessionEvent::ToolUse { seq, .. } => *seq,
419                SessionEvent::CustomToolUse { seq, .. } => *seq,
420                SessionEvent::McpToolUse { seq, .. } => *seq,
421                SessionEvent::StatusIdle { seq, .. } => *seq,
422                SessionEvent::StatusRunning { seq } => *seq,
423                SessionEvent::Error { seq, .. } => *seq,
424            };
425            assert_eq!(event_seq, seq);
426        }
427    }
428}