1use crate::NaviConfig;
2use crate::context::{ContextPacket, render_context_packets};
3use crate::harness::{build_system_prompt_with_manifest_text, tool_prompt_manifest};
4use crate::model::ModelMessage;
5use crate::skills::{SkillManifest, render_active_skills, render_available_skills};
6use crate::tool::ToolDefinition;
7use anyhow::{Context, Result};
8use std::collections::HashMap;
9use std::collections::hash_map::DefaultHasher;
10use std::fs;
11use std::hash::{Hash, Hasher};
12use std::path::{Path, PathBuf};
13use std::sync::Mutex;
14use std::sync::atomic::{AtomicUsize, Ordering};
15use std::time::SystemTime;
16
17#[derive(Debug, Default)]
18pub struct PromptCache {
19 files: Mutex<HashMap<PathBuf, CachedFile>>,
20 rendered: Mutex<HashMap<RenderedPromptKey, String>>,
21 disk_reads: AtomicUsize,
22}
23
24#[derive(Debug, Clone)]
25struct CachedFile {
26 content: String,
27 modified: Option<SystemTime>,
28 len: u64,
29}
30
31#[derive(Debug, Clone, PartialEq, Eq, Hash)]
32enum RenderedPromptKey {
33 ToolManifest(u64),
34}
35
36impl PromptCache {
37 pub fn new() -> Self {
38 Self::default()
39 }
40
41 pub fn read_file(&self, path: &Path) -> Result<String> {
42 let metadata =
43 fs::metadata(path).with_context(|| format!("failed to stat {}", path.display()))?;
44 let modified = metadata.modified().ok();
45 let len = metadata.len();
46 let canonical = normalize_cache_path(path);
47
48 if let Some(cached) = self
49 .files
50 .lock()
51 .unwrap_or_else(|e| e.into_inner())
52 .get(&canonical)
53 && cached.modified == modified
54 && cached.len == len
55 {
56 return Ok(cached.content.clone());
57 }
58
59 let content = fs::read_to_string(path)
60 .with_context(|| format!("failed to read {}", path.display()))?;
61 self.disk_reads.fetch_add(1, Ordering::Relaxed);
62 self.files.lock().unwrap_or_else(|e| e.into_inner()).insert(
63 canonical,
64 CachedFile {
65 content: content.clone(),
66 modified,
67 len,
68 },
69 );
70 Ok(content)
71 }
72
73 pub fn render_tool_manifest(&self, tools: &[ToolDefinition]) -> String {
74 let key = RenderedPromptKey::ToolManifest(tool_definitions_hash(tools));
75 if let Some(cached) = self
76 .rendered
77 .lock()
78 .unwrap_or_else(|e| e.into_inner())
79 .get(&key)
80 {
81 return cached.clone();
82 }
83 let rendered = tool_prompt_manifest(tools);
84 self.rendered
85 .lock()
86 .unwrap_or_else(|e| e.into_inner())
87 .insert(key, rendered.clone());
88 rendered
89 }
90
91 pub fn disk_read_count(&self) -> usize {
92 self.disk_reads.load(Ordering::Relaxed)
93 }
94}
95
96#[derive(Debug, Clone, Default)]
102pub struct RenderedPrompt {
103 pub instructions: String,
107 pub developer_messages: Vec<ModelMessage>,
111}
112
113#[derive(Clone)]
114pub struct SystemPromptRenderer {
115 cache: std::sync::Arc<PromptCache>,
116}
117
118impl SystemPromptRenderer {
119 pub fn new(cache: std::sync::Arc<PromptCache>) -> Self {
120 Self { cache }
121 }
122
123 pub fn render(&self, input: SystemPromptInput) -> RenderedPrompt {
124 let manifest = if input.include_tool_prompt_manifest && !input.tools.is_empty() {
125 Some(self.cache.render_tool_manifest(&input.tools))
126 } else {
127 None
128 };
129
130 let instructions = build_system_prompt_with_manifest_text(
134 &input.config,
135 &input.project_dir,
136 None,
137 manifest.as_deref(),
138 );
139
140 let mut developer_messages = Vec::new();
141
142 if let Ok(dirs) = crate::config::persistence::navi_dirs() {
144 let global_agents_path = dirs.config_dir().join("AGENTS.md");
145 if let Ok(global_agents) = self.cache.read_file(&global_agents_path)
146 && !global_agents.trim().is_empty()
147 {
148 developer_messages.push(ModelMessage::developer(format!(
149 "=== Global User Instructions (AGENTS.md) ===\n{global_agents}"
150 )));
151 }
152 }
153
154 if let Ok(project_agents) = self.cache.read_file(&input.project_dir.join("AGENTS.md"))
156 && !project_agents.trim().is_empty()
157 {
158 developer_messages.push(ModelMessage::developer(format!(
159 "=== AGENTS.md / Project Instructions ===\n{project_agents}"
160 )));
161 }
162
163 if let Some(context) = render_context_packets(&input.context_packets) {
165 developer_messages.push(ModelMessage::developer(context));
166 }
167
168 if let Some(skills) = render_available_skills(&input.available_skills) {
170 developer_messages.push(ModelMessage::developer(skills));
171 }
172
173 if let Some(skills) = render_active_skills(&input.active_skills) {
175 developer_messages.push(ModelMessage::developer(skills));
176 }
177
178 if let Some(memory) = &input.memory_injection
180 && !memory.trim().is_empty()
181 {
182 developer_messages.push(ModelMessage::developer(memory.clone()));
183 }
184
185 RenderedPrompt {
186 instructions,
187 developer_messages,
188 }
189 }
190}
191
192pub struct SystemPromptInput {
193 pub config: NaviConfig,
194 pub project_dir: PathBuf,
195 pub memory_injection: Option<String>,
196 pub tools: Vec<ToolDefinition>,
197 pub include_tool_prompt_manifest: bool,
198 pub context_packets: Vec<ContextPacket>,
199 pub available_skills: Vec<SkillManifest>,
200 pub active_skills: Vec<SkillManifest>,
201}
202
203fn normalize_cache_path(path: &Path) -> PathBuf {
204 path.canonicalize().unwrap_or_else(|_| path.to_path_buf())
205}
206
207fn tool_definitions_hash(tools: &[ToolDefinition]) -> u64 {
208 let mut tools = tools.to_vec();
209 tools.sort_by(|a, b| a.name.cmp(&b.name));
210 let serialized = serde_json::to_string(&tools).unwrap_or_default();
211 let mut hasher = DefaultHasher::new();
212 serialized.hash(&mut hasher);
213 hasher.finish()
214}
215
216#[cfg(test)]
217mod tests {
218 use super::*;
219 use crate::{HarnessProfile, ToolKind};
220 use serde_json::json;
221
222 #[test]
223 fn prompt_cache_reuses_unchanged_file() {
224 let tempdir = tempfile::tempdir().expect("tempdir");
225 let path = tempdir.path().join("AGENTS.md");
226 std::fs::write(&path, "instructions").expect("write");
227 let cache = PromptCache::new();
228
229 assert_eq!(cache.read_file(&path).unwrap(), "instructions");
230 assert_eq!(cache.read_file(&path).unwrap(), "instructions");
231 assert_eq!(cache.disk_read_count(), 1);
232 }
233
234 #[test]
235 fn prompt_cache_invalidates_when_file_changes() {
236 let tempdir = tempfile::tempdir().expect("tempdir");
237 let path = tempdir.path().join("AGENTS.md");
238 std::fs::write(&path, "one").expect("write one");
239 let cache = PromptCache::new();
240
241 assert_eq!(cache.read_file(&path).unwrap(), "one");
242 std::thread::sleep(std::time::Duration::from_millis(5));
243 std::fs::write(&path, "two-two").expect("write two");
244 assert_eq!(cache.read_file(&path).unwrap(), "two-two");
245 assert_eq!(cache.disk_read_count(), 2);
246 }
247
248 #[test]
249 fn system_prompt_renderer_uses_cached_agents_md() {
250 let tempdir = tempfile::tempdir().expect("tempdir");
251 std::fs::write(tempdir.path().join("AGENTS.md"), "project rules").expect("write");
252 let cache = std::sync::Arc::new(PromptCache::new());
253 let renderer = SystemPromptRenderer::new(cache.clone());
254 let input = || SystemPromptInput {
255 config: crate::NaviConfig {
256 harness: crate::config::HarnessConfig {
257 profile: HarnessProfile::Small,
258 ..Default::default()
259 },
260 ..Default::default()
261 },
262 project_dir: tempdir.path().to_path_buf(),
263 memory_injection: None,
264 tools: vec![ToolDefinition {
265 name: "read_file".to_string(),
266 description: "read".to_string(),
267 kind: ToolKind::Read,
268 input_schema: json!({"type":"object"}),
269 ..Default::default()
270 }],
271 include_tool_prompt_manifest: true,
272 context_packets: Vec::new(),
273 available_skills: Vec::new(),
274 active_skills: Vec::new(),
275 };
276
277 let first = renderer.render(input());
278 let second = renderer.render(input());
279 assert!(
280 first
281 .developer_messages
282 .iter()
283 .any(|m| m.content.contains("project rules"))
284 );
285 assert_eq!(first.instructions, second.instructions);
286 assert_eq!(
287 first.developer_messages.len(),
288 second.developer_messages.len()
289 );
290 for (a, b) in first
291 .developer_messages
292 .iter()
293 .zip(second.developer_messages.iter())
294 {
295 assert_eq!(a.content, b.content);
296 }
297 assert_eq!(cache.disk_read_count(), 1);
298 }
299}