1use std::sync::LazyLock;
29
30use regex::Regex;
31use serde_json::{Map, Value};
32
33use crate::core::tokens::count_tokens;
34
35static VOLATILE_PATTERNS: LazyLock<Vec<Regex>> = LazyLock::new(|| {
41 [
42 r"\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}(?::\d{2})?(?:\.\d+)?(?:Z|[+-]\d{2}:?\d{2})?",
44 r"\d{4}-\d{2}-\d{2}",
46 r"[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}",
48 r"\b[0-9a-f]{40}\b",
50 ]
51 .iter()
52 .filter_map(|p| Regex::new(p).ok())
53 .collect()
54});
55
56#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
58pub(crate) struct VolatileScan {
59 pub fields: usize,
61 pub volatile_bytes: usize,
63}
64
65fn merged_spans(text: &str) -> Vec<(usize, usize)> {
70 let mut spans: Vec<(usize, usize)> = Vec::new();
71 for re in VOLATILE_PATTERNS.iter() {
72 spans.extend(re.find_iter(text).map(|m| (m.start(), m.end())));
73 }
74 if spans.is_empty() {
75 return spans;
76 }
77 spans.sort_unstable();
78 let mut merged: Vec<(usize, usize)> = Vec::with_capacity(spans.len());
79 for (start, end) in spans {
80 match merged.last_mut() {
81 Some(last) if start <= last.1 => last.1 = last.1.max(end),
82 _ => merged.push((start, end)),
83 }
84 }
85 merged
86}
87
88pub(crate) fn scan_volatile(text: &str) -> VolatileScan {
90 let merged = merged_spans(text);
91 VolatileScan {
92 fields: merged.len(),
93 volatile_bytes: merged.iter().map(|(s, e)| e - s).sum(),
94 }
95}
96
97pub(crate) fn system_text(system: &Value) -> Option<String> {
100 match system {
101 Value::String(s) => Some(s.clone()),
102 Value::Array(blocks) => {
103 let joined = blocks
104 .iter()
105 .filter_map(|b| b.get("text").and_then(Value::as_str))
106 .collect::<Vec<_>>()
107 .join("\n");
108 (!joined.is_empty()).then_some(joined)
109 }
110 _ => None,
111 }
112}
113
114const MIN_STABLE_TOKENS: usize = 1024;
118
119const TAIL_HEADER: &str = "Volatile context (relocated to keep the prompt-cache prefix stable):";
122
123fn placeholder(n: usize) -> String {
128 format!("[ctx#{n}]")
129}
130
131#[derive(Debug, Clone, PartialEq, Eq)]
133pub(crate) struct RelocateResult {
134 pub stable: String,
137 pub tail: String,
140 pub fields: usize,
142}
143
144pub(crate) fn relocate_volatile(text: &str) -> Option<RelocateResult> {
148 let spans = merged_spans(text);
149 if spans.is_empty() {
150 return None;
151 }
152 let mut stable = String::with_capacity(text.len());
153 let mut values: Vec<&str> = Vec::with_capacity(spans.len());
154 let mut cursor = 0usize;
155 for (start, end) in &spans {
156 stable.push_str(&text[cursor..*start]);
157 stable.push_str(&placeholder(values.len() + 1));
158 values.push(&text[*start..*end]);
159 cursor = *end;
160 }
161 stable.push_str(&text[cursor..]);
162
163 let mut tail = String::from(TAIL_HEADER);
164 for (i, value) in values.iter().enumerate() {
165 tail.push('\n');
166 tail.push_str(&placeholder(i + 1));
167 tail.push_str(" = ");
168 tail.push_str(value);
169 }
170 Some(RelocateResult {
171 stable,
172 tail,
173 fields: values.len(),
174 })
175}
176
177fn text_block(text: String) -> Map<String, Value> {
179 let mut block = Map::new();
180 block.insert("type".into(), Value::String("text".into()));
181 block.insert("text".into(), Value::String(text));
182 block
183}
184
185fn stable_block(text: String) -> Value {
187 let mut block = text_block(text);
188 block.insert(
189 "cache_control".into(),
190 serde_json::json!({ "type": "ephemeral" }),
191 );
192 Value::Object(block)
193}
194
195pub(crate) fn apply_anthropic_relocate(doc: &mut Value) -> usize {
203 let Some(system) = doc.get_mut("system") else {
204 return 0;
205 };
206 let text = match system {
207 Value::String(s) => s.clone(),
208 Value::Array(blocks) => {
209 let all_plain_text = !blocks.is_empty()
210 && blocks.iter().all(|b| {
211 b.get("type").and_then(Value::as_str) == Some("text")
212 && b.get("text").is_some_and(Value::is_string)
213 && b.get("cache_control").is_none()
214 });
215 if !all_plain_text {
216 return 0;
217 }
218 blocks
219 .iter()
220 .filter_map(|b| b.get("text").and_then(Value::as_str))
221 .collect::<Vec<_>>()
222 .join("\n")
223 }
224 _ => return 0,
225 };
226 let Some(result) = relocate_volatile(&text) else {
227 return 0;
228 };
229 if count_tokens(&result.stable) < MIN_STABLE_TOKENS {
230 return 0;
231 }
232 *system = Value::Array(vec![
233 stable_block(result.stable),
234 Value::Object(text_block(result.tail)),
235 ]);
236 result.fields
237}
238
239#[cfg(test)]
240mod tests {
241 use super::*;
242
243 #[test]
244 fn counts_each_volatile_kind_once() {
245 let text = "Today is 2026-06-22. Session 550e8400-e29b-41d4-a716-446655440000 \
246 at commit da39a3ee5e6b4b0d3255bfef95601890afd80709.";
247 let scan = scan_volatile(text);
248 assert_eq!(scan.fields, 3, "one date, one UUID, one git SHA");
249 assert!(scan.volatile_bytes > 0);
250 }
251
252 #[test]
253 fn datetime_and_inner_date_merge_to_one_span() {
254 let scan = scan_volatile("Generated at 2026-06-22T15:04:05Z by the agent.");
257 assert_eq!(
258 scan.fields, 1,
259 "overlapping datetime/date spans merge to one"
260 );
261 }
262
263 #[test]
264 fn stable_prompt_has_no_volatile_fields() {
265 let scan = scan_volatile("You are a careful senior engineer. Prefer small diffs.");
266 assert_eq!(scan, VolatileScan::default());
267 }
268
269 #[test]
270 fn scan_is_deterministic() {
271 let text = "v1 2026-06-22 id 550e8400-e29b-41d4-a716-446655440000 and 2025-01-01";
272 assert_eq!(scan_volatile(text), scan_volatile(text));
273 }
274
275 #[test]
276 fn system_text_reads_string_and_block_array() {
277 assert_eq!(
278 system_text(&Value::String("hi".into())).as_deref(),
279 Some("hi")
280 );
281 let arr = serde_json::json!([
282 {"type": "text", "text": "alpha"},
283 {"type": "text", "text": "beta"}
284 ]);
285 assert_eq!(system_text(&arr).as_deref(), Some("alpha\nbeta"));
286 assert_eq!(system_text(&serde_json::json!(42)), None);
287 }
288
289 fn big_system_with_date() -> String {
291 format!(
292 "You are a meticulous senior engineer. Today is 2026-06-27. {}",
293 "Prefer small, well-tested diffs. ".repeat(400)
294 )
295 }
296
297 #[test]
298 fn relocate_moves_volatiles_to_tail_and_leaves_placeholders() {
299 let result =
300 relocate_volatile("Date 2026-06-27, id 550e8400-e29b-41d4-a716-446655440000.").unwrap();
301 assert_eq!(result.fields, 2);
302 assert!(!result.stable.contains("2026-06-27"), "value left prefix");
303 assert!(result.stable.contains("[ctx#1]") && result.stable.contains("[ctx#2]"));
304 assert!(result.tail.contains("[ctx#1] = 2026-06-27"));
305 assert!(
306 result
307 .tail
308 .contains("[ctx#2] = 550e8400-e29b-41d4-a716-446655440000")
309 );
310 }
311
312 #[test]
313 fn relocate_is_noop_without_volatile_fields() {
314 assert!(relocate_volatile("You are a careful engineer.").is_none());
315 }
316
317 #[test]
318 fn relocate_is_idempotent() {
319 let once = relocate_volatile("Built at 2026-06-27 ok").unwrap();
320 assert!(
321 relocate_volatile(&once.stable).is_none(),
322 "placeholders carry no volatile pattern, so a second pass is a no-op"
323 );
324 }
325
326 #[test]
327 fn relocate_is_deterministic() {
328 let text = "v 2026-06-27 id 550e8400-e29b-41d4-a716-446655440000 sha \
329 da39a3ee5e6b4b0d3255bfef95601890afd80709";
330 assert_eq!(relocate_volatile(text), relocate_volatile(text));
331 }
332
333 #[test]
334 fn apply_rewrites_string_system_into_stable_plus_tail() {
335 let mut doc = serde_json::json!({ "system": big_system_with_date(), "messages": [] });
336 assert_eq!(apply_anthropic_relocate(&mut doc), 1);
337 let system = &doc["system"];
338 assert!(system.is_array(), "string system becomes a block array");
339 assert_eq!(system[0]["cache_control"]["type"], "ephemeral");
340 assert!(
341 !system[0]["text"].as_str().unwrap().contains("2026-06-27"),
342 "the date left the cacheable prefix"
343 );
344 assert!(
345 system[1].get("cache_control").is_none(),
346 "the tail block stays uncached"
347 );
348 assert!(
349 system[1]["text"].as_str().unwrap().contains("2026-06-27"),
350 "the date was relocated to the tail"
351 );
352 }
353
354 #[test]
355 fn apply_skips_small_system_and_clean_system() {
356 let mut small = serde_json::json!({ "system": "Today is 2026-06-27", "messages": [] });
357 assert_eq!(
358 apply_anthropic_relocate(&mut small),
359 0,
360 "below the cacheable floor → no churn"
361 );
362 let mut clean =
363 serde_json::json!({ "system": "You are precise. ".repeat(400), "messages": [] });
364 assert_eq!(
365 apply_anthropic_relocate(&mut clean),
366 0,
367 "no volatile fields → strict no-op"
368 );
369 }
370
371 #[test]
372 fn apply_skips_array_with_existing_breakpoint() {
373 let mut doc = serde_json::json!({
374 "system": [{
375 "type": "text",
376 "text": big_system_with_date(),
377 "cache_control": { "type": "ephemeral" }
378 }],
379 "messages": []
380 });
381 assert_eq!(
382 apply_anthropic_relocate(&mut doc),
383 0,
384 "a client-anchored array must be left untouched"
385 );
386 }
387
388 #[test]
389 fn apply_is_deterministic() {
390 let mk = || serde_json::json!({ "system": big_system_with_date(), "messages": [] });
391 let (mut a, mut b) = (mk(), mk());
392 assert_eq!(apply_anthropic_relocate(&mut a), 1);
393 assert_eq!(apply_anthropic_relocate(&mut b), 1);
394 assert_eq!(a, b, "identical input → byte-identical output (#498)");
395 }
396}