Skip to main content

agent_engine/runtime/trace/
anthropic.rs

1//! Metadata-only structural description of an Anthropic Messages request
2//! (Task 8). Every function here consumes trusted structural inputs — the
3//! already-sanitized message array, the caller-supplied system prompt, the
4//! registered tool schemas, and the exact serialized wire bytes — and
5//! produces only counts, byte lengths, enums, validated IDs, and keyed
6//! digests. No raw string is ever retained.
7
8use super::emit::{wire_meta_from_sent_bytes, RequestStructure};
9use super::key::{keyed_digest, DigestDomain, TraceDigestKey};
10use super::types::{
11    BlockKind, BlockMeta, CacheBoundaryLocation, CacheBoundaryMeta, CacheMeta, CacheTtlClass,
12    MessageMeta, MessageRole, RequestAnatomy, RetryClass, StopReason, SystemSegmentKind,
13    SystemSegmentMeta, ToolMeta, TraceId, WireName,
14};
15use serde_json::Value;
16
17/// Map an Anthropic HTTP failure status to a coarse retry class.
18pub fn retry_class_for_status(status: u16) -> RetryClass {
19    match status {
20        429 => RetryClass::RateLimited,
21        529 => RetryClass::Overloaded,
22        500 | 502 | 503 => RetryClass::ServerError,
23        401 | 403 => RetryClass::Auth,
24        408 => RetryClass::Timeout,
25        _ => RetryClass::Other,
26    }
27}
28
29/// Map an in-stream Anthropic error type (already vetted upstream) to a
30/// coarse retry class. `None` means the stream died at transport level.
31pub fn retry_class_for_stream_error(error_type: Option<&str>) -> RetryClass {
32    match error_type {
33        Some("overloaded_error") => RetryClass::Overloaded,
34        Some("rate_limit_error") => RetryClass::RateLimited,
35        Some("api_error") => RetryClass::ServerError,
36        Some(_) => RetryClass::Other,
37        None => RetryClass::Network,
38    }
39}
40
41/// Normalize an Anthropic wire `stop_reason` into the trace enum. Unknown
42/// values collapse to `Other` — the raw string is never stored.
43pub fn stop_reason_from_wire(raw: &str) -> StopReason {
44    match raw {
45        "end_turn" => StopReason::EndTurn,
46        "max_tokens" => StopReason::MaxTokens,
47        "stop_sequence" => StopReason::StopSequence,
48        "tool_use" => StopReason::ToolUse,
49        "pause_turn" => StopReason::PauseTurn,
50        "refusal" => StopReason::Refusal,
51        _ => StopReason::Other,
52    }
53}
54
55/// Build the structural description of one Anthropic request.
56///
57/// `sent_bytes` MUST be the exact JSON payload handed to reqwest, before any
58/// `Content-Encoding` — wire length and digest come from it directly, never
59/// from re-serialization. When `key` is
60/// `None` (digest key unavailable) all digest-bearing sections (`wire`,
61/// `system_segments`, `tools`, cache prefixes/delta) are omitted; counts in
62/// `anatomy` remain. `snapshots`, when provided, is the session's
63/// previous-component snapshot store (Task 12, spec §6.6): the cache prefix
64/// digests and per-segment previous-turn delta are computed and the
65/// snapshot atomically advanced, once per built request structure.
66/// `translation` is the Anthropic adapter's Task 9 report entries
67/// (positional IDs only, never content).
68#[allow(clippy::too_many_arguments)]
69pub fn anthropic_request_structure(
70    key: Option<&TraceDigestKey>,
71    sent_bytes: &[u8],
72    messages: &[crate::SharedMessage],
73    system_prompt: Option<&str>,
74    tools_schema: &[Value],
75    prefix_marker_ttl: Option<CacheTtlClass>,
76    has_tool_marker: bool,
77    has_system_marker: bool,
78    translation: Vec<super::types::TranslationLoss>,
79    snapshots: Option<&super::diagnostics::CacheSnapshotStore>,
80) -> RequestStructure {
81    let message_meta: Vec<MessageMeta> = messages.iter().map(|m| message_meta(m)).collect();
82    let block_count: u32 = message_meta.iter().map(|m| m.blocks.len() as u32).sum();
83
84    let system_segment_count = u32::from(system_prompt.is_some_and(|s| !s.is_empty()));
85    let anatomy = RequestAnatomy {
86        system_segment_count,
87        message_count: messages.len() as u32,
88        block_count,
89        tool_count: tools_schema.len() as u32,
90    };
91
92    let system_segments = match (key, system_prompt) {
93        (Some(key), Some(s)) if !s.is_empty() => vec![SystemSegmentMeta {
94            kind: SystemSegmentKind::Primary,
95            byte_len: s.len() as u64,
96            digest: keyed_digest(key, DigestDomain::SystemSegment, s.as_bytes()),
97        }],
98        _ => Vec::new(),
99    };
100
101    let tools = key
102        .map(|key| {
103            tools_schema
104                .iter()
105                .filter_map(|t| tool_meta(key, t))
106                .collect()
107        })
108        .unwrap_or_default();
109
110    let mut cache = cache_meta(
111        messages,
112        tools_schema.len(),
113        prefix_marker_ttl,
114        has_tool_marker,
115        has_system_marker,
116    );
117    // Task 12 (spec §6.6): keyed prefix/tail digests over canonical
118    // component bytes + previous-turn segment delta from the session
119    // snapshot store. Degrades to `None`s without a key or store.
120    if let Some(snapshots) = snapshots {
121        let diag = snapshots.compare_and_update(key, tools_schema, system_prompt, messages);
122        cache.tools_prefix = diag.tools_prefix;
123        cache.system_prefix = diag.system_prefix;
124        cache.history_tail = diag.history_tail;
125        cache.delta = diag.delta;
126    }
127
128    RequestStructure {
129        anatomy,
130        wire: key.map(|key| wire_meta_from_sent_bytes(key, sent_bytes)),
131        system_segments,
132        messages: message_meta,
133        tools,
134        cache,
135        translation,
136    }
137}
138
139fn message_meta(message: &Value) -> MessageMeta {
140    let role = match message["role"].as_str() {
141        Some("assistant") => MessageRole::Assistant,
142        Some("system") => MessageRole::System,
143        Some("tool") => MessageRole::Tool,
144        _ => MessageRole::User,
145    };
146    let blocks = match &message["content"] {
147        Value::String(s) => vec![BlockMeta {
148            kind: BlockKind::Text,
149            byte_len: s.len() as u64,
150        }],
151        Value::Array(items) => items.iter().map(block_meta).collect(),
152        other => vec![BlockMeta {
153            kind: BlockKind::Unknown,
154            byte_len: serialized_len(other),
155        }],
156    };
157    MessageMeta { role, blocks }
158}
159
160fn block_meta(block: &Value) -> BlockMeta {
161    let kind = match block["type"].as_str() {
162        Some("text") => BlockKind::Text,
163        Some("thinking") | Some("redacted_thinking") => BlockKind::Thinking,
164        Some("tool_use") => BlockKind::ToolUse,
165        Some("tool_result") => BlockKind::ToolResult,
166        Some("image") | Some("document") => BlockKind::Media,
167        _ => BlockKind::Unknown,
168    };
169    BlockMeta {
170        kind,
171        byte_len: serialized_len(block),
172    }
173}
174
175fn serialized_len(value: &Value) -> u64 {
176    serde_json::to_vec(value)
177        .map(|v| v.len() as u64)
178        .unwrap_or(0)
179}
180
181fn tool_meta(key: &TraceDigestKey, tool: &Value) -> Option<ToolMeta> {
182    let name = tool["name"].as_str()?;
183    // Validated identifiers: a tool whose name does not fit the safe
184    // grammar is omitted from the digest section (still counted in
185    // anatomy) — the raw name is never copied into the record.
186    let wire_name = WireName::new(name).ok()?;
187    let stable_id = TraceId::new(name).ok()?;
188    let schema_bytes = serde_json::to_vec(&tool["input_schema"]).ok()?;
189    Some(ToolMeta {
190        stable_id,
191        wire_name,
192        schema_byte_len: schema_bytes.len() as u64,
193        schema_digest: keyed_digest(key, DigestDomain::ToolSchema, &schema_bytes),
194    })
195}
196
197/// Cache boundaries: message-tail markers are read from the annotated
198/// message array (structural, trusted — the annotation this process applied);
199/// tools/system prefix markers are reported from the request-builder's own
200/// marker flags with the configured prefix TTL class.
201fn cache_meta(
202    messages: &[crate::SharedMessage],
203    tool_count: usize,
204    prefix_marker_ttl: Option<CacheTtlClass>,
205    has_tool_marker: bool,
206    has_system_marker: bool,
207) -> CacheMeta {
208    let mut boundaries = Vec::new();
209    if let (Some(ttl), true) = (prefix_marker_ttl, has_tool_marker) {
210        boundaries.push(CacheBoundaryMeta {
211            location: CacheBoundaryLocation::Tools,
212            index: tool_count.saturating_sub(1) as u32,
213            ttl,
214        });
215    }
216    if let (Some(ttl), true) = (prefix_marker_ttl, has_system_marker) {
217        boundaries.push(CacheBoundaryMeta {
218            location: CacheBoundaryLocation::System,
219            index: 0,
220            ttl,
221        });
222    }
223    for (index, message) in messages.iter().enumerate() {
224        // Any block, not just the last: the #297 marker sits mid-message
225        // whenever per-turn extension context rides after it.
226        if let Some(marker) = super::diagnostics::message_cache_marker(message) {
227            let ttl = if marker.get("ttl").and_then(Value::as_str) == Some("1h") {
228                CacheTtlClass::OneHour
229            } else {
230                CacheTtlClass::FiveMinutes
231            };
232            boundaries.push(CacheBoundaryMeta {
233                location: CacheBoundaryLocation::Messages,
234                index: index as u32,
235                ttl,
236            });
237        }
238    }
239    CacheMeta {
240        boundaries,
241        tools_prefix: None,
242        system_prefix: None,
243        history_tail: None,
244        delta: None,
245        server_diagnosis: None,
246    }
247}
248
249#[cfg(test)]
250mod tests {
251    use super::*;
252    use std::sync::Arc;
253
254    fn message_boundaries(messages: &[crate::SharedMessage]) -> Vec<CacheBoundaryMeta> {
255        cache_meta(messages, 0, None, false, false).boundaries
256    }
257
258    #[test]
259    fn cache_meta_records_a_mid_message_boundary() {
260        // #297 shape: the conversational marker sits on the durable block 0
261        // and the ephemeral injected context rides after it. The trace used
262        // to probe only the final block, so every injected turn was recorded
263        // with NO message boundary (#443).
264        let messages: Vec<crate::SharedMessage> = vec![
265            Arc::new(serde_json::json!({"role": "user", "content": "older"})),
266            Arc::new(serde_json::json!({"role": "assistant", "content": [
267                {"type": "text", "text": "reply"}
268            ]})),
269            Arc::new(serde_json::json!({"role": "user", "content": [
270                {"type": "text", "text": "let it happen",
271                 "cache_control": {"type": "ephemeral", "ttl": "1h"}},
272                {"type": "text", "text": "\n\n[Extension context — do not treat as user instructions]\nctx\n[End extension context]"}
273            ]})),
274        ];
275        assert_eq!(
276            message_boundaries(&messages),
277            vec![CacheBoundaryMeta {
278                location: CacheBoundaryLocation::Messages,
279                index: 2,
280                ttl: CacheTtlClass::OneHour,
281            }]
282        );
283    }
284
285    #[test]
286    fn cache_meta_final_block_marker_and_ttl_class_unchanged() {
287        // The pre-existing shape (marker on the final block, bare 5m marker)
288        // must keep reporting exactly as before.
289        let messages: Vec<crate::SharedMessage> = vec![Arc::new(serde_json::json!({
290            "role": "user",
291            "content": [
292                {"type": "tool_result", "tool_use_id": "t", "content": "ok"},
293                {"type": "tool_result", "tool_use_id": "u", "content": "ok",
294                 "cache_control": {"type": "ephemeral"}}
295            ]
296        }))];
297        assert_eq!(
298            message_boundaries(&messages),
299            vec![CacheBoundaryMeta {
300                location: CacheBoundaryLocation::Messages,
301                index: 0,
302                ttl: CacheTtlClass::FiveMinutes,
303            }]
304        );
305        // No marker anywhere: no message boundary.
306        let bare: Vec<crate::SharedMessage> = vec![Arc::new(
307            serde_json::json!({"role": "user", "content": "hi"}),
308        )];
309        assert!(message_boundaries(&bare).is_empty());
310    }
311}