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::{CatalogEntries, SkillManifest, SkillPool, render_catalog_entries};
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 let catalog = CatalogEntries {
170 root_skills: input.available_skills.clone(),
171 pools: input.skill_pools.clone(),
172 };
173 if let Some(skills) = render_catalog_entries(&catalog) {
174 developer_messages.push(ModelMessage::developer(skills));
175 }
176
177 if let Some(card) = &input.harness_card
179 && !card.trim().is_empty()
180 {
181 developer_messages.push(ModelMessage::developer(card.clone()));
182 }
183
184 if let Some(memory) = &input.memory_injection
186 && !memory.trim().is_empty()
187 {
188 developer_messages.push(ModelMessage::developer(memory.clone()));
189 }
190
191 RenderedPrompt {
192 instructions,
193 developer_messages,
194 }
195 }
196}
197
198pub struct SystemPromptInput {
199 pub config: NaviConfig,
200 pub project_dir: PathBuf,
201 pub memory_injection: Option<String>,
202 pub tools: Vec<ToolDefinition>,
203 pub include_tool_prompt_manifest: bool,
204 pub context_packets: Vec<ContextPacket>,
205 pub available_skills: Vec<SkillManifest>,
206 pub active_skills: Vec<SkillManifest>,
207 pub skill_pools: Vec<SkillPool>,
209 pub harness_card: Option<String>,
211}
212
213fn normalize_cache_path(path: &Path) -> PathBuf {
214 path.canonicalize().unwrap_or_else(|_| path.to_path_buf())
215}
216
217fn tool_definitions_hash(tools: &[ToolDefinition]) -> u64 {
218 let mut tools = tools.to_vec();
219 tools.sort_by(|a, b| a.name.cmp(&b.name));
220 let serialized = serde_json::to_string(&tools).unwrap_or_default();
221 let mut hasher = DefaultHasher::new();
222 serialized.hash(&mut hasher);
223 hasher.finish()
224}
225
226#[cfg(test)]
227mod tests {
228 use super::*;
229 use crate::{HarnessProfile, ToolKind};
230 use serde_json::json;
231
232 #[test]
233 fn prompt_cache_reuses_unchanged_file() {
234 let tempdir = tempfile::tempdir().expect("tempdir");
235 let path = tempdir.path().join("AGENTS.md");
236 std::fs::write(&path, "instructions").expect("write");
237 let cache = PromptCache::new();
238
239 assert_eq!(cache.read_file(&path).unwrap(), "instructions");
240 assert_eq!(cache.read_file(&path).unwrap(), "instructions");
241 assert_eq!(cache.disk_read_count(), 1);
242 }
243
244 #[test]
245 fn prompt_cache_invalidates_when_file_changes() {
246 let tempdir = tempfile::tempdir().expect("tempdir");
247 let path = tempdir.path().join("AGENTS.md");
248 std::fs::write(&path, "one").expect("write one");
249 let cache = PromptCache::new();
250
251 assert_eq!(cache.read_file(&path).unwrap(), "one");
252 std::thread::sleep(std::time::Duration::from_millis(5));
253 std::fs::write(&path, "two-two").expect("write two");
254 assert_eq!(cache.read_file(&path).unwrap(), "two-two");
255 assert_eq!(cache.disk_read_count(), 2);
256 }
257
258 #[test]
259 fn system_prompt_renderer_uses_cached_agents_md() {
260 let tempdir = tempfile::tempdir().expect("tempdir");
261 std::fs::write(tempdir.path().join("AGENTS.md"), "project rules").expect("write");
262 let cache = std::sync::Arc::new(PromptCache::new());
263 let renderer = SystemPromptRenderer::new(cache.clone());
264 let input = || SystemPromptInput {
265 config: crate::NaviConfig {
266 harness: crate::config::HarnessConfig {
267 profile: HarnessProfile::Small,
268 ..Default::default()
269 },
270 ..Default::default()
271 },
272 project_dir: tempdir.path().to_path_buf(),
273 memory_injection: None,
274 tools: vec![ToolDefinition {
275 name: "read_file".to_string(),
276 description: "read".to_string(),
277 kind: ToolKind::Read,
278 input_schema: json!({"type":"object"}),
279 ..Default::default()
280 }],
281 include_tool_prompt_manifest: true,
282 context_packets: Vec::new(),
283 available_skills: Vec::new(),
284 active_skills: Vec::new(),
285 skill_pools: Vec::new(),
286 harness_card: None,
287 };
288
289 let first = renderer.render(input());
290 let second = renderer.render(input());
291 assert!(
292 first
293 .developer_messages
294 .iter()
295 .any(|m| m.content.contains("project rules"))
296 );
297 assert_eq!(first.instructions, second.instructions);
298 assert_eq!(
299 first.developer_messages.len(),
300 second.developer_messages.len()
301 );
302 for (a, b) in first
303 .developer_messages
304 .iter()
305 .zip(second.developer_messages.iter())
306 {
307 assert_eq!(a.content, b.content);
308 }
309 assert_eq!(cache.disk_read_count(), 1);
310 }
311}