use crate::NaviConfig;
use crate::context::{ContextPacket, render_context_packets};
use crate::harness::{build_system_prompt_with_manifest_text, tool_prompt_manifest};
use crate::model::ModelMessage;
use crate::skills::{SkillManifest, render_active_skills, render_available_skills};
use crate::tool::ToolDefinition;
use anyhow::{Context, Result};
use std::collections::HashMap;
use std::collections::hash_map::DefaultHasher;
use std::fs;
use std::hash::{Hash, Hasher};
use std::path::{Path, PathBuf};
use std::sync::Mutex;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::time::SystemTime;
#[derive(Debug, Default)]
pub struct PromptCache {
files: Mutex<HashMap<PathBuf, CachedFile>>,
rendered: Mutex<HashMap<RenderedPromptKey, String>>,
disk_reads: AtomicUsize,
}
#[derive(Debug, Clone)]
struct CachedFile {
content: String,
modified: Option<SystemTime>,
len: u64,
}
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
enum RenderedPromptKey {
ToolManifest(u64),
}
impl PromptCache {
pub fn new() -> Self {
Self::default()
}
pub fn read_file(&self, path: &Path) -> Result<String> {
let metadata =
fs::metadata(path).with_context(|| format!("failed to stat {}", path.display()))?;
let modified = metadata.modified().ok();
let len = metadata.len();
let canonical = normalize_cache_path(path);
if let Some(cached) = self
.files
.lock()
.unwrap_or_else(|e| e.into_inner())
.get(&canonical)
&& cached.modified == modified
&& cached.len == len
{
return Ok(cached.content.clone());
}
let content = fs::read_to_string(path)
.with_context(|| format!("failed to read {}", path.display()))?;
self.disk_reads.fetch_add(1, Ordering::Relaxed);
self.files.lock().unwrap_or_else(|e| e.into_inner()).insert(
canonical,
CachedFile {
content: content.clone(),
modified,
len,
},
);
Ok(content)
}
pub fn render_tool_manifest(&self, tools: &[ToolDefinition]) -> String {
let key = RenderedPromptKey::ToolManifest(tool_definitions_hash(tools));
if let Some(cached) = self
.rendered
.lock()
.unwrap_or_else(|e| e.into_inner())
.get(&key)
{
return cached.clone();
}
let rendered = tool_prompt_manifest(tools);
self.rendered
.lock()
.unwrap_or_else(|e| e.into_inner())
.insert(key, rendered.clone());
rendered
}
pub fn disk_read_count(&self) -> usize {
self.disk_reads.load(Ordering::Relaxed)
}
}
#[derive(Debug, Clone, Default)]
pub struct RenderedPrompt {
pub instructions: String,
pub developer_messages: Vec<ModelMessage>,
}
#[derive(Clone)]
pub struct SystemPromptRenderer {
cache: std::sync::Arc<PromptCache>,
}
impl SystemPromptRenderer {
pub fn new(cache: std::sync::Arc<PromptCache>) -> Self {
Self { cache }
}
pub fn render(&self, input: SystemPromptInput) -> RenderedPrompt {
let manifest = if input.include_tool_prompt_manifest && !input.tools.is_empty() {
Some(self.cache.render_tool_manifest(&input.tools))
} else {
None
};
let instructions = build_system_prompt_with_manifest_text(
&input.config,
&input.project_dir,
None,
manifest.as_deref(),
);
let mut developer_messages = Vec::new();
if let Ok(dirs) = crate::config::persistence::navi_dirs() {
let global_agents_path = dirs.config_dir().join("AGENTS.md");
if let Ok(global_agents) = self.cache.read_file(&global_agents_path)
&& !global_agents.trim().is_empty()
{
developer_messages.push(ModelMessage::developer(format!(
"=== Global User Instructions (AGENTS.md) ===\n{global_agents}"
)));
}
}
if let Ok(project_agents) = self.cache.read_file(&input.project_dir.join("AGENTS.md"))
&& !project_agents.trim().is_empty()
{
developer_messages.push(ModelMessage::developer(format!(
"=== AGENTS.md / Project Instructions ===\n{project_agents}"
)));
}
if let Some(context) = render_context_packets(&input.context_packets) {
developer_messages.push(ModelMessage::developer(context));
}
if let Some(skills) = render_available_skills(&input.available_skills) {
developer_messages.push(ModelMessage::developer(skills));
}
if let Some(skills) = render_active_skills(&input.active_skills) {
developer_messages.push(ModelMessage::developer(skills));
}
if let Some(memory) = &input.memory_injection
&& !memory.trim().is_empty()
{
developer_messages.push(ModelMessage::developer(memory.clone()));
}
RenderedPrompt {
instructions,
developer_messages,
}
}
}
pub struct SystemPromptInput {
pub config: NaviConfig,
pub project_dir: PathBuf,
pub memory_injection: Option<String>,
pub tools: Vec<ToolDefinition>,
pub include_tool_prompt_manifest: bool,
pub context_packets: Vec<ContextPacket>,
pub available_skills: Vec<SkillManifest>,
pub active_skills: Vec<SkillManifest>,
}
fn normalize_cache_path(path: &Path) -> PathBuf {
path.canonicalize().unwrap_or_else(|_| path.to_path_buf())
}
fn tool_definitions_hash(tools: &[ToolDefinition]) -> u64 {
let mut tools = tools.to_vec();
tools.sort_by(|a, b| a.name.cmp(&b.name));
let serialized = serde_json::to_string(&tools).unwrap_or_default();
let mut hasher = DefaultHasher::new();
serialized.hash(&mut hasher);
hasher.finish()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{HarnessProfile, ToolKind};
use serde_json::json;
#[test]
fn prompt_cache_reuses_unchanged_file() {
let tempdir = tempfile::tempdir().expect("tempdir");
let path = tempdir.path().join("AGENTS.md");
std::fs::write(&path, "instructions").expect("write");
let cache = PromptCache::new();
assert_eq!(cache.read_file(&path).unwrap(), "instructions");
assert_eq!(cache.read_file(&path).unwrap(), "instructions");
assert_eq!(cache.disk_read_count(), 1);
}
#[test]
fn prompt_cache_invalidates_when_file_changes() {
let tempdir = tempfile::tempdir().expect("tempdir");
let path = tempdir.path().join("AGENTS.md");
std::fs::write(&path, "one").expect("write one");
let cache = PromptCache::new();
assert_eq!(cache.read_file(&path).unwrap(), "one");
std::thread::sleep(std::time::Duration::from_millis(5));
std::fs::write(&path, "two-two").expect("write two");
assert_eq!(cache.read_file(&path).unwrap(), "two-two");
assert_eq!(cache.disk_read_count(), 2);
}
#[test]
fn system_prompt_renderer_uses_cached_agents_md() {
let tempdir = tempfile::tempdir().expect("tempdir");
std::fs::write(tempdir.path().join("AGENTS.md"), "project rules").expect("write");
let cache = std::sync::Arc::new(PromptCache::new());
let renderer = SystemPromptRenderer::new(cache.clone());
let input = || SystemPromptInput {
config: crate::NaviConfig {
harness: crate::config::HarnessConfig {
profile: HarnessProfile::Small,
..Default::default()
},
..Default::default()
},
project_dir: tempdir.path().to_path_buf(),
memory_injection: None,
tools: vec![ToolDefinition {
name: "read_file".to_string(),
description: "read".to_string(),
kind: ToolKind::Read,
input_schema: json!({"type":"object"}),
..Default::default()
}],
include_tool_prompt_manifest: true,
context_packets: Vec::new(),
available_skills: Vec::new(),
active_skills: Vec::new(),
};
let first = renderer.render(input());
let second = renderer.render(input());
assert!(
first
.developer_messages
.iter()
.any(|m| m.content.contains("project rules"))
);
assert_eq!(first.instructions, second.instructions);
assert_eq!(
first.developer_messages.len(),
second.developer_messages.len()
);
for (a, b) in first
.developer_messages
.iter()
.zip(second.developer_messages.iter())
{
assert_eq!(a.content, b.content);
}
assert_eq!(cache.disk_read_count(), 1);
}
}