Skip to main content

microclaw_engine/
prompt_cache.rs

1//! Anthropic prompt-cache breakpoints (system_and_3 strategy).
2//!
3//! Anthropic accepts up to 4 `cache_control` markers per request. This module
4//! places one on the system prompt (stable across the whole session) and one
5//! each on the last 3 non-system messages (rolling window of recent turns).
6//! On a multi-turn chat that means the second turn re-uses the prefix from
7//! the first, the third re-uses the second's prefix, etc., cutting input
8//! token cost by ~75%.
9//!
10//! Implementation note: rather than threading a `cache_control` field through
11//! the typed `MessagesRequest`/`Message`/`ContentBlock` shapes (which would
12//! pollute the OpenAI-compat path), we serialize the request to a
13//! `serde_json::Value` and mutate it just before send. The mutation upgrades
14//! `system: "..."` to `system: [{type: "text", text: "...", cache_control}]`
15//! and attaches `cache_control` to the LAST content block of each targeted
16//! message (Anthropic's documented attachment point).
17//!
18//! Hermes reference: `agent/prompt_caching.py:apply_anthropic_cache_control`.
19
20use serde_json::{json, Value};
21
22const MAX_BREAKPOINTS: usize = 4;
23
24/// Mutate an Anthropic `/v1/messages` request body in-place to add prompt-cache
25/// breakpoints. Safe to call on any request: if the structure isn't what we
26/// expect we leave it alone and the API just doesn't cache.
27pub fn apply_anthropic_prompt_cache(body: &mut Value, ttl: &str) {
28    let marker = build_marker(ttl);
29
30    let mut budget = MAX_BREAKPOINTS;
31
32    if let Some(obj) = body.as_object_mut() {
33        if let Some(system) = obj.get_mut("system") {
34            if mark_system(system, &marker) {
35                budget -= 1;
36            }
37        }
38
39        if budget == 0 {
40            return;
41        }
42
43        if let Some(messages) = obj.get_mut("messages").and_then(|v| v.as_array_mut()) {
44            // last `budget` non-system messages — in microclaw the messages
45            // array never contains a "system" role (system is a top-level
46            // field), but we filter defensively for parity with hermes.
47            let mut targets: Vec<usize> = messages
48                .iter()
49                .enumerate()
50                .filter(|(_, m)| {
51                    m.get("role")
52                        .and_then(|r| r.as_str())
53                        .map(|r| r != "system")
54                        .unwrap_or(true)
55                })
56                .map(|(i, _)| i)
57                .collect();
58            let take = budget.min(targets.len());
59            let start = targets.len().saturating_sub(take);
60            targets.drain(..start);
61            for idx in targets {
62                mark_message(&mut messages[idx], &marker);
63            }
64        }
65    }
66}
67
68fn build_marker(ttl: &str) -> Value {
69    if ttl == "1h" {
70        json!({"type": "ephemeral", "ttl": "1h"})
71    } else {
72        json!({"type": "ephemeral"})
73    }
74}
75
76/// Promote the system prompt to a structured array so cache_control can attach
77/// to it. Returns true if a breakpoint was placed.
78fn mark_system(system: &mut Value, marker: &Value) -> bool {
79    match system {
80        Value::String(s) => {
81            if s.is_empty() {
82                return false;
83            }
84            *system = json!([{
85                "type": "text",
86                "text": s.clone(),
87                "cache_control": marker,
88            }]);
89            true
90        }
91        Value::Array(blocks) => {
92            if let Some(last) = blocks.last_mut() {
93                attach_marker_to_block(last, marker);
94                true
95            } else {
96                false
97            }
98        }
99        _ => false,
100    }
101}
102
103/// Attach a cache_control marker to a single message. The marker goes on the
104/// LAST content block; if `content` is a plain string we lift it into a single
105/// text block first.
106fn mark_message(msg: &mut Value, marker: &Value) {
107    let Some(obj) = msg.as_object_mut() else {
108        return;
109    };
110    let content = obj.entry("content").or_insert(Value::Null);
111    match content {
112        Value::String(s) => {
113            *content = json!([{
114                "type": "text",
115                "text": s.clone(),
116                "cache_control": marker,
117            }]);
118        }
119        Value::Array(blocks) => {
120            if let Some(last) = blocks.last_mut() {
121                attach_marker_to_block(last, marker);
122            }
123        }
124        _ => {
125            // Null / number / bool — unusual, skip.
126        }
127    }
128}
129
130fn attach_marker_to_block(block: &mut Value, marker: &Value) {
131    if let Some(obj) = block.as_object_mut() {
132        obj.insert("cache_control".to_string(), marker.clone());
133    }
134}
135
136#[cfg(test)]
137mod tests {
138    use super::*;
139    use serde_json::json;
140
141    fn ephemeral() -> Value {
142        json!({"type": "ephemeral"})
143    }
144
145    #[test]
146    fn marks_system_string_as_block_array() {
147        let mut body = json!({
148            "model": "claude-sonnet-4-5",
149            "system": "You are helpful.",
150            "messages": [],
151        });
152        apply_anthropic_prompt_cache(&mut body, "5m");
153        let sys = &body["system"];
154        assert!(sys.is_array());
155        assert_eq!(sys[0]["text"], "You are helpful.");
156        assert_eq!(sys[0]["cache_control"], ephemeral());
157    }
158
159    #[test]
160    fn empty_system_string_no_marker() {
161        let mut body = json!({
162            "model": "claude-sonnet-4-5",
163            "system": "",
164            "messages": [
165                {"role": "user", "content": "hi"},
166            ],
167        });
168        apply_anthropic_prompt_cache(&mut body, "5m");
169        // Empty system stays a string, not promoted; the user message still
170        // gets a marker.
171        assert!(body["system"].is_string());
172        assert_eq!(
173            body["messages"][0]["content"][0]["cache_control"],
174            ephemeral()
175        );
176    }
177
178    #[test]
179    fn marks_last_three_non_system_messages() {
180        let mut body = json!({
181            "system": "S",
182            "messages": [
183                {"role": "user", "content": "m1"},
184                {"role": "assistant", "content": "m2"},
185                {"role": "user", "content": "m3"},
186                {"role": "assistant", "content": "m4"},
187                {"role": "user", "content": "m5"},
188            ],
189        });
190        apply_anthropic_prompt_cache(&mut body, "5m");
191        // m1, m2 unchanged; m3, m4, m5 each carry a marker on their lifted text block.
192        assert!(body["messages"][0]["content"].is_string(), "m1 untouched");
193        assert!(body["messages"][1]["content"].is_string(), "m2 untouched");
194        for i in [2, 3, 4] {
195            assert_eq!(
196                body["messages"][i]["content"][0]["cache_control"],
197                ephemeral(),
198                "m{} should be marked",
199                i + 1
200            );
201        }
202    }
203
204    #[test]
205    fn marks_last_block_when_content_is_array() {
206        let mut body = json!({
207            "system": "S",
208            "messages": [
209                {
210                    "role": "user",
211                    "content": [
212                        {"type": "text", "text": "first"},
213                        {"type": "text", "text": "second"},
214                    ],
215                },
216            ],
217        });
218        apply_anthropic_prompt_cache(&mut body, "5m");
219        let blocks = &body["messages"][0]["content"];
220        assert!(blocks[0].get("cache_control").is_none());
221        assert_eq!(blocks[1]["cache_control"], ephemeral());
222    }
223
224    #[test]
225    fn ttl_1h_marker() {
226        let mut body = json!({
227            "system": "S",
228            "messages": [],
229        });
230        apply_anthropic_prompt_cache(&mut body, "1h");
231        assert_eq!(body["system"][0]["cache_control"]["ttl"], "1h");
232    }
233
234    #[test]
235    fn budget_capped_at_four_total() {
236        let mut body = json!({
237            "system": "S",
238            "messages": [
239                {"role": "user", "content": "m1"},
240                {"role": "assistant", "content": "m2"},
241                {"role": "user", "content": "m3"},
242                {"role": "assistant", "content": "m4"},
243                {"role": "user", "content": "m5"},
244            ],
245        });
246        apply_anthropic_prompt_cache(&mut body, "5m");
247        // 1 system + 3 messages = 4. The 4th-from-end (m2) must NOT be marked.
248        assert!(body["messages"][1]["content"].is_string());
249    }
250
251    #[test]
252    fn no_messages_just_system() {
253        let mut body = json!({
254            "system": "S",
255            "messages": [],
256        });
257        apply_anthropic_prompt_cache(&mut body, "5m");
258        assert_eq!(body["system"][0]["cache_control"], ephemeral());
259    }
260
261    #[test]
262    fn missing_system_field_safe() {
263        let mut body = json!({
264            "messages": [{"role": "user", "content": "hi"}],
265        });
266        apply_anthropic_prompt_cache(&mut body, "5m");
267        assert_eq!(
268            body["messages"][0]["content"][0]["cache_control"],
269            ephemeral()
270        );
271    }
272}