Skip to main content

talos_agent/
caching.rs

1//! Prompt caching strategy for provider-side caching.
2//!
3//! This module structures system prompts to maximize cache hit rates by
4//! separating stable content (identity, tool definitions, context files)
5//! from dynamic content (conversation history). The static prefix remains
6//! identical across turns, enabling providers like Anthropic to cache and
7//! reuse token computations.
8//!
9//! # Cache Control Strategy
10//!
11//! The system prompt is divided into three cacheable sections:
12//! 1. **Identity** — agent identity and behavioral instructions (~1024 tokens)
13//! 2. **Tool definitions** — sorted by name for stable ordering
14//! 3. **Context files** — AGENTS.md and other reference materials
15//!
16//! Each section boundary is marked with a `cache_control` breakpoint,
17//! signaling the provider to cache up to that point.
18//!
19//! # Example
20//!
21//! ```
22//! use talos_agent::caching::PromptCache;
23//! use talos_core::provider::ToolDefinition;
24//!
25//! let mut cache = PromptCache::new();
26//! let tools = vec![
27//!     ToolDefinition::new("bash", "Execute shell commands", serde_json::json!({})),
28//!     ToolDefinition::new("read", "Read a file", serde_json::json!({})),
29//! ];
30//! let system_prompt = cache.build_system_prompt(
31//!     "You are a helpful coding assistant.",
32//!     &tools,
33//!     "# Project Rules\nFollow the coding guide.",
34//! );
35//!
36//! let anthropic_format = system_prompt.to_anthropic_format();
37//! ```
38
39use serde_json::{Value, json};
40use talos_core::provider::ToolDefinition;
41
42/// A structured system prompt with cache control breakpoints.
43///
44/// The system prompt is divided into a static prefix (stable across turns)
45/// and dynamic content. Cache control breakpoints mark positions where the
46/// provider should cache token computations.
47#[derive(Debug, Clone)]
48pub struct SystemPrompt {
49    /// The complete system prompt text.
50    full_text: String,
51    /// Byte positions where `cache_control` markers should be inserted.
52    /// These correspond to boundaries between cacheable sections.
53    cache_control_breakpoints: Vec<usize>,
54}
55
56impl SystemPrompt {
57    /// Returns the complete system prompt text.
58    #[must_use]
59    pub fn full_text(&self) -> &str {
60        &self.full_text
61    }
62
63    /// Returns the byte positions where cache control markers should be inserted.
64    #[must_use]
65    pub fn cache_control_breakpoints(&self) -> &[usize] {
66        &self.cache_control_breakpoints
67    }
68
69    /// Formats this system prompt for the Anthropic Messages API with
70    /// `cache_control` markers at the appropriate breakpoints.
71    ///
72    /// The output follows the Anthropic format:
73    /// ```json
74    /// {
75    ///   "system": [
76    ///     {"type": "text", "text": "...", "cache_control": {"type": "ephemeral"}},
77    ///     {"type": "text", "text": "..."}
78    ///   ]
79    /// }
80    /// ```
81    ///
82    /// Each section between breakpoints becomes a separate content block.
83    /// The last block before each breakpoint gets a `cache_control` marker.
84    #[must_use]
85    pub fn to_anthropic_format(&self) -> Value {
86        let mut blocks: Vec<Value> = Vec::new();
87        let text = &self.full_text;
88        let mut prev_pos = 0;
89
90        for &bp in &self.cache_control_breakpoints {
91            let end = bp.min(text.len());
92            if end > prev_pos {
93                let section_text = text[prev_pos..end].to_string();
94                blocks.push(json!({
95                    "type": "text",
96                    "text": section_text,
97                    "cache_control": {"type": "ephemeral"}
98                }));
99            }
100            prev_pos = end;
101        }
102
103        // Remaining text after the last breakpoint (dynamic part, not cached)
104        if prev_pos < text.len() {
105            let remaining = text[prev_pos..].to_string();
106            blocks.push(json!({
107                "type": "text",
108                "text": remaining
109            }));
110        }
111
112        // If no breakpoints exist, return the full text as a single uncached block
113        if blocks.is_empty() {
114            blocks.push(json!({
115                "type": "text",
116                "text": text
117            }));
118        }
119
120        json!({
121            "system": blocks
122        })
123    }
124}
125
126/// Manages prompt caching for the agent turn loop.
127///
128/// `PromptCache` builds structured system prompts with cache control
129/// breakpoints and tracks cache performance metrics.
130#[derive(Debug)]
131pub struct PromptCache {
132    /// Number of cache hits observed.
133    cache_hits: u64,
134    /// Total number of cache checks performed.
135    cache_checks: u64,
136}
137
138impl PromptCache {
139    /// Creates a new prompt cache with zeroed metrics.
140    #[must_use]
141    pub fn new() -> Self {
142        Self {
143            cache_hits: 0,
144            cache_checks: 0,
145        }
146    }
147
148    /// Builds a structured system prompt with cache control breakpoints.
149    ///
150    /// The system prompt is organized into three cacheable sections:
151    /// 1. **Identity** — the agent's identity and behavioral instructions
152    /// 2. **Tool definitions** — sorted alphabetically by name for stable ordering
153    /// 3. **Context files** — reference materials like AGENTS.md
154    ///
155    /// A dynamic section follows the static prefix, reserved for conversation
156    /// history that changes each turn.
157    ///
158    /// # Arguments
159    ///
160    /// * `identity` — Agent identity and behavioral instructions.
161    /// * `tools` — List of tool definitions (will be sorted by name).
162    /// * `context` — Context file contents (e.g., AGENTS.md).
163    ///
164    /// # Cache Breakpoints
165    ///
166    /// Three breakpoints are inserted:
167    /// - After the identity section
168    /// - After the tool definitions section
169    /// - After the context files section
170    #[must_use]
171    pub fn build_system_prompt(
172        &self,
173        identity: &str,
174        tools: &[ToolDefinition],
175        context: &str,
176    ) -> SystemPrompt {
177        // Sort tools by name for stable ordering
178        let mut sorted_tools: Vec<&ToolDefinition> = tools.iter().collect();
179        sorted_tools.sort_by(|a, b| a.name.cmp(&b.name));
180
181        // Build the identity section
182        let identity_section = format!("# Identity\n{identity}\n");
183
184        // Build the tool definitions section
185        let tools_section = if sorted_tools.is_empty() {
186            String::from("# Tools\nNo tools available.\n")
187        } else {
188            let mut section = String::from("# Tools\n");
189            for tool in &sorted_tools {
190                section.push_str(&tool.to_prompt_text());
191                section.push_str("\n\n");
192            }
193            section
194        };
195
196        // Build the context section
197        let context_section = if context.is_empty() {
198            String::from("# Context\nNo context files loaded.\n")
199        } else {
200            format!("# Context\n{context}\n")
201        };
202
203        // Assemble the full static prefix
204        let static_prefix = format!("{identity_section}\n{tools_section}\n{context_section}");
205
206        // Calculate breakpoints (byte positions)
207        let bp1 = identity_section.len();
208        let bp2 = bp1 + 1 + tools_section.len(); // +1 for the separator newline
209        let bp3 = bp2 + 1 + context_section.len(); // +1 for the separator newline
210
211        SystemPrompt {
212            full_text: static_prefix,
213            cache_control_breakpoints: vec![bp1, bp2, bp3],
214        }
215    }
216
217    /// Records a cache hit or miss for performance tracking.
218    ///
219    /// # Arguments
220    ///
221    /// * `hit` — `true` if the cache was hit, `false` if it was a miss.
222    pub fn track_cache_hit_rate(&mut self, hit: bool) {
223        self.cache_checks += 1;
224        if hit {
225            self.cache_hits += 1;
226        }
227    }
228
229    /// Returns the cache hit rate as a percentage (0.0 to 100.0).
230    ///
231    /// Returns `0.0` if no cache checks have been recorded.
232    #[must_use]
233    pub fn cache_hit_rate(&self) -> f64 {
234        if self.cache_checks == 0 {
235            0.0
236        } else {
237            (self.cache_hits as f64 / self.cache_checks as f64) * 100.0
238        }
239    }
240}
241
242impl Default for PromptCache {
243    fn default() -> Self {
244        Self::new()
245    }
246}
247
248#[cfg(test)]
249#[allow(warnings)]
250mod tests {
251    use super::*;
252
253    // --- System prompt structure tests ---
254
255    #[test]
256    fn test_system_prompt_has_static_prefix() {
257        let cache = PromptCache::new();
258        let tools = vec![ToolDefinition::new(
259            "bash",
260            "Execute shell commands",
261            json!({}),
262        )];
263        let prompt = cache.build_system_prompt("You are an assistant.", &tools, "Context here.");
264
265        assert!(prompt.full_text().contains("# Identity"));
266        assert!(prompt.full_text().contains("You are an assistant."));
267        assert!(prompt.full_text().contains("# Tools"));
268        assert!(prompt.full_text().contains("# Context"));
269        assert!(prompt.full_text().contains("Context here."));
270    }
271
272    #[test]
273    fn test_system_prompt_static_prefix_is_consistent() {
274        let cache = PromptCache::new();
275        let tools = vec![ToolDefinition::new(
276            "bash",
277            "Execute shell commands",
278            json!({}),
279        )];
280
281        let prompt1 = cache.build_system_prompt("You are an assistant.", &tools, "Context here.");
282        let prompt2 = cache.build_system_prompt("You are an assistant.", &tools, "Context here.");
283
284        assert_eq!(prompt1.full_text(), prompt2.full_text());
285        assert_eq!(
286            prompt1.cache_control_breakpoints(),
287            prompt2.cache_control_breakpoints()
288        );
289    }
290
291    // --- Cache control breakpoint tests ---
292
293    #[test]
294    fn test_cache_control_breakpoints_at_correct_positions() {
295        let cache = PromptCache::new();
296        let prompt = cache.build_system_prompt("Identity text.", &[], "");
297
298        let breakpoints = prompt.cache_control_breakpoints();
299        assert_eq!(breakpoints.len(), 3);
300
301        // BP1 should be after the identity section
302        let bp1 = breakpoints[0];
303        assert!(prompt.full_text()[..bp1].contains("# Identity"));
304        assert!(prompt.full_text()[..bp1].contains("Identity text."));
305
306        // BP2 should be after the tools section
307        let bp2 = breakpoints[1];
308        assert!(prompt.full_text()[..bp2].contains("# Tools"));
309
310        // BP3 should be after the context section
311        let bp3 = breakpoints[2];
312        assert!(prompt.full_text()[..bp3].contains("# Context"));
313
314        // BP3 should be at or near the end of the text
315        assert!(bp3 <= prompt.full_text().len());
316    }
317
318    #[test]
319    fn test_breakpoints_are_increasing() {
320        let cache = PromptCache::new();
321        let tools = vec![ToolDefinition::new("bash", "Execute commands", json!({}))];
322        let prompt = cache.build_system_prompt("Identity.", &tools, "Context.");
323
324        let bps = prompt.cache_control_breakpoints();
325        assert!(bps[0] < bps[1]);
326        assert!(bps[1] < bps[2]);
327    }
328
329    // --- Tool definition sorting tests ---
330
331    #[test]
332    fn test_tool_definitions_sorted_by_name() {
333        let cache = PromptCache::new();
334        let tools = vec![
335            ToolDefinition::new("write", "Write a file", json!({})),
336            ToolDefinition::new("bash", "Execute commands", json!({})),
337            ToolDefinition::new("read", "Read a file", json!({})),
338        ];
339        let prompt = cache.build_system_prompt("Identity.", &tools, "");
340
341        let text = prompt.full_text();
342        let bash_pos = text.find("## bash").expect("bash should be present");
343        let read_pos = text.find("## read").expect("read should be present");
344        let write_pos = text.find("## write").expect("write should be present");
345
346        assert!(bash_pos < read_pos, "bash should come before read");
347        assert!(read_pos < write_pos, "read should come before write");
348    }
349
350    #[test]
351    fn test_empty_tools_list() {
352        let cache = PromptCache::new();
353        let prompt = cache.build_system_prompt("Identity.", &[], "");
354
355        assert!(prompt.full_text().contains("No tools available."));
356        assert_eq!(prompt.cache_control_breakpoints().len(), 3);
357    }
358
359    #[test]
360    fn test_empty_context() {
361        let cache = PromptCache::new();
362        let prompt = cache.build_system_prompt("Identity.", &[], "");
363
364        assert!(prompt.full_text().contains("No context files loaded."));
365    }
366
367    // --- Anthropic format tests ---
368
369    #[test]
370    fn test_to_anthropic_format_produces_valid_json() {
371        let cache = PromptCache::new();
372        let tools = vec![ToolDefinition::new("bash", "Execute commands", json!({}))];
373        let prompt = cache.build_system_prompt("Identity.", &tools, "Context.");
374        let anthropic = prompt.to_anthropic_format();
375
376        // Should be a valid JSON object with "system" key
377        assert!(anthropic.get("system").is_some());
378        let system_blocks = anthropic["system"]
379            .as_array()
380            .expect("operation should succeed");
381        assert!(!system_blocks.is_empty());
382    }
383
384    #[test]
385    fn test_to_anthropic_format_has_cache_control_markers() {
386        let cache = PromptCache::new();
387        let prompt = cache.build_system_prompt("Identity.", &[], "");
388        let anthropic = prompt.to_anthropic_format();
389
390        let system_blocks = anthropic["system"]
391            .as_array()
392            .expect("operation should succeed");
393
394        // Should have 4 blocks: 3 cached + 1 uncached (or 3 cached if no trailing text)
395        // At minimum, the first 3 blocks should have cache_control
396        let cached_blocks: Vec<_> = system_blocks
397            .iter()
398            .filter(|b| b.get("cache_control").is_some())
399            .collect();
400
401        assert!(
402            cached_blocks.len() >= 3,
403            "Expected at least 3 cached blocks, got {}",
404            cached_blocks.len()
405        );
406
407        // Verify cache_control format
408        for block in &cached_blocks {
409            let cc = block["cache_control"]
410                .as_object()
411                .expect("operation should succeed");
412            assert_eq!(
413                cc.get("type")
414                    .expect("operation should succeed")
415                    .as_str()
416                    .expect("operation should succeed"),
417                "ephemeral"
418            );
419        }
420    }
421
422    #[test]
423    fn test_to_anthropic_format_last_block_uncached() {
424        let cache = PromptCache::new();
425        let prompt = cache.build_system_prompt("Identity.", &[], "Context.");
426        let anthropic = prompt.to_anthropic_format();
427
428        let system_blocks = anthropic["system"]
429            .as_array()
430            .expect("operation should succeed");
431
432        // The last block should NOT have cache_control (it's the dynamic part)
433        // But since build_system_prompt only builds the static prefix,
434        // the last cached block ends at bp3 which is the end of text.
435        // So all blocks will be cached in this case.
436        let last_block = system_blocks.last().expect("operation should succeed");
437        // When static_prefix ends exactly at bp3, the last block is cached
438        assert!(last_block.get("cache_control").is_some());
439    }
440
441    #[test]
442    fn test_to_anthropic_format_with_empty_prompt() {
443        let prompt = SystemPrompt {
444            full_text: String::new(),
445            cache_control_breakpoints: vec![],
446        };
447        let anthropic = prompt.to_anthropic_format();
448
449        let system_blocks = anthropic["system"]
450            .as_array()
451            .expect("operation should succeed");
452        assert_eq!(system_blocks.len(), 1);
453        assert_eq!(system_blocks[0]["text"], "");
454        // No cache_control on the single block
455        assert!(system_blocks[0].get("cache_control").is_none());
456    }
457
458    // --- Cache hit rate tests ---
459
460    #[test]
461    fn test_cache_hit_rate_initially_zero() {
462        let cache = PromptCache::new();
463        assert_eq!(cache.cache_hit_rate(), 0.0);
464    }
465
466    #[test]
467    fn test_cache_hit_rate_after_hits() {
468        let mut cache = PromptCache::new();
469        cache.track_cache_hit_rate(true);
470        cache.track_cache_hit_rate(true);
471        cache.track_cache_hit_rate(false);
472        cache.track_cache_hit_rate(true);
473
474        // 3 hits out of 4 checks = 75%
475        assert!((cache.cache_hit_rate() - 75.0).abs() < f64::EPSILON);
476    }
477
478    #[test]
479    fn test_cache_hit_rate_all_hits() {
480        let mut cache = PromptCache::new();
481        cache.track_cache_hit_rate(true);
482        cache.track_cache_hit_rate(true);
483
484        assert!((cache.cache_hit_rate() - 100.0).abs() < f64::EPSILON);
485    }
486
487    #[test]
488    fn test_cache_hit_rate_all_misses() {
489        let mut cache = PromptCache::new();
490        cache.track_cache_hit_rate(false);
491        cache.track_cache_hit_rate(false);
492
493        assert!((cache.cache_hit_rate() - 0.0).abs() < f64::EPSILON);
494    }
495
496    #[test]
497    fn test_cache_hit_rate_single_hit() {
498        let mut cache = PromptCache::new();
499        cache.track_cache_hit_rate(true);
500
501        assert!((cache.cache_hit_rate() - 100.0).abs() < f64::EPSILON);
502    }
503
504    #[test]
505    fn test_cache_hit_rate_single_miss() {
506        let mut cache = PromptCache::new();
507        cache.track_cache_hit_rate(false);
508
509        assert!((cache.cache_hit_rate() - 0.0).abs() < f64::EPSILON);
510    }
511
512    // --- ToolDefinition tests ---
513
514    #[test]
515    fn test_tool_definition_to_prompt_text() {
516        let tool = ToolDefinition::new(
517            "read_file",
518            "Read the contents of a file",
519            json!({
520                "type": "object",
521                "properties": {
522                    "path": {"type": "string"}
523                }
524            }),
525        );
526
527        let text = tool.to_prompt_text();
528        assert!(text.contains("## read_file"));
529        assert!(text.contains("Read the contents of a file"));
530        assert!(text.contains("path"));
531    }
532
533    // --- Integration-style tests ---
534
535    #[test]
536    fn test_full_prompt_with_all_sections() {
537        let cache = PromptCache::new();
538        let tools = vec![
539            ToolDefinition::new("bash", "Run shell commands", json!({"command": "string"})),
540            ToolDefinition::new("read", "Read files", json!({"path": "string"})),
541            ToolDefinition::new(
542                "write",
543                "Write files",
544                json!({"path": "string", "content": "string"}),
545            ),
546        ];
547        let identity = "You are Talos, a safety-first agent runtime.";
548        let context = "# AGENTS.md\nFollow the coding guide.";
549
550        let prompt = cache.build_system_prompt(identity, &tools, context);
551        let anthropic = prompt.to_anthropic_format();
552
553        // Verify structure
554        assert!(prompt.full_text().contains("You are Talos"));
555        assert!(prompt.full_text().contains("## bash"));
556        assert!(prompt.full_text().contains("## read"));
557        assert!(prompt.full_text().contains("## write"));
558        assert!(prompt.full_text().contains("AGENTS.md"));
559
560        // Verify Anthropic format
561        let system_blocks = anthropic["system"]
562            .as_array()
563            .expect("operation should succeed");
564        assert!(!system_blocks.is_empty());
565
566        // Verify tools are in alphabetical order in the output
567        let text = prompt.full_text();
568        assert!(
569            text.find("## bash").expect("operation should succeed")
570                < text.find("## read").expect("operation should succeed")
571        );
572        assert!(
573            text.find("## read").expect("operation should succeed")
574                < text.find("## write").expect("operation should succeed")
575        );
576    }
577
578    #[test]
579    fn test_prompt_cache_default_trait() {
580        let cache = PromptCache::default();
581        assert_eq!(cache.cache_hit_rate(), 0.0);
582    }
583
584    #[test]
585    fn test_system_prompt_clone() {
586        let cache = PromptCache::new();
587        let prompt = cache.build_system_prompt("Identity.", &[], "");
588        let cloned = prompt.clone();
589
590        assert_eq!(prompt.full_text(), cloned.full_text());
591        assert_eq!(
592            prompt.cache_control_breakpoints(),
593            cloned.cache_control_breakpoints()
594        );
595    }
596
597    #[test]
598    fn test_tool_definition_clone_and_eq() {
599        let tool1 = ToolDefinition::new("bash", "Run commands", json!({}));
600        let tool2 = tool1.clone();
601
602        assert_eq!(tool1, tool2);
603    }
604}