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                // Scan assistant text for upstream usage-limit signals (e.g.
380                // `Claude AI usage limit reached|<epoch>`). The streaming
381                // translator does the same — mirroring here keeps exec/batch
382                // mode consistent so `record_agent_output` produces a
383                // `UsageLimitHit` log event in both paths.
384                if let Some(ref text) = last_assistant_text {
385                    let cfg = crate::usage_limits::UsageLimitConfig::default();
386                    if let Some(hit) = super::usage_limits::detect_text(text, &cfg) {
387                        events.push(UnifiedEvent::UsageLimitDetected {
388                            provider: hit.provider.to_string(),
389                            scope: hit.scope.as_str().to_string(),
390                            reset_at: hit.reset_at.map(|t| t.to_rfc3339()),
391                            raw: Some(hit.raw),
392                        });
393                    }
394                }
395
396                events.push(UnifiedEvent::AssistantMessage {
397                    content,
398                    usage: msg_usage,
399                    parent_tool_use_id,
400                });
401            }
402
403            ClaudeEvent::User {
404                message,
405                tool_use_result,
406                session_id: sid,
407                parent_tool_use_id,
408                ..
409            } => {
410                session_id = sid;
411
412                // Convert tool results to tool execution events (skip non-tool-result blocks)
413                for block in message.content {
414                    if let UserContentBlock::ToolResult {
415                        tool_use_id,
416                        content,
417                        is_error,
418                    } = block
419                    {
420                        let tool_name = find_tool_name(&events, &tool_use_id)
421                            .unwrap_or_else(|| "unknown".to_string());
422
423                        let tool_result = ToolResult {
424                            success: !is_error,
425                            output: if !is_error {
426                                Some(content.clone())
427                            } else {
428                                None
429                            },
430                            error: if is_error {
431                                Some(content.clone())
432                            } else {
433                                None
434                            },
435                            data: tool_use_result.clone(),
436                        };
437
438                        events.push(UnifiedEvent::ToolExecution {
439                            tool_name,
440                            tool_id: tool_use_id,
441                            input: serde_json::Value::Null,
442                            result: tool_result,
443                            parent_tool_use_id: parent_tool_use_id.clone(),
444                        });
445                    }
446                }
447            }
448
449            ClaudeEvent::Other => {
450                log::debug!("Skipping unknown Claude event type during output conversion");
451            }
452
453            ClaudeEvent::Result {
454                is_error: err,
455                result: res,
456                total_cost_usd: cost,
457                usage: u,
458                duration_ms,
459                num_turns,
460                permission_denials,
461                session_id: sid,
462                structured_output,
463                subtype: _,
464                ..
465            } => {
466                session_id = sid;
467                is_error = err;
468
469                // When Result.result is empty, fall back to structured_output
470                // (set by Claude CLI when --json-schema is used) or the last
471                // assistant message text.
472                let effective_result = if res.is_empty() {
473                    if let Some(ref so) = structured_output {
474                        let json = serde_json::to_string(so).unwrap_or_default();
475                        log::debug!(
476                            "Result.result is empty; using structured_output ({} bytes)",
477                            json.len()
478                        );
479                        json
480                    } else if let Some(ref fallback) = last_assistant_text {
481                        log::debug!(
482                            "Result.result is empty; using last assistant text ({} bytes)",
483                            fallback.len()
484                        );
485                        fallback.clone()
486                    } else {
487                        res.clone()
488                    }
489                } else {
490                    res.clone()
491                };
492
493                result = Some(effective_result.clone());
494                total_cost_usd = Some(cost);
495
496                // Convert usage
497                usage = Some(UnifiedUsage {
498                    input_tokens: u.input_tokens,
499                    output_tokens: u.output_tokens,
500                    cache_read_tokens: Some(u.cache_read_input_tokens),
501                    cache_creation_tokens: Some(u.cache_creation_input_tokens),
502                    web_search_requests: u.server_tool_use.as_ref().map(|s| s.web_search_requests),
503                    web_fetch_requests: u.server_tool_use.as_ref().map(|s| s.web_fetch_requests),
504                });
505
506                // Add permission denial events
507                for denial in permission_denials {
508                    events.push(UnifiedEvent::PermissionRequest {
509                        tool_name: denial.tool_name,
510                        description: format!(
511                            "Permission denied for tool input: {}",
512                            serde_json::to_string(&denial.tool_input).unwrap_or_default()
513                        ),
514                        granted: false,
515                    });
516                }
517
518                // Emit TurnComplete immediately before the per-turn Result.
519                events.push(UnifiedEvent::TurnComplete {
520                    stop_reason: pending_stop_reason.take(),
521                    turn_index: next_turn_index,
522                    usage: pending_turn_usage.take(),
523                });
524                next_turn_index = next_turn_index.saturating_add(1);
525
526                // Add final result event
527                events.push(UnifiedEvent::Result {
528                    success: !err,
529                    message: Some(effective_result),
530                    duration_ms: Some(duration_ms),
531                    num_turns: Some(num_turns),
532                });
533            }
534        }
535    }
536
537    AgentOutput {
538        agent: "claude".to_string(),
539        session_id,
540        events,
541        result,
542        is_error,
543        exit_code: None,
544        error_message: None,
545        total_cost_usd,
546        usage,
547        model: model_name,
548        provider: Some("claude".to_string()),
549        log_path: None,
550    }
551}
552
553/// Find the tool name for a given tool_use_id by searching previous events.
554fn find_tool_name(events: &[UnifiedEvent], tool_use_id: &str) -> Option<String> {
555    for event in events.iter().rev() {
556        if let UnifiedEvent::AssistantMessage { content, .. } = event {
557            for block in content {
558                if let UnifiedContentBlock::ToolUse { id, name, .. } = block
559                    && id == tool_use_id
560                {
561                    return Some(name.clone());
562                }
563            }
564        }
565    }
566    None
567}
568
569#[cfg(test)]
570#[path = "models_tests.rs"]
571mod tests;