agtrace_types/
models.rs

1use chrono::{DateTime, Utc};
2use serde::{Deserialize, Serialize};
3use serde_json::Value;
4use uuid::Uuid;
5
6// NOTE: Schema Design Goals
7//
8// 1. Normalization: Abstract provider-specific quirks into unified time-series events
9//    - Gemini: Unfold nested batch records into sequential events
10//    - Codex: Align async token notifications and eliminate echo duplicates
11//    - Claude: Extract embedded usage into independent events
12//
13// 2. Observability: Enable accurate cost/performance tracking
14//    - Token: Sidecar pattern + incremental detection for precise billing (no double-counting)
15//    - Latency: Measure turnaround time (T_req → T_res) from user perspective
16//
17// 3. Replayability: Reconstruct full conversation context via parent_id chain
18//    - Linked-list structure ensures deterministic history recovery regardless of parallel execution
19//
20// 4. Separation: Distinguish time-series flow (parent_id) from logical relations (tool_call_id)
21//    - Enables both "conversation replay" and "request/response mapping"
22//
23// NOTE: Intentional Limitations (Not Goals)
24//
25// - OS-level execution timestamps: Unavailable in logs; command issue time ≒ execution start
26// - Tree/branch structure: Parallel tool calls are linearized in chronological/array order
27// - Real-time token sync: Codex-style delayed tokens handled via eventual consistency (sidecar)
28// - Gemini token breakdown: Total usage attached to final generation event (no speculation)
29
30/// Stream identifier for multi-stream sessions
31/// Enables parallel conversation streams within same session (e.g., background reasoning, subagents)
32#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
33#[serde(tag = "stream_type", content = "stream_data")]
34#[serde(rename_all = "snake_case")]
35pub enum StreamId {
36    /// Main conversation stream (default)
37    #[default]
38    Main,
39    /// Claude sidechain (background agent with specific ID)
40    Sidechain { agent_id: String },
41    /// Codex subagent (e.g., "review", "test", etc.)
42    Subagent { name: String },
43}
44
45impl StreamId {
46    /// Get string representation for debugging/logging
47    pub fn as_str(&self) -> String {
48        match self {
49            StreamId::Main => "main".to_string(),
50            StreamId::Sidechain { agent_id } => format!("sidechain:{}", agent_id),
51            StreamId::Subagent { name } => format!("subagent:{}", name),
52        }
53    }
54}
55
56/// Tool classification by semantic purpose
57#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
58#[serde(rename_all = "snake_case")]
59pub enum ToolKind {
60    /// Read operations (files, resources, data)
61    Read,
62    /// Write operations (edit, create, patch)
63    Write,
64    /// Execute operations (shell commands, scripts)
65    Execute,
66    /// Planning operations (todo, task management)
67    Plan,
68    /// Search operations (web, file search, grep)
69    Search,
70    /// User interaction (questions, prompts)
71    Ask,
72    /// Other/unknown operations
73    Other,
74}
75
76/// Tool origin classification
77///
78/// Distinguishes between provider-native tools and MCP protocol tools.
79///
80/// # Important
81/// The origin is determined by how the tool is invoked, not by what it operates on:
82/// - `System`: Tool is built-in to the provider and invoked directly by the LLM
83/// - `Mcp`: Tool is invoked via MCP protocol (typically prefixed with `mcp__`)
84///
85/// # Examples
86/// - `Bash` (Claude Code) → System (provider-native tool)
87/// - `read_mcp_resource` (Codex) → System (provider-native tool that happens to read MCP resources)
88/// - `mcp__sqlite__query` → Mcp (external tool invoked via MCP protocol)
89#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
90#[serde(rename_all = "snake_case")]
91pub enum ToolOrigin {
92    /// System-provided tool (built-in to the provider)
93    System,
94    /// MCP (Model Context Protocol) tool invoked via MCP protocol
95    Mcp,
96}
97
98/// Agent event
99/// Maps 1:1 to database table row
100#[derive(Debug, Clone, Serialize, Deserialize)]
101pub struct AgentEvent {
102    /// Unique event ID
103    pub id: Uuid,
104
105    /// Session/trace ID (groups entire conversation)
106    pub session_id: Uuid,
107
108    /// Parent event ID in time-series chain (Linked List structure)
109    /// None for root events (first User input)
110    pub parent_id: Option<Uuid>,
111
112    /// Event timestamp (UTC)
113    pub timestamp: DateTime<Utc>,
114
115    /// Stream identifier (main, sidechain, subagent)
116    /// Enables parallel conversation streams within same session
117    #[serde(default)]
118    pub stream_id: StreamId,
119
120    /// Event type and content (flattened enum)
121    #[serde(flatten)]
122    pub payload: EventPayload,
123
124    /// Provider-specific raw data and debug information
125    /// Examples: Codex "call_id", Gemini "finish_reason", etc.
126    #[serde(default, skip_serializing_if = "Option::is_none")]
127    pub metadata: Option<Value>,
128}
129
130/// Event payload variants
131#[derive(Debug, Clone, Serialize, Deserialize)]
132#[serde(tag = "type", content = "content")]
133#[serde(rename_all = "snake_case")]
134pub enum EventPayload {
135    /// 1. User input (Trigger)
136    User(UserPayload),
137
138    /// 2. Assistant reasoning/thinking process (Gemini thoughts, etc.)
139    Reasoning(ReasoningPayload),
140
141    /// 3. Tool execution request (Action Request)
142    ///
143    /// Note: TokenUsage can be attached as sidecar to this
144    ToolCall(ToolCallPayload),
145
146    /// 4. Tool execution result (Action Result)
147    ToolResult(ToolResultPayload),
148
149    /// 5. Assistant text response (Final Response)
150    ///
151    /// Note: TokenUsage can be attached as sidecar to this
152    Message(MessagePayload),
153
154    /// 6. Cost information (Sidecar / Leaf Node)
155    ///
156    /// Not included in context, used for cost calculation
157    TokenUsage(TokenUsagePayload),
158
159    /// 7. User-facing system notification (updates, alerts, status changes)
160    Notification(NotificationPayload),
161}
162
163// --- Payload Definitions ---
164
165#[derive(Debug, Clone, Serialize, Deserialize)]
166pub struct UserPayload {
167    /// User input text
168    pub text: String,
169}
170
171#[derive(Debug, Clone, Serialize, Deserialize)]
172pub struct ReasoningPayload {
173    /// Reasoning/thinking content
174    pub text: String,
175}
176
177/// Normalized tool call with structured arguments
178///
179/// This enum provides type-safe access to common tool call patterns while
180/// maintaining compatibility with the original JSON structure.
181#[derive(Debug, Clone, Serialize, Deserialize)]
182#[serde(untagged)]
183pub enum ToolCallPayload {
184    /// File read operation (Read, Glob, etc.)
185    FileRead {
186        name: String,
187        arguments: FileReadArgs,
188        #[serde(default, skip_serializing_if = "Option::is_none")]
189        provider_call_id: Option<String>,
190    },
191
192    /// File edit operation (Edit)
193    FileEdit {
194        name: String,
195        arguments: FileEditArgs,
196        #[serde(default, skip_serializing_if = "Option::is_none")]
197        provider_call_id: Option<String>,
198    },
199
200    /// File write operation (Write)
201    FileWrite {
202        name: String,
203        arguments: FileWriteArgs,
204        #[serde(default, skip_serializing_if = "Option::is_none")]
205        provider_call_id: Option<String>,
206    },
207
208    /// Execute/shell command (Bash, etc.)
209    Execute {
210        name: String,
211        arguments: ExecuteArgs,
212        #[serde(default, skip_serializing_if = "Option::is_none")]
213        provider_call_id: Option<String>,
214    },
215
216    /// Search operation (Grep, WebSearch, etc.)
217    Search {
218        name: String,
219        arguments: SearchArgs,
220        #[serde(default, skip_serializing_if = "Option::is_none")]
221        provider_call_id: Option<String>,
222    },
223
224    /// MCP (Model Context Protocol) tool call
225    Mcp {
226        name: String,
227        arguments: McpArgs,
228        #[serde(default, skip_serializing_if = "Option::is_none")]
229        provider_call_id: Option<String>,
230    },
231
232    /// Generic/fallback for unknown or custom tools
233    Generic {
234        name: String,
235        arguments: Value,
236        #[serde(default, skip_serializing_if = "Option::is_none")]
237        provider_call_id: Option<String>,
238    },
239}
240
241impl ToolCallPayload {
242    /// Get tool name regardless of variant
243    pub fn name(&self) -> &str {
244        match self {
245            ToolCallPayload::FileRead { name, .. } => name,
246            ToolCallPayload::FileEdit { name, .. } => name,
247            ToolCallPayload::FileWrite { name, .. } => name,
248            ToolCallPayload::Execute { name, .. } => name,
249            ToolCallPayload::Search { name, .. } => name,
250            ToolCallPayload::Mcp { name, .. } => name,
251            ToolCallPayload::Generic { name, .. } => name,
252        }
253    }
254
255    /// Get provider call ID regardless of variant
256    pub fn provider_call_id(&self) -> Option<&str> {
257        match self {
258            ToolCallPayload::FileRead {
259                provider_call_id, ..
260            } => provider_call_id.as_deref(),
261            ToolCallPayload::FileEdit {
262                provider_call_id, ..
263            } => provider_call_id.as_deref(),
264            ToolCallPayload::FileWrite {
265                provider_call_id, ..
266            } => provider_call_id.as_deref(),
267            ToolCallPayload::Execute {
268                provider_call_id, ..
269            } => provider_call_id.as_deref(),
270            ToolCallPayload::Search {
271                provider_call_id, ..
272            } => provider_call_id.as_deref(),
273            ToolCallPayload::Mcp {
274                provider_call_id, ..
275            } => provider_call_id.as_deref(),
276            ToolCallPayload::Generic {
277                provider_call_id, ..
278            } => provider_call_id.as_deref(),
279        }
280    }
281
282    /// Derive semantic ToolKind from ToolCallPayload variant
283    pub fn kind(&self) -> ToolKind {
284        match self {
285            ToolCallPayload::FileRead { .. } => ToolKind::Read,
286            ToolCallPayload::FileEdit { .. } => ToolKind::Write,
287            ToolCallPayload::FileWrite { .. } => ToolKind::Write,
288            ToolCallPayload::Execute { .. } => ToolKind::Execute,
289            ToolCallPayload::Search { .. } => ToolKind::Search,
290            ToolCallPayload::Mcp { .. } => ToolKind::Other,
291            ToolCallPayload::Generic { .. } => ToolKind::Other,
292        }
293    }
294}
295
296// --- Tool Arguments ---
297
298#[derive(Debug, Clone, Serialize, Deserialize)]
299pub struct FileReadArgs {
300    #[serde(default, skip_serializing_if = "Option::is_none")]
301    pub file_path: Option<String>,
302    #[serde(default, skip_serializing_if = "Option::is_none")]
303    pub path: Option<String>,
304    #[serde(default, skip_serializing_if = "Option::is_none")]
305    pub pattern: Option<String>,
306    #[serde(flatten)]
307    pub extra: Value,
308}
309
310impl FileReadArgs {
311    /// Get file path from various field names
312    pub fn path(&self) -> Option<&str> {
313        self.file_path.as_deref().or(self.path.as_deref())
314    }
315}
316
317#[derive(Debug, Clone, Serialize, Deserialize)]
318pub struct FileEditArgs {
319    pub file_path: String,
320    pub old_string: String,
321    pub new_string: String,
322    #[serde(default)]
323    pub replace_all: bool,
324}
325
326#[derive(Debug, Clone, Serialize, Deserialize)]
327pub struct FileWriteArgs {
328    pub file_path: String,
329    pub content: String,
330}
331
332#[derive(Debug, Clone, Serialize, Deserialize)]
333pub struct ExecuteArgs {
334    #[serde(default, skip_serializing_if = "Option::is_none")]
335    pub command: Option<String>,
336    #[serde(default, skip_serializing_if = "Option::is_none")]
337    pub description: Option<String>,
338    #[serde(default, skip_serializing_if = "Option::is_none")]
339    pub timeout: Option<u64>,
340    #[serde(flatten)]
341    pub extra: Value,
342}
343
344impl ExecuteArgs {
345    pub fn command(&self) -> Option<&str> {
346        self.command.as_deref()
347    }
348}
349
350#[derive(Debug, Clone, Serialize, Deserialize)]
351pub struct SearchArgs {
352    #[serde(default, skip_serializing_if = "Option::is_none")]
353    pub pattern: Option<String>,
354    #[serde(default, skip_serializing_if = "Option::is_none")]
355    pub query: Option<String>,
356    #[serde(default, skip_serializing_if = "Option::is_none")]
357    pub input: Option<String>,
358    #[serde(default, skip_serializing_if = "Option::is_none")]
359    pub path: Option<String>,
360    #[serde(flatten)]
361    pub extra: Value,
362}
363
364impl SearchArgs {
365    /// Get search pattern from various field names
366    pub fn pattern(&self) -> Option<&str> {
367        self.pattern
368            .as_deref()
369            .or(self.query.as_deref())
370            .or(self.input.as_deref())
371    }
372}
373
374#[derive(Debug, Clone, Serialize, Deserialize)]
375pub struct McpArgs {
376    #[serde(flatten)]
377    pub inner: Value,
378}
379
380impl McpArgs {
381    /// Parse MCP tool name from full name (e.g., "mcp__o3__o3-search" -> ("o3", "o3-search"))
382    pub fn parse_name(full_name: &str) -> Option<(String, String)> {
383        if !full_name.starts_with("mcp__") {
384            return None;
385        }
386
387        let rest = &full_name[5..]; // Remove "mcp__"
388        let parts: Vec<&str> = rest.splitn(2, "__").collect();
389
390        if parts.len() == 2 {
391            Some((parts[0].to_string(), parts[1].to_string()))
392        } else {
393            None
394        }
395    }
396
397    /// Get server name from full MCP tool name
398    pub fn server_name(full_name: &str) -> Option<String> {
399        Self::parse_name(full_name).map(|(server, _)| server)
400    }
401
402    /// Get tool name from full MCP tool name
403    pub fn tool_name(full_name: &str) -> Option<String> {
404        Self::parse_name(full_name).map(|(_, tool)| tool)
405    }
406}
407
408#[derive(Debug, Clone, Serialize, Deserialize)]
409pub struct ToolResultPayload {
410    /// Tool execution result (text, JSON string, error message, etc.)
411    pub output: String,
412
413    /// Logical parent (Tool Call) reference ID
414    /// Separate from parent_id (time-series parent) to explicitly identify which call this result belongs to
415    pub tool_call_id: Uuid,
416
417    /// Execution success or failure
418    #[serde(default)]
419    pub is_error: bool,
420}
421
422#[derive(Debug, Clone, Serialize, Deserialize)]
423pub struct MessagePayload {
424    /// Response text
425    pub text: String,
426}
427
428#[derive(Debug, Clone, Serialize, Deserialize)]
429pub struct TokenUsagePayload {
430    /// Input tokens (incremental)
431    pub input_tokens: i32,
432    /// Output tokens (incremental)
433    pub output_tokens: i32,
434    /// Total tokens (incremental)
435    pub total_tokens: i32,
436
437    /// Detailed breakdown (optional)
438    #[serde(default, skip_serializing_if = "Option::is_none")]
439    pub details: Option<TokenUsageDetails>,
440}
441
442#[derive(Debug, Clone, Serialize, Deserialize)]
443pub struct TokenUsageDetails {
444    /// Cache creation input tokens (Claude/Gemini)
445    pub cache_creation_input_tokens: Option<i32>,
446    /// Cache read input tokens (Claude/Gemini)
447    pub cache_read_input_tokens: Option<i32>,
448    /// Reasoning output tokens (o1/Gemini)
449    pub reasoning_output_tokens: Option<i32>,
450}
451
452#[derive(Debug, Clone, Serialize, Deserialize)]
453pub struct NotificationPayload {
454    /// Notification message text
455    pub text: String,
456    /// Optional severity level (e.g., "info", "warning", "error")
457    #[serde(default, skip_serializing_if = "Option::is_none")]
458    pub level: Option<String>,
459}
460
461#[cfg(test)]
462mod tests {
463    use super::*;
464
465    #[test]
466    fn test_serialization() {
467        let event = AgentEvent {
468            id: Uuid::new_v4(),
469            session_id: Uuid::new_v4(),
470            parent_id: None,
471            timestamp: Utc::now(),
472            stream_id: StreamId::Main,
473            payload: EventPayload::User(UserPayload {
474                text: "Hello".to_string(),
475            }),
476            metadata: None,
477        };
478
479        let json = serde_json::to_string(&event).unwrap();
480        let deserialized: AgentEvent = serde_json::from_str(&json).unwrap();
481
482        match deserialized.payload {
483            EventPayload::User(payload) => assert_eq!(payload.text, "Hello"),
484            _ => panic!("Wrong payload type"),
485        }
486    }
487
488    #[test]
489    fn test_stream_id_variants() {
490        // Test Main stream
491        let main_stream = StreamId::Main;
492        assert_eq!(main_stream.as_str(), "main");
493
494        // Test Sidechain stream
495        let sidechain_stream = StreamId::Sidechain {
496            agent_id: "abc123".to_string(),
497        };
498        assert_eq!(sidechain_stream.as_str(), "sidechain:abc123");
499
500        // Test Subagent stream
501        let subagent_stream = StreamId::Subagent {
502            name: "review".to_string(),
503        };
504        assert_eq!(subagent_stream.as_str(), "subagent:review");
505    }
506
507    #[test]
508    fn test_tool_call_serialization_roundtrip() {
509        let original = ToolCallPayload::FileRead {
510            name: "Read".to_string(),
511            arguments: FileReadArgs {
512                file_path: Some("/path/to/file.rs".to_string()),
513                path: None,
514                pattern: None,
515                extra: serde_json::json!({}),
516            },
517            provider_call_id: Some("call_123".to_string()),
518        };
519
520        let json = serde_json::to_string(&original).unwrap();
521        let deserialized: ToolCallPayload = serde_json::from_str(&json).unwrap();
522
523        match deserialized {
524            ToolCallPayload::FileRead {
525                name,
526                arguments,
527                provider_call_id,
528            } => {
529                assert_eq!(name, "Read");
530                assert_eq!(arguments.file_path, Some("/path/to/file.rs".to_string()));
531                assert_eq!(provider_call_id, Some("call_123".to_string()));
532            }
533            _ => panic!("Expected FileRead variant"),
534        }
535    }
536
537    #[test]
538    fn test_file_read_args_path_helper() {
539        let args1 = FileReadArgs {
540            file_path: Some("/path1".to_string()),
541            path: None,
542            pattern: None,
543            extra: serde_json::json!({}),
544        };
545        assert_eq!(args1.path(), Some("/path1"));
546
547        let args2 = FileReadArgs {
548            file_path: None,
549            path: Some("/path2".to_string()),
550            pattern: None,
551            extra: serde_json::json!({}),
552        };
553        assert_eq!(args2.path(), Some("/path2"));
554
555        let args3 = FileReadArgs {
556            file_path: Some("/path1".to_string()),
557            path: Some("/path2".to_string()),
558            pattern: None,
559            extra: serde_json::json!({}),
560        };
561        assert_eq!(args3.path(), Some("/path1"));
562    }
563
564    #[test]
565    fn test_search_args_pattern_helper() {
566        let args1 = SearchArgs {
567            pattern: Some("pattern1".to_string()),
568            query: None,
569            input: None,
570            path: None,
571            extra: serde_json::json!({}),
572        };
573        assert_eq!(args1.pattern(), Some("pattern1"));
574
575        let args2 = SearchArgs {
576            pattern: None,
577            query: Some("query2".to_string()),
578            input: None,
579            path: None,
580            extra: serde_json::json!({}),
581        };
582        assert_eq!(args2.pattern(), Some("query2"));
583
584        let args3 = SearchArgs {
585            pattern: None,
586            query: None,
587            input: Some("input3".to_string()),
588            path: None,
589            extra: serde_json::json!({}),
590        };
591        assert_eq!(args3.pattern(), Some("input3"));
592    }
593
594    #[test]
595    fn test_mcp_args_parse_name() {
596        assert_eq!(
597            McpArgs::parse_name("mcp__o3__o3-search"),
598            Some(("o3".to_string(), "o3-search".to_string()))
599        );
600
601        assert_eq!(
602            McpArgs::parse_name("mcp__sqlite__query"),
603            Some(("sqlite".to_string(), "query".to_string()))
604        );
605
606        assert_eq!(McpArgs::parse_name("not_mcp_tool"), None);
607        assert_eq!(McpArgs::parse_name("mcp__only_server"), None);
608    }
609
610    #[test]
611    fn test_mcp_args_server_and_tool_name() {
612        assert_eq!(
613            McpArgs::server_name("mcp__o3__o3-search"),
614            Some("o3".to_string())
615        );
616        assert_eq!(
617            McpArgs::tool_name("mcp__o3__o3-search"),
618            Some("o3-search".to_string())
619        );
620    }
621
622    #[test]
623    fn test_tool_call_kind_derivation() {
624        let read_payload = ToolCallPayload::FileRead {
625            name: "Read".to_string(),
626            arguments: FileReadArgs {
627                file_path: Some("/path".to_string()),
628                path: None,
629                pattern: None,
630                extra: serde_json::json!({}),
631            },
632            provider_call_id: None,
633        };
634        assert_eq!(read_payload.kind(), ToolKind::Read);
635
636        let edit_payload = ToolCallPayload::FileEdit {
637            name: "Edit".to_string(),
638            arguments: FileEditArgs {
639                file_path: "/path".to_string(),
640                old_string: "old".to_string(),
641                new_string: "new".to_string(),
642                replace_all: false,
643            },
644            provider_call_id: None,
645        };
646        assert_eq!(edit_payload.kind(), ToolKind::Write);
647
648        let write_payload = ToolCallPayload::FileWrite {
649            name: "Write".to_string(),
650            arguments: FileWriteArgs {
651                file_path: "/path".to_string(),
652                content: "content".to_string(),
653            },
654            provider_call_id: None,
655        };
656        assert_eq!(write_payload.kind(), ToolKind::Write);
657
658        let exec_payload = ToolCallPayload::Execute {
659            name: "Bash".to_string(),
660            arguments: ExecuteArgs {
661                command: Some("ls".to_string()),
662                description: None,
663                timeout: None,
664                extra: serde_json::json!({}),
665            },
666            provider_call_id: None,
667        };
668        assert_eq!(exec_payload.kind(), ToolKind::Execute);
669
670        let search_payload = ToolCallPayload::Search {
671            name: "Grep".to_string(),
672            arguments: SearchArgs {
673                pattern: Some("pattern".to_string()),
674                query: None,
675                input: None,
676                path: None,
677                extra: serde_json::json!({}),
678            },
679            provider_call_id: None,
680        };
681        assert_eq!(search_payload.kind(), ToolKind::Search);
682
683        let mcp_payload = ToolCallPayload::Mcp {
684            name: "mcp__o3__search".to_string(),
685            arguments: McpArgs {
686                inner: serde_json::json!({}),
687            },
688            provider_call_id: None,
689        };
690        assert_eq!(mcp_payload.kind(), ToolKind::Other);
691
692        let generic_payload = ToolCallPayload::Generic {
693            name: "CustomTool".to_string(),
694            arguments: serde_json::json!({}),
695            provider_call_id: None,
696        };
697        assert_eq!(generic_payload.kind(), ToolKind::Other);
698    }
699}