1use super::key::{keyed_digest, DigestDomain, TraceDigestKey};
38use super::types::{CacheSegmentDelta, PrefixMeta, SegmentChange, TraceId};
39use serde_json::Value;
40use std::sync::Mutex;
41
42const RS: u8 = 0x1e;
44const US: u8 = 0x1f;
46
47pub fn tools_prefix_bytes(tools_schema: &[Value]) -> Vec<u8> {
51 let mut out = Vec::new();
52 for tool in tools_schema {
53 if let Some(name) = tool["name"].as_str() {
54 out.extend_from_slice(name.as_bytes());
55 }
56 out.push(US);
57 if let Ok(schema) = serde_json::to_vec(&tool["input_schema"]) {
58 out.extend_from_slice(&schema);
59 }
60 out.push(RS);
61 }
62 out
63}
64
65pub fn system_prefix_bytes(system_prompt: &str) -> Vec<u8> {
67 system_prompt.as_bytes().to_vec()
68}
69
70fn history_tail_start(messages: &[crate::SharedMessage]) -> usize {
74 messages
75 .iter()
76 .rposition(|m| {
77 m["content"]
78 .as_array()
79 .and_then(|blocks| blocks.last())
80 .and_then(|b| b.get("cache_control"))
81 .is_some()
82 })
83 .map(|idx| idx + 1)
84 .unwrap_or(0)
85}
86
87pub fn history_tail_bytes(messages: &[crate::SharedMessage]) -> Vec<u8> {
89 let start = history_tail_start(messages);
90 let mut out = Vec::new();
91 for message in &messages[start..] {
92 if let Ok(bytes) = serde_json::to_vec(&**message) {
93 out.extend_from_slice(&bytes);
94 }
95 out.push(RS);
96 }
97 out
98}
99
100#[derive(Debug, Clone, PartialEq, Eq)]
104struct SegmentSnapshot {
105 digest: super::key::ComponentDigest,
106 byte_len: u64,
107}
108
109impl SegmentSnapshot {
110 fn from_meta(meta: &PrefixMeta) -> Self {
111 Self {
112 digest: meta.digest.clone(),
113 byte_len: meta.byte_len,
114 }
115 }
116}
117
118#[derive(Debug, Clone, PartialEq, Eq)]
120struct ToolSnapshot {
121 stable_id: TraceId,
122 schema_digest: super::key::ComponentDigest,
123}
124
125#[derive(Debug, Default)]
126struct SnapshotInner {
127 tools: Option<SegmentSnapshot>,
128 system: Option<SegmentSnapshot>,
129 history_tail: Option<SegmentSnapshot>,
130 tool_list: Vec<ToolSnapshot>,
131 last: Option<CacheActivity>,
133}
134
135#[derive(Debug, Clone, PartialEq, Eq)]
138pub struct CacheActivity {
139 pub tools_prefix: Option<PrefixMeta>,
140 pub system_prefix: Option<PrefixMeta>,
141 pub history_tail: Option<PrefixMeta>,
142 pub delta: CacheSegmentDelta,
143}
144
145#[derive(Debug, Clone, Default)]
147pub struct CacheDiagnostics {
148 pub tools_prefix: Option<PrefixMeta>,
149 pub system_prefix: Option<PrefixMeta>,
150 pub history_tail: Option<PrefixMeta>,
151 pub delta: Option<CacheSegmentDelta>,
152}
153
154#[derive(Debug, Default)]
158pub struct CacheSnapshotStore {
159 inner: Mutex<SnapshotInner>,
160}
161
162impl CacheSnapshotStore {
163 pub fn new() -> Self {
164 Self::default()
165 }
166
167 pub fn compare_and_update(
174 &self,
175 key: Option<&TraceDigestKey>,
176 tools_schema: &[Value],
177 system_prompt: Option<&str>,
178 messages: &[crate::SharedMessage],
179 ) -> CacheDiagnostics {
180 let Some(key) = key else {
181 return CacheDiagnostics::default();
182 };
183
184 let tools_bytes = tools_prefix_bytes(tools_schema);
185 let tools_prefix = (!tools_schema.is_empty()).then(|| PrefixMeta {
186 byte_len: tools_bytes.len() as u64,
187 digest: keyed_digest(key, DigestDomain::ToolsPrefix, &tools_bytes),
188 });
189 let system_prefix = system_prompt.filter(|s| !s.is_empty()).map(|s| {
190 let bytes = system_prefix_bytes(s);
191 PrefixMeta {
192 byte_len: bytes.len() as u64,
193 digest: keyed_digest(key, DigestDomain::SystemPrefix, &bytes),
194 }
195 });
196 let tail_bytes = history_tail_bytes(messages);
197 let history_tail = (!messages.is_empty()).then(|| PrefixMeta {
198 byte_len: tail_bytes.len() as u64,
199 digest: keyed_digest(key, DigestDomain::HistoryTail, &tail_bytes),
200 });
201 let tool_list: Vec<ToolSnapshot> = tools_schema
202 .iter()
203 .filter_map(|tool| {
204 let name = tool["name"].as_str()?;
205 let stable_id = TraceId::new(name).ok()?;
206 let schema = serde_json::to_vec(&tool["input_schema"]).ok()?;
207 Some(ToolSnapshot {
208 stable_id,
209 schema_digest: keyed_digest(key, DigestDomain::ToolSchema, &schema),
210 })
211 })
212 .collect();
213
214 let mut inner = self.inner.lock().expect("cache snapshot store poisoned");
215 let had_previous = inner.tools.is_some()
216 || inner.system.is_some()
217 || inner.history_tail.is_some()
218 || !inner.tool_list.is_empty();
219
220 let delta = if had_previous {
221 let tools_change = segment_change(inner.tools.as_ref(), tools_prefix.as_ref());
222 let system_change = segment_change(inner.system.as_ref(), system_prefix.as_ref());
223 let tail_change = segment_change(inner.history_tail.as_ref(), history_tail.as_ref());
224 let (changed_tool_ids, tool_order_changed) =
225 tool_delta(&inner.tool_list, &tool_list, tools_change);
226 let (reused, recomputed) = reuse_estimate(&[
227 (tools_change, tools_prefix.as_ref()),
228 (system_change, system_prefix.as_ref()),
229 (tail_change, history_tail.as_ref()),
230 ]);
231 CacheSegmentDelta {
232 tools: tools_change,
233 system: system_change,
234 history_tail: tail_change,
235 changed_tool_ids,
236 tool_order_changed,
237 estimated_reused_bytes: Some(reused),
238 estimated_recomputed_bytes: Some(recomputed),
239 }
240 } else {
241 CacheSegmentDelta {
242 tools: tools_prefix.as_ref().map(|_| SegmentChange::New),
243 system: system_prefix.as_ref().map(|_| SegmentChange::New),
244 history_tail: history_tail.as_ref().map(|_| SegmentChange::New),
245 changed_tool_ids: Vec::new(),
246 tool_order_changed: false,
247 estimated_reused_bytes: Some(0),
248 estimated_recomputed_bytes: Some(
249 [&tools_prefix, &system_prefix, &history_tail]
250 .iter()
251 .filter_map(|m| m.as_ref().map(|m| m.byte_len))
252 .sum(),
253 ),
254 }
255 };
256
257 inner.tools = tools_prefix.as_ref().map(SegmentSnapshot::from_meta);
258 inner.system = system_prefix.as_ref().map(SegmentSnapshot::from_meta);
259 inner.history_tail = history_tail.as_ref().map(SegmentSnapshot::from_meta);
260 inner.tool_list = tool_list;
261 inner.last = Some(CacheActivity {
262 tools_prefix: tools_prefix.clone(),
263 system_prefix: system_prefix.clone(),
264 history_tail: history_tail.clone(),
265 delta: delta.clone(),
266 });
267
268 CacheDiagnostics {
269 tools_prefix,
270 system_prefix,
271 history_tail,
272 delta: Some(delta),
273 }
274 }
275
276 pub fn last_activity(&self) -> Option<CacheActivity> {
278 self.inner
279 .lock()
280 .expect("cache snapshot store poisoned")
281 .last
282 .clone()
283 }
284}
285
286fn segment_change(
287 previous: Option<&SegmentSnapshot>,
288 current: Option<&PrefixMeta>,
289) -> Option<SegmentChange> {
290 match (previous, current) {
291 (None, None) => None,
292 (None, Some(_)) | (Some(_), None) => Some(SegmentChange::New),
293 (Some(prev), Some(cur)) if prev.digest == cur.digest => Some(SegmentChange::Unchanged),
294 _ => Some(SegmentChange::Changed),
295 }
296}
297
298fn tool_delta(
301 previous: &[ToolSnapshot],
302 current: &[ToolSnapshot],
303 tools_change: Option<SegmentChange>,
304) -> (Vec<TraceId>, bool) {
305 if tools_change != Some(SegmentChange::Changed) {
306 return (Vec::new(), false);
307 }
308 let mut changed: Vec<TraceId> = Vec::new();
309 for cur in current {
310 match previous.iter().find(|p| p.stable_id == cur.stable_id) {
311 None => changed.push(cur.stable_id.clone()),
312 Some(prev) if prev.schema_digest != cur.schema_digest => {
313 changed.push(cur.stable_id.clone())
314 }
315 Some(_) => {}
316 }
317 }
318 for prev in previous {
319 if !current.iter().any(|c| c.stable_id == prev.stable_id) {
320 changed.push(prev.stable_id.clone());
321 }
322 }
323 let order_changed = changed.is_empty() && previous != current;
325 (changed, order_changed)
326}
327
328fn reuse_estimate(segments: &[(Option<SegmentChange>, Option<&PrefixMeta>)]) -> (u64, u64) {
329 let mut reused = 0u64;
330 let mut recomputed = 0u64;
331 for (change, meta) in segments {
332 let Some(meta) = meta else { continue };
333 match change {
334 Some(SegmentChange::Unchanged) => reused += meta.byte_len,
335 Some(SegmentChange::Changed) | Some(SegmentChange::New) => recomputed += meta.byte_len,
336 None => {}
337 }
338 }
339 (reused, recomputed)
340}
341
342#[derive(Debug, Clone, Copy, PartialEq, Eq)]
347pub enum ReportValue {
348 Count(u64),
349 Unavailable,
351}
352
353impl std::fmt::Display for ReportValue {
354 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
355 match self {
356 ReportValue::Count(n) => write!(f, "{n}"),
357 ReportValue::Unavailable => f.write_str("unavailable"),
358 }
359 }
360}
361
362#[derive(Debug, Clone)]
365pub struct ContextReport {
366 pub model: String,
367 pub system_prompt_bytes: ReportValue,
368 pub tool_count: ReportValue,
369 pub history_messages: ReportValue,
373 pub history_bytes: ReportValue,
374 pub loaded_skills: ReportValue,
378 pub loaded_memories: ReportValue,
379 pub cache: Option<CacheActivity>,
380 pub trace_enabled: bool,
381 pub writer_stats: Option<crate::runtime::telemetry::WriterStats>,
382 pub degraded_records: u64,
383}
384
385fn fmt_change(change: Option<SegmentChange>) -> &'static str {
386 match change {
387 Some(SegmentChange::Unchanged) => "unchanged",
388 Some(SegmentChange::Changed) => "changed",
389 Some(SegmentChange::New) => "new",
390 None => "absent",
391 }
392}
393
394impl ContextReport {
395 pub fn render(&self) -> String {
397 let mut out = String::new();
398 use std::fmt::Write as _;
399 let _ = writeln!(out, "context — model {}", self.model);
400 let _ = writeln!(out, " system prompt: {} bytes", self.system_prompt_bytes);
401 let _ = writeln!(out, " tools: {}", self.tool_count);
402 let _ = writeln!(
403 out,
404 " history: {} messages, {} bytes",
405 self.history_messages, self.history_bytes
406 );
407 let _ = writeln!(
408 out,
409 " loaded skills: {}, memories: {}",
410 self.loaded_skills, self.loaded_memories
411 );
412 match &self.cache {
413 Some(activity) => {
414 let d = &activity.delta;
415 let _ = writeln!(
416 out,
417 " cache: tools {}, system {}, history tail {}",
418 fmt_change(d.tools),
419 fmt_change(d.system),
420 fmt_change(d.history_tail),
421 );
422 if !d.changed_tool_ids.is_empty() {
423 let ids: Vec<&str> = d.changed_tool_ids.iter().map(|t| t.as_str()).collect();
424 let _ = writeln!(out, " changed tools: {}", ids.join(", "));
425 }
426 if d.tool_order_changed {
427 let _ = writeln!(out, " tool order changed (prefix invalidated)");
428 }
429 if let (Some(reused), Some(recomputed)) =
430 (d.estimated_reused_bytes, d.estimated_recomputed_bytes)
431 {
432 let _ = writeln!(
433 out,
434 " estimated reuse: {reused} bytes reused, {recomputed} bytes recomputed",
435 );
436 }
437 }
438 None => {
439 let _ = writeln!(
440 out,
441 " cache: no diagnostics yet (tracing {} this session)",
442 if self.trace_enabled {
443 "enabled, no traced request"
444 } else {
445 "disabled"
446 }
447 );
448 }
449 }
450 match self.writer_stats {
451 Some(stats) => {
452 let _ = writeln!(
453 out,
454 " trace writer: {} enqueued, {} written, {} dropped; {} degraded records",
455 stats.enqueued, stats.written, stats.dropped, self.degraded_records,
456 );
457 }
458 None => {
459 let _ = writeln!(
460 out,
461 " trace writer: off; {} degraded records",
462 self.degraded_records
463 );
464 }
465 }
466 out.trim_end().to_string()
467 }
468}
469
470#[cfg(test)]
471mod tests {
472 use super::*;
473 use std::sync::Arc;
474
475 fn test_key() -> TraceDigestKey {
476 let dir = tempfile::tempdir().expect("tempdir");
477 super::super::key::load_or_create_digest_key_at(&dir.path().join("k.key"))
478 .expect("test key")
479 }
480
481 fn tool(name: &str, schema: Value) -> Value {
482 serde_json::json!({"name": name, "input_schema": schema})
483 }
484
485 fn msg(role: &str, text: &str) -> crate::SharedMessage {
486 Arc::new(serde_json::json!({"role": role, "content": text}))
487 }
488
489 #[test]
490 fn first_request_is_new_then_identical_retry_is_unchanged() {
491 let key = test_key();
492 let store = CacheSnapshotStore::new();
493 let tools = vec![tool("alpha", serde_json::json!({"type": "object"}))];
494 let messages = vec![msg("user", "hi")];
495
496 let first = store.compare_and_update(Some(&key), &tools, Some("sys"), &messages);
497 let d = first.delta.expect("delta");
498 assert_eq!(d.tools, Some(SegmentChange::New));
499 assert_eq!(d.system, Some(SegmentChange::New));
500 assert_eq!(d.history_tail, Some(SegmentChange::New));
501 assert_eq!(d.estimated_reused_bytes, Some(0));
502
503 let second = store.compare_and_update(Some(&key), &tools, Some("sys"), &messages);
505 let d = second.delta.expect("delta");
506 assert_eq!(d.tools, Some(SegmentChange::Unchanged));
507 assert_eq!(d.system, Some(SegmentChange::Unchanged));
508 assert_eq!(d.history_tail, Some(SegmentChange::Unchanged));
509 assert!(d.changed_tool_ids.is_empty());
510 assert!(!d.tool_order_changed);
511 assert_eq!(d.estimated_recomputed_bytes, Some(0));
512 assert!(d.estimated_reused_bytes.unwrap() > 0);
513 }
514
515 #[test]
516 fn tool_order_change_flags_prefix_change_without_changed_ids() {
517 let key = test_key();
518 let store = CacheSnapshotStore::new();
519 let a = tool("alpha", serde_json::json!({"type": "object"}));
520 let b = tool("beta", serde_json::json!({"type": "object"}));
521 let messages = vec![msg("user", "hi")];
522
523 store.compare_and_update(Some(&key), &[a.clone(), b.clone()], None, &messages);
524 let swapped = store.compare_and_update(Some(&key), &[b, a], None, &messages);
525 let d = swapped.delta.expect("delta");
526 assert_eq!(d.tools, Some(SegmentChange::Changed));
527 assert!(d.changed_tool_ids.is_empty(), "same tools, only reordered");
528 assert!(d.tool_order_changed);
529 }
530
531 #[test]
532 fn tool_schema_change_names_the_precise_tool() {
533 let key = test_key();
534 let store = CacheSnapshotStore::new();
535 let a1 = tool("alpha", serde_json::json!({"type": "object"}));
536 let a2 = tool(
537 "alpha",
538 serde_json::json!({"type": "object", "properties": {"x": {}}}),
539 );
540 let b = tool("beta", serde_json::json!({"type": "object"}));
541 let messages = vec![msg("user", "hi")];
542
543 store.compare_and_update(Some(&key), &[a1, b.clone()], None, &messages);
544 let changed = store.compare_and_update(Some(&key), &[a2, b], None, &messages);
545 let d = changed.delta.expect("delta");
546 assert_eq!(d.tools, Some(SegmentChange::Changed));
547 assert_eq!(
548 d.changed_tool_ids
549 .iter()
550 .map(|t| t.as_str())
551 .collect::<Vec<_>>(),
552 vec!["alpha"]
553 );
554 assert!(!d.tool_order_changed);
555 }
556
557 #[test]
558 fn prefix_digests_are_keyed_hmac_over_canonical_bytes() {
559 let key = test_key();
560 let store = CacheSnapshotStore::new();
561 let tools = vec![tool("alpha", serde_json::json!({"type": "object"}))];
562 let messages = vec![msg("user", "hello")];
563 let diag = store.compare_and_update(Some(&key), &tools, Some("sys"), &messages);
564
565 let expected_tools =
566 keyed_digest(&key, DigestDomain::ToolsPrefix, &tools_prefix_bytes(&tools));
567 assert_eq!(diag.tools_prefix.unwrap().digest, expected_tools);
568
569 let expected_system = keyed_digest(&key, DigestDomain::SystemPrefix, b"sys");
570 assert_eq!(diag.system_prefix.unwrap().digest, expected_system);
571
572 let expected_tail = keyed_digest(
573 &key,
574 DigestDomain::HistoryTail,
575 &history_tail_bytes(&messages),
576 );
577 assert_eq!(diag.history_tail.unwrap().digest, expected_tail);
578 }
579
580 #[test]
581 fn history_tail_excludes_messages_up_to_last_cache_boundary() {
582 let cached: crate::SharedMessage = Arc::new(serde_json::json!({
583 "role": "user",
584 "content": [
585 {"type": "text", "text": "old", "cache_control": {"type": "ephemeral"}}
586 ]
587 }));
588 let fresh = msg("user", "new");
589 let tail = history_tail_bytes(&[cached.clone(), fresh.clone()]);
590 let only_fresh = history_tail_bytes(&[fresh.clone()]);
591 assert_eq!(tail, only_fresh);
593 let full = history_tail_bytes(&[cached, fresh]);
595 assert_eq!(full, only_fresh);
596 }
597
598 #[test]
599 fn missing_key_yields_no_diagnostics_and_preserves_snapshot() {
600 let key = test_key();
601 let store = CacheSnapshotStore::new();
602 let tools = vec![tool("alpha", serde_json::json!({"type": "object"}))];
603 let messages = vec![msg("user", "hi")];
604 store.compare_and_update(Some(&key), &tools, None, &messages);
605
606 let degraded = store.compare_and_update(None, &tools, None, &messages);
607 assert!(degraded.delta.is_none());
608 assert!(degraded.tools_prefix.is_none());
609
610 let after = store.compare_and_update(Some(&key), &tools, None, &messages);
613 assert_eq!(after.delta.unwrap().tools, Some(SegmentChange::Unchanged));
614 }
615
616 #[test]
617 fn cache_meta_without_new_fields_still_deserializes() {
618 let old = serde_json::json!({"boundaries": []});
620 let meta: super::super::types::CacheMeta =
621 serde_json::from_value(old).expect("old CacheMeta parses");
622 assert!(meta.history_tail.is_none());
623 assert!(meta.delta.is_none());
624 }
625
626 #[test]
627 fn context_report_render_contains_no_content_sentinel() {
628 let secret = "SENTINEL-SECRET-9f8e7d";
629 let key = test_key();
632 let store = CacheSnapshotStore::new();
633 let tools = vec![tool(
634 "alpha",
635 serde_json::json!({"type": "object", "d": secret}),
636 )];
637 let messages = vec![msg("user", secret)];
638 store.compare_and_update(Some(&key), &tools, Some(secret), &messages);
639 let report = ContextReport {
640 model: "anthropic/claude-test".to_string(),
641 system_prompt_bytes: ReportValue::Count(secret.len() as u64),
642 tool_count: ReportValue::Count(1),
643 history_messages: ReportValue::Count(1),
644 history_bytes: ReportValue::Count(42),
645 loaded_skills: ReportValue::Unavailable,
646 loaded_memories: ReportValue::Unavailable,
647 cache: store.last_activity(),
648 trace_enabled: true,
649 writer_stats: None,
650 degraded_records: 0,
651 };
652 let rendered = report.render();
653 assert!(!rendered.contains(secret), "sentinel leaked: {rendered}");
654 assert!(rendered.contains("unavailable"));
655 assert!(rendered.contains("cache:"));
656 }
657}