agtrace_providers/
normalization.rs

1// Tool call normalization from raw provider data to typed ToolCallPayload variants
2//
3// Rationale for provider-layer placement:
4//   This module contains provider-specific knowledge about tool names and their
5//   argument schemas. While the ToolCallPayload enum itself is in agtrace-types
6//   (domain model), the logic to map raw tool names to typed variants belongs here.
7//
8// Design principle:
9//   - agtrace-types: Defines domain model structure (ToolCallPayload enum)
10//   - agtrace-providers: Knows how to normalize provider data into domain model
11//   - This separation keeps types pure and provider logic centralized
12
13use agtrace_types::ToolCallPayload;
14use serde_json::Value;
15
16/// Normalize raw tool call data into a typed ToolCallPayload variant
17///
18/// # Deprecation Notice
19///
20/// **This function is deprecated.** Use provider-specific `ToolMapper::normalize_call` instead.
21///
22/// ## Why deprecated?
23///
24/// This function contains provider-specific logic that violates architecture principles:
25/// - Provider details leak into a provider-agnostic layer
26/// - Not scalable: adding providers requires modifying this function
27/// - Responsibility inversion: provider-specific logic should be in provider implementations
28///
29/// ## Migration path
30///
31/// Instead of using this function directly, use the `ToolMapper` trait:
32///
33/// ```rust,ignore
34/// use agtrace_providers::ProviderAdapter;
35///
36/// let adapter = ProviderAdapter::claude();
37/// let payload = adapter.mapper.normalize_call("Read", args, Some("call_123"));
38/// ```
39///
40/// Each provider implements `ToolMapper::normalize_call` with provider-specific logic:
41/// - `ClaudeToolMapper` handles Claude Code tools
42/// - `CodexToolMapper` handles Codex tools
43/// - `GeminiToolMapper` handles Gemini tools
44///
45/// ## Legacy behavior
46///
47/// This function provides generic normalization for common tool patterns.
48/// It does NOT parse provider-specific details like MCP server/tool names.
49/// For full normalization, use provider-specific mappers.
50#[deprecated(
51    since = "0.3.0",
52    note = "Use ToolMapper::normalize_call instead. See function docs for migration path."
53)]
54pub fn normalize_tool_call(
55    name: String,
56    arguments: Value,
57    provider_call_id: Option<String>,
58) -> ToolCallPayload {
59    // Try to parse into specific variants based on name
60    match name.as_str() {
61        "Read" | "Glob" => {
62            if let Ok(args) = serde_json::from_value(arguments.clone()) {
63                return ToolCallPayload::FileRead {
64                    name,
65                    arguments: args,
66                    provider_call_id,
67                };
68            }
69        }
70        "Edit" => {
71            if let Ok(args) = serde_json::from_value(arguments.clone()) {
72                return ToolCallPayload::FileEdit {
73                    name,
74                    arguments: args,
75                    provider_call_id,
76                };
77            }
78        }
79        "Write" => {
80            if let Ok(args) = serde_json::from_value(arguments.clone()) {
81                return ToolCallPayload::FileWrite {
82                    name,
83                    arguments: args,
84                    provider_call_id,
85                };
86            }
87        }
88        "Bash" | "KillShell" | "BashOutput" => {
89            if let Ok(args) = serde_json::from_value(arguments.clone()) {
90                return ToolCallPayload::Execute {
91                    name,
92                    arguments: args,
93                    provider_call_id,
94                };
95            }
96        }
97        "Grep" | "WebSearch" | "WebFetch" => {
98            if let Ok(args) = serde_json::from_value(arguments.clone()) {
99                return ToolCallPayload::Search {
100                    name,
101                    arguments: args,
102                    provider_call_id,
103                };
104            }
105        }
106        _ if name.starts_with("mcp__") => {
107            if let Ok(args) = serde_json::from_value(arguments.clone()) {
108                return ToolCallPayload::Mcp {
109                    name,
110                    arguments: args,
111                    provider_call_id,
112                };
113            }
114        }
115        _ => {}
116    }
117
118    // Fallback to generic
119    ToolCallPayload::Generic {
120        name,
121        arguments,
122        provider_call_id,
123    }
124}
125
126#[cfg(test)]
127mod tests {
128    use super::*;
129    use serde_json::json;
130
131    #[allow(deprecated)]
132    #[test]
133    fn test_normalize_file_read() {
134        let payload = normalize_tool_call(
135            "Read".to_string(),
136            json!({"file_path": "/path/to/file.rs"}),
137            Some("call_123".to_string()),
138        );
139
140        match payload {
141            ToolCallPayload::FileRead {
142                name,
143                arguments,
144                provider_call_id,
145            } => {
146                assert_eq!(name, "Read");
147                assert_eq!(arguments.path(), Some("/path/to/file.rs"));
148                assert_eq!(provider_call_id, Some("call_123".to_string()));
149            }
150            _ => panic!("Expected FileRead variant"),
151        }
152    }
153
154    #[allow(deprecated)]
155    #[test]
156    fn test_normalize_execute() {
157        let payload = normalize_tool_call(
158            "Bash".to_string(),
159            json!({"command": "ls -la"}),
160            Some("call_456".to_string()),
161        );
162
163        match payload {
164            ToolCallPayload::Execute {
165                name,
166                arguments,
167                provider_call_id,
168            } => {
169                assert_eq!(name, "Bash");
170                assert_eq!(arguments.command, Some("ls -la".to_string()));
171                assert_eq!(provider_call_id, Some("call_456".to_string()));
172            }
173            _ => panic!("Expected Execute variant"),
174        }
175    }
176
177    #[allow(deprecated)]
178    #[test]
179    fn test_normalize_mcp_tool() {
180        let payload = normalize_tool_call(
181            "mcp__o3__search".to_string(),
182            json!({"query": "test"}),
183            Some("call_789".to_string()),
184        );
185
186        match payload {
187            ToolCallPayload::Mcp {
188                name,
189                arguments,
190                provider_call_id,
191            } => {
192                assert_eq!(name, "mcp__o3__search");
193                // McpArgs wraps raw JSON, verify it contains the query
194                assert_eq!(
195                    arguments.inner.get("query").and_then(|v| v.as_str()),
196                    Some("test")
197                );
198                assert_eq!(provider_call_id, Some("call_789".to_string()));
199            }
200            _ => panic!("Expected Mcp variant"),
201        }
202    }
203
204    #[allow(deprecated)]
205    #[test]
206    fn test_normalize_unknown_tool_fallback() {
207        let payload = normalize_tool_call(
208            "UnknownTool".to_string(),
209            json!({"foo": "bar"}),
210            Some("call_999".to_string()),
211        );
212
213        match payload {
214            ToolCallPayload::Generic {
215                name,
216                arguments,
217                provider_call_id,
218            } => {
219                assert_eq!(name, "UnknownTool");
220                assert_eq!(arguments, json!({"foo": "bar"}));
221                assert_eq!(provider_call_id, Some("call_999".to_string()));
222            }
223            _ => panic!("Expected Generic variant for unknown tool"),
224        }
225    }
226
227    #[allow(deprecated)]
228    #[test]
229    fn test_normalize_invalid_arguments_fallback() {
230        // FileReadArgs has `extra: Value` field, so it accepts any fields
231        // This test verifies that invalid fields are captured in `extra`
232        let payload = normalize_tool_call(
233            "Read".to_string(),
234            json!({"invalid_field": 123}),
235            Some("call_000".to_string()),
236        );
237
238        // Should parse as FileRead with invalid field in `extra`
239        match payload {
240            ToolCallPayload::FileRead {
241                name, arguments, ..
242            } => {
243                assert_eq!(name, "Read");
244                assert_eq!(arguments.file_path, None);
245                assert_eq!(arguments.path, None);
246                assert_eq!(arguments.pattern, None);
247                assert_eq!(arguments.extra.get("invalid_field"), Some(&json!(123)));
248            }
249            _ => panic!("Expected FileRead variant, got: {:?}", payload.kind()),
250        }
251    }
252}