Skip to main content

navi_core/
prompt.rs

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/// The result of rendering a system prompt: a stable base `instructions`
97/// string (sent in the provider's `instructions` field or as the first
98/// system message) and a list of dynamic `developer_messages` (injected
99/// as separate messages so that changes to context blocks don't
100/// invalidate the provider's prompt cache for the base prefix).
101#[derive(Debug, Clone, Default)]
102pub struct RenderedPrompt {
103    /// Stable base instructions for the `instructions` field of the
104    /// provider request. Kept identical across turns when config, cwd,
105    /// and tool set are unchanged.
106    pub instructions: String,
107    /// Dynamic context blocks injected as separate developer-role
108    /// messages after the base instructions. Each block can change
109    /// independently without invalidating the cache for `instructions`.
110    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        // Stable base: identity, workflow, tool rules, code tools, sprint
131        // contract, auto-memory instructions, and tool manifest. Does NOT
132        // include AGENTS.md, context packets, skills, or memory injection.
133        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        // Global user instructions (~/.config/navi/AGENTS.md).
143        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        // Project-level AGENTS.md (omit entirely when absent — no placeholder noise).
155        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        // Context packets (external context from clients).
164        if let Some(context) = render_context_packets(&input.context_packets) {
165            developer_messages.push(ModelMessage::developer(context));
166        }
167
168        // Available skills catalog.
169        if let Some(skills) = render_available_skills(&input.available_skills) {
170            developer_messages.push(ModelMessage::developer(skills));
171        }
172
173        // Active skills with instruction text.
174        if let Some(skills) = render_active_skills(&input.active_skills) {
175            developer_messages.push(ModelMessage::developer(skills));
176        }
177
178        // Memory injection (auto-memory index + session memory).
179        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}