claude_agent/context/
static_context.rs

1//! Static Context for Prompt Caching
2//!
3//! Content that is always loaded and cached for the entire session.
4//! Per Anthropic best practices, static content uses 1-hour TTL.
5
6use crate::mcp::make_mcp_name;
7use crate::types::{CacheTtl, SystemBlock, ToolDefinition};
8use serde::{Deserialize, Serialize};
9
10#[derive(Clone, Debug, Default)]
11pub struct StaticContext {
12    pub system_prompt: String,
13    pub claude_md: String,
14    pub skill_index_summary: String,
15    pub rules_summary: String,
16    pub tool_definitions: Vec<ToolDefinition>,
17    pub mcp_tool_metadata: Vec<McpToolMeta>,
18}
19
20#[derive(Clone, Debug, Serialize, Deserialize)]
21pub struct McpToolMeta {
22    pub server: String,
23    pub name: String,
24    pub description: String,
25}
26
27impl StaticContext {
28    pub fn new() -> Self {
29        Self::default()
30    }
31
32    pub fn with_system_prompt(mut self, prompt: impl Into<String>) -> Self {
33        self.system_prompt = prompt.into();
34        self
35    }
36
37    pub fn with_claude_md(mut self, content: impl Into<String>) -> Self {
38        self.claude_md = content.into();
39        self
40    }
41
42    pub fn with_skill_summary(mut self, summary: impl Into<String>) -> Self {
43        self.skill_index_summary = summary.into();
44        self
45    }
46
47    pub fn with_rules_summary(mut self, summary: impl Into<String>) -> Self {
48        self.rules_summary = summary.into();
49        self
50    }
51
52    pub fn with_tools(mut self, tools: Vec<ToolDefinition>) -> Self {
53        self.tool_definitions = tools;
54        self
55    }
56
57    pub fn with_mcp_tools(mut self, tools: Vec<McpToolMeta>) -> Self {
58        self.mcp_tool_metadata = tools;
59        self
60    }
61
62    /// Convert static context to system blocks with 1-hour TTL caching.
63    ///
64    /// Per Anthropic best practices:
65    /// - Static content uses longer TTL (1 hour)
66    /// - Long TTL content must come before short TTL content
67    pub fn to_system_blocks(&self) -> Vec<SystemBlock> {
68        let mut blocks = Vec::new();
69        let ttl = CacheTtl::OneHour;
70
71        if !self.system_prompt.is_empty() {
72            blocks.push(SystemBlock::cached_with_ttl(&self.system_prompt, ttl));
73        }
74
75        if !self.claude_md.is_empty() {
76            blocks.push(SystemBlock::cached_with_ttl(&self.claude_md, ttl));
77        }
78
79        if !self.skill_index_summary.is_empty() {
80            blocks.push(SystemBlock::cached_with_ttl(&self.skill_index_summary, ttl));
81        }
82
83        if !self.rules_summary.is_empty() {
84            blocks.push(SystemBlock::cached_with_ttl(&self.rules_summary, ttl));
85        }
86
87        if !self.mcp_tool_metadata.is_empty() {
88            blocks.push(SystemBlock::cached_with_ttl(self.format_mcp_summary(), ttl));
89        }
90
91        blocks
92    }
93
94    fn format_mcp_summary(&self) -> String {
95        let mut lines = vec!["# MCP Server Tools".to_string()];
96        for tool in &self.mcp_tool_metadata {
97            lines.push(format!(
98                "- {}:  {}",
99                make_mcp_name(&tool.server, &tool.name),
100                tool.description
101            ));
102        }
103        lines.join("\n")
104    }
105
106    pub fn content_hash(&self) -> String {
107        use std::collections::hash_map::DefaultHasher;
108        use std::hash::{Hash, Hasher};
109
110        let mut hasher = DefaultHasher::new();
111        self.system_prompt.hash(&mut hasher);
112        self.claude_md.hash(&mut hasher);
113        self.skill_index_summary.hash(&mut hasher);
114        self.rules_summary.hash(&mut hasher);
115
116        for tool in &self.tool_definitions {
117            tool.name.hash(&mut hasher);
118        }
119
120        for mcp in &self.mcp_tool_metadata {
121            mcp.server.hash(&mut hasher);
122            mcp.name.hash(&mut hasher);
123        }
124
125        format!("{:016x}", hasher.finish())
126    }
127
128    pub fn estimate_tokens(&self) -> u64 {
129        let total_chars = self.system_prompt.len()
130            + self.claude_md.len()
131            + self.skill_index_summary.len()
132            + self.rules_summary.len()
133            + self
134                .mcp_tool_metadata
135                .iter()
136                .map(|t| t.description.len())
137                .sum::<usize>();
138
139        (total_chars / 4) as u64
140    }
141}
142
143#[cfg(test)]
144mod tests {
145    use super::*;
146    use crate::types::CacheType;
147
148    #[test]
149    fn test_system_block_cached_with_ttl() {
150        let block = SystemBlock::cached_with_ttl("Hello", CacheTtl::OneHour);
151        assert!(block.cache_control.is_some());
152        let cache_ctrl = block.cache_control.unwrap();
153        assert_eq!(cache_ctrl.cache_type, CacheType::Ephemeral);
154        assert_eq!(cache_ctrl.ttl, Some(CacheTtl::OneHour));
155        assert_eq!(block.block_type, "text");
156    }
157
158    #[test]
159    fn test_static_context_blocks() {
160        let static_context = StaticContext::new()
161            .with_system_prompt("You are a helpful assistant")
162            .with_claude_md("# Project\nThis is a Rust project");
163
164        let blocks = static_context.to_system_blocks();
165        assert_eq!(blocks.len(), 2);
166        assert!(blocks[0].text.contains("helpful assistant"));
167        assert!(blocks[1].text.contains("Rust project"));
168    }
169
170    #[test]
171    fn test_content_hash_consistency() {
172        let ctx1 = StaticContext::new()
173            .with_system_prompt("Same prompt")
174            .with_claude_md("Same content");
175
176        let ctx2 = StaticContext::new()
177            .with_system_prompt("Same prompt")
178            .with_claude_md("Same content");
179
180        assert_eq!(ctx1.content_hash(), ctx2.content_hash());
181    }
182
183    #[test]
184    fn test_content_hash_different() {
185        let ctx1 = StaticContext::new().with_system_prompt("Prompt A");
186        let ctx2 = StaticContext::new().with_system_prompt("Prompt B");
187
188        assert_ne!(ctx1.content_hash(), ctx2.content_hash());
189    }
190}