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