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