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::{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/// 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        // Catalog of root skills + pools (metadata only; open pool / load_skill for more).
169        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        // Active harness pack cards (loop/graph soft policy).
178        if let Some(card) = &input.harness_card
179            && !card.trim().is_empty()
180        {
181            developer_messages.push(ModelMessage::developer(card.clone()));
182        }
183
184        // Memory injection (auto-memory index + session memory).
185        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    /// Skill pools (folders) shown next to root skills in the catalog.
208    pub skill_pools: Vec<SkillPool>,
209    /// Optional harness pack developer card (loop/graph soft policy).
210    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}