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