Skip to main content

zag_agent/providers/claude/
models.rs

1/// Claude-specific JSON output models.
2///
3/// These structures directly map to the JSON output format produced by the
4/// Claude CLI when running with `--output json` (verbose mode). They can be
5/// deserialized from JSON and then converted to the unified `AgentOutput` format.
6///
7/// See README.md in this directory for detailed documentation on the output format.
8use serde::{Deserialize, Serialize};
9use std::collections::HashMap;
10
11use crate::output::{
12    AgentOutput, ContentBlock as UnifiedContentBlock, Event as UnifiedEvent, ToolResult,
13    Usage as UnifiedUsage,
14};
15
16/// The root structure: an array of events.
17pub type ClaudeOutput = Vec<ClaudeEvent>;
18
19/// A single event in Claude's output stream.
20#[derive(Debug, Clone, Serialize, Deserialize)]
21#[serde(tag = "type", rename_all = "snake_case")]
22pub enum ClaudeEvent {
23    /// System initialization event
24    System {
25        subtype: String,
26        session_id: String,
27        cwd: Option<String>,
28        model: String,
29        tools: Vec<String>,
30        #[serde(default)]
31        mcp_servers: Vec<serde_json::Value>,
32        #[serde(rename = "permissionMode")]
33        permission_mode: Option<String>,
34        #[serde(default)]
35        slash_commands: Vec<String>,
36        #[serde(default)]
37        agents: Vec<String>,
38        #[serde(default)]
39        skills: Vec<serde_json::Value>,
40        #[serde(default)]
41        plugins: Vec<Plugin>,
42        uuid: String,
43        #[serde(flatten)]
44        extra: HashMap<String, serde_json::Value>,
45    },
46
47    /// Assistant message event
48    Assistant {
49        message: Message,
50        parent_tool_use_id: Option<String>,
51        session_id: String,
52        uuid: String,
53    },
54
55    /// User message event (tool results)
56    User {
57        message: UserMessage,
58        parent_tool_use_id: Option<String>,
59        session_id: String,
60        uuid: String,
61        tool_use_result: Option<serde_json::Value>,
62    },
63
64    /// Final result event
65    Result {
66        subtype: String,
67        is_error: bool,
68        duration_ms: u64,
69        duration_api_ms: u64,
70        num_turns: u32,
71        result: String,
72        session_id: String,
73        total_cost_usd: f64,
74        usage: Usage,
75        #[serde(default, rename = "modelUsage")]
76        model_usage: HashMap<String, ModelUsage>,
77        #[serde(default)]
78        permission_denials: Vec<PermissionDenial>,
79        uuid: String,
80    },
81
82    /// Unknown/unhandled event type (e.g., rate_limit_event) — silently ignored
83    #[serde(other)]
84    Other,
85}
86
87/// An assistant message from Claude.
88#[derive(Debug, Clone, Serialize, Deserialize)]
89pub struct Message {
90    pub model: String,
91    pub id: String,
92    #[serde(rename = "type")]
93    pub message_type: String,
94    pub role: String,
95    pub content: Vec<ContentBlock>,
96    pub stop_reason: Option<String>,
97    pub stop_sequence: Option<String>,
98    pub usage: Usage,
99    pub context_management: Option<serde_json::Value>,
100}
101
102/// A user message containing tool results and other content.
103#[derive(Debug, Clone, Serialize, Deserialize)]
104pub struct UserMessage {
105    pub role: String,
106    pub content: Vec<UserContentBlock>,
107}
108
109/// A content block in an assistant message.
110#[derive(Debug, Clone, Serialize, Deserialize)]
111#[serde(tag = "type", rename_all = "snake_case")]
112pub enum ContentBlock {
113    /// Text content
114    Text { text: String },
115
116    /// Tool invocation
117    ToolUse {
118        id: String,
119        name: String,
120        input: serde_json::Value,
121    },
122
123    /// Thinking content (extended thinking)
124    Thinking {
125        #[serde(default)]
126        thinking: String,
127        #[serde(flatten)]
128        extra: HashMap<String, serde_json::Value>,
129    },
130}
131
132/// A content block in a user message (tool results, text, or other types).
133#[derive(Debug, Clone, Serialize, Deserialize)]
134#[serde(tag = "type", rename_all = "snake_case")]
135pub enum UserContentBlock {
136    /// Tool result
137    ToolResult {
138        tool_use_id: String,
139        content: String,
140        #[serde(default)]
141        is_error: bool,
142    },
143
144    /// Text content
145    Text { text: String },
146
147    /// Any other content type
148    #[serde(other)]
149    Other,
150}
151
152/// Usage statistics for a message or session.
153#[derive(Debug, Clone, Serialize, Deserialize)]
154pub struct Usage {
155    pub input_tokens: u64,
156    #[serde(default)]
157    pub cache_creation_input_tokens: u64,
158    #[serde(default)]
159    pub cache_read_input_tokens: u64,
160    pub output_tokens: u64,
161    #[serde(default)]
162    pub cache_creation: Option<CacheCreation>,
163    #[serde(default)]
164    pub server_tool_use: Option<ServerToolUse>,
165    #[serde(default)]
166    pub service_tier: Option<String>,
167}
168
169/// Cache creation details.
170#[derive(Debug, Clone, Serialize, Deserialize)]
171pub struct CacheCreation {
172    #[serde(default)]
173    pub ephemeral_5m_input_tokens: u64,
174    #[serde(default)]
175    pub ephemeral_1h_input_tokens: u64,
176}
177
178/// Server-side tool usage.
179#[derive(Debug, Clone, Serialize, Deserialize)]
180pub struct ServerToolUse {
181    #[serde(default)]
182    pub web_search_requests: u32,
183    #[serde(default)]
184    pub web_fetch_requests: u32,
185}
186
187/// Per-model usage statistics.
188#[derive(Debug, Clone, Serialize, Deserialize)]
189pub struct ModelUsage {
190    #[serde(rename = "inputTokens")]
191    pub input_tokens: u64,
192    #[serde(rename = "outputTokens")]
193    pub output_tokens: u64,
194    #[serde(default, rename = "cacheReadInputTokens")]
195    pub cache_read_input_tokens: u64,
196    #[serde(default, rename = "cacheCreationInputTokens")]
197    pub cache_creation_input_tokens: u64,
198    #[serde(default, rename = "webSearchRequests")]
199    pub web_search_requests: u32,
200    #[serde(rename = "costUSD")]
201    pub cost_usd: f64,
202    #[serde(default, rename = "contextWindow")]
203    pub context_window: u64,
204    #[serde(default, rename = "maxOutputTokens")]
205    pub max_output_tokens: u64,
206}
207
208/// Information about a denied permission request.
209#[derive(Debug, Clone, Serialize, Deserialize)]
210pub struct PermissionDenial {
211    pub tool_name: String,
212    pub tool_use_id: String,
213    pub tool_input: serde_json::Value,
214}
215
216/// Plugin information.
217#[derive(Debug, Clone, Serialize, Deserialize)]
218pub struct Plugin {
219    pub name: String,
220    pub path: String,
221}
222
223/// Convert Claude output to unified agent output.
224pub fn claude_output_to_agent_output(claude_output: ClaudeOutput) -> AgentOutput {
225    let mut session_id = String::from("unknown");
226    let mut result = None;
227    let mut is_error = false;
228    let mut total_cost_usd = None;
229    let mut usage = None;
230    let mut events = Vec::new();
231
232    // Turn-boundary state for synthesizing Event::TurnComplete before each
233    // Event::Result. Mirrors `ClaudeEventTranslator` in the streaming path
234    // but is inlined here because the full-parse path also does its own
235    // metadata extraction (session_id, total_cost_usd, ...) that doesn't
236    // fit the translator's per-event shape.
237    let mut pending_stop_reason: Option<String> = None;
238    let mut pending_turn_usage: Option<UnifiedUsage> = None;
239    let mut next_turn_index: u32 = 0;
240
241    // Track text from the last assistant message for fallback when
242    // Result.result is empty (e.g. when --json-schema is used, Claude Code
243    // may put the content in the assistant message but leave the result
244    // field blank).
245    let mut last_assistant_text: Option<String> = None;
246
247    for event in claude_output {
248        match event {
249            ClaudeEvent::System {
250                session_id: sid,
251                model,
252                tools,
253                cwd,
254                mut extra,
255                ..
256            } => {
257                session_id = sid;
258
259                // Include all extra fields as metadata
260                if let Some(cwd) = cwd {
261                    extra.insert("cwd".to_string(), serde_json::json!(cwd));
262                }
263
264                events.push(UnifiedEvent::Init {
265                    model,
266                    tools,
267                    working_directory: extra
268                        .get("cwd")
269                        .and_then(|v| v.as_str().map(|s| s.to_string())),
270                    metadata: extra,
271                });
272            }
273
274            ClaudeEvent::Assistant {
275                message,
276                session_id: sid,
277                parent_tool_use_id,
278                ..
279            } => {
280                session_id = sid;
281
282                // Track the latest stop_reason for the current turn; the
283                // final assistant message before a Result is the one whose
284                // stop_reason explains why the turn ended.
285                if let Some(reason) = &message.stop_reason {
286                    pending_stop_reason = Some(reason.clone());
287                }
288
289                // Convert content blocks (skip thinking blocks)
290                let content: Vec<UnifiedContentBlock> = message
291                    .content
292                    .into_iter()
293                    .filter_map(|block| match block {
294                        ContentBlock::Text { text } => Some(UnifiedContentBlock::Text { text }),
295                        ContentBlock::ToolUse { id, name, input } => {
296                            Some(UnifiedContentBlock::ToolUse { id, name, input })
297                        }
298                        ContentBlock::Thinking { .. } => None,
299                    })
300                    .collect();
301
302                // Collect text blocks for fallback result extraction.
303                let text_parts: Vec<&str> = content
304                    .iter()
305                    .filter_map(|b| match b {
306                        UnifiedContentBlock::Text { text } => Some(text.as_str()),
307                        _ => None,
308                    })
309                    .collect();
310                if !text_parts.is_empty() {
311                    last_assistant_text = Some(text_parts.join("\n"));
312                }
313
314                // Convert usage
315                let msg_usage = Some(UnifiedUsage {
316                    input_tokens: message.usage.input_tokens,
317                    output_tokens: message.usage.output_tokens,
318                    cache_read_tokens: Some(message.usage.cache_read_input_tokens),
319                    cache_creation_tokens: Some(message.usage.cache_creation_input_tokens),
320                    web_search_requests: message
321                        .usage
322                        .server_tool_use
323                        .as_ref()
324                        .map(|s| s.web_search_requests),
325                    web_fetch_requests: message
326                        .usage
327                        .server_tool_use
328                        .as_ref()
329                        .map(|s| s.web_fetch_requests),
330                });
331                pending_turn_usage = msg_usage.clone();
332
333                events.push(UnifiedEvent::AssistantMessage {
334                    content,
335                    usage: msg_usage,
336                    parent_tool_use_id,
337                });
338            }
339
340            ClaudeEvent::User {
341                message,
342                tool_use_result,
343                session_id: sid,
344                parent_tool_use_id,
345                ..
346            } => {
347                session_id = sid;
348
349                // Convert tool results to tool execution events (skip non-tool-result blocks)
350                for block in message.content {
351                    if let UserContentBlock::ToolResult {
352                        tool_use_id,
353                        content,
354                        is_error,
355                    } = block
356                    {
357                        let tool_name = find_tool_name(&events, &tool_use_id)
358                            .unwrap_or_else(|| "unknown".to_string());
359
360                        let tool_result = ToolResult {
361                            success: !is_error,
362                            output: if !is_error {
363                                Some(content.clone())
364                            } else {
365                                None
366                            },
367                            error: if is_error {
368                                Some(content.clone())
369                            } else {
370                                None
371                            },
372                            data: tool_use_result.clone(),
373                        };
374
375                        events.push(UnifiedEvent::ToolExecution {
376                            tool_name,
377                            tool_id: tool_use_id,
378                            input: serde_json::Value::Null,
379                            result: tool_result,
380                            parent_tool_use_id: parent_tool_use_id.clone(),
381                        });
382                    }
383                }
384            }
385
386            ClaudeEvent::Other => {
387                log::debug!("Skipping unknown Claude event type during output conversion");
388            }
389
390            ClaudeEvent::Result {
391                is_error: err,
392                result: res,
393                total_cost_usd: cost,
394                usage: u,
395                duration_ms,
396                num_turns,
397                permission_denials,
398                session_id: sid,
399                subtype: _,
400                ..
401            } => {
402                session_id = sid;
403                is_error = err;
404
405                // When Result.result is empty, fall back to the last assistant
406                // message text.  Claude Code sometimes puts the actual content
407                // (especially --json-schema output) in the assistant message
408                // while leaving the result field blank.
409                let effective_result = if res.is_empty() {
410                    if let Some(ref fallback) = last_assistant_text {
411                        log::debug!(
412                            "Result.result is empty; using last assistant text ({} bytes)",
413                            fallback.len()
414                        );
415                        fallback.clone()
416                    } else {
417                        res.clone()
418                    }
419                } else {
420                    res.clone()
421                };
422
423                result = Some(effective_result.clone());
424                total_cost_usd = Some(cost);
425
426                // Convert usage
427                usage = Some(UnifiedUsage {
428                    input_tokens: u.input_tokens,
429                    output_tokens: u.output_tokens,
430                    cache_read_tokens: Some(u.cache_read_input_tokens),
431                    cache_creation_tokens: Some(u.cache_creation_input_tokens),
432                    web_search_requests: u.server_tool_use.as_ref().map(|s| s.web_search_requests),
433                    web_fetch_requests: u.server_tool_use.as_ref().map(|s| s.web_fetch_requests),
434                });
435
436                // Add permission denial events
437                for denial in permission_denials {
438                    events.push(UnifiedEvent::PermissionRequest {
439                        tool_name: denial.tool_name,
440                        description: format!(
441                            "Permission denied for tool input: {}",
442                            serde_json::to_string(&denial.tool_input).unwrap_or_default()
443                        ),
444                        granted: false,
445                    });
446                }
447
448                // Emit TurnComplete immediately before the per-turn Result.
449                events.push(UnifiedEvent::TurnComplete {
450                    stop_reason: pending_stop_reason.take(),
451                    turn_index: next_turn_index,
452                    usage: pending_turn_usage.take(),
453                });
454                next_turn_index = next_turn_index.saturating_add(1);
455
456                // Add final result event
457                events.push(UnifiedEvent::Result {
458                    success: !err,
459                    message: Some(effective_result),
460                    duration_ms: Some(duration_ms),
461                    num_turns: Some(num_turns),
462                });
463            }
464        }
465    }
466
467    AgentOutput {
468        agent: "claude".to_string(),
469        session_id,
470        events,
471        result,
472        is_error,
473        exit_code: None,
474        error_message: None,
475        total_cost_usd,
476        usage,
477    }
478}
479
480/// Find the tool name for a given tool_use_id by searching previous events.
481fn find_tool_name(events: &[UnifiedEvent], tool_use_id: &str) -> Option<String> {
482    for event in events.iter().rev() {
483        if let UnifiedEvent::AssistantMessage { content, .. } = event {
484            for block in content {
485                if let UnifiedContentBlock::ToolUse { id, name, .. } = block
486                    && id == tool_use_id
487                {
488                    return Some(name.clone());
489                }
490            }
491        }
492    }
493    None
494}
495
496#[cfg(test)]
497#[path = "models_tests.rs"]
498mod tests;