use std::path::Path;
use sha2::{Digest, Sha256};
use bamboo_agent_core::Session;
use crate::runtime::runner::session_setup::prompt_setup::StablePrefixSection;
const STATE_KEY: &str = "prefix_cache_section_state";
const OBS_ROUND_KEY: &str = "prefix_cache_obs_round";
const DUMP_COUNT_KEY: &str = "prefix_cache_drift_dumps";
const MAX_DUMPS_PER_SESSION: usize = 20;
const DRIFT_SUBDIR: &str = "prompt-cache-drift";
const LAST_SECTIONS_FILE: &str = "_last_sections.json";
fn sha256_hex(content: &str) -> String {
let mut hasher = Sha256::new();
hasher.update(content.as_bytes());
format!("{:x}", hasher.finalize())
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq, Eq)]
struct SectionState {
name: String,
len: usize,
sha: String,
}
fn capture_state(sections: &[StablePrefixSection]) -> Vec<SectionState> {
sections
.iter()
.map(|section| SectionState {
name: section.name.to_string(),
len: section.content.len(),
sha: sha256_hex(§ion.content),
})
.collect()
}
fn metadata_usize(session: &Session, key: &str) -> usize {
session
.metadata
.get(key)
.and_then(|raw| raw.parse::<usize>().ok())
.unwrap_or(0)
}
pub(super) fn record_prefix_drift(
session: &mut Session,
app_data_dir: Option<&Path>,
sections: &[StablePrefixSection],
) {
let obs_round = metadata_usize(session, OBS_ROUND_KEY) + 1;
session
.metadata
.insert(OBS_ROUND_KEY.to_string(), obs_round.to_string());
let current = capture_state(sections);
let previous: Option<Vec<SectionState>> = session
.metadata
.get(STATE_KEY)
.and_then(|raw| serde_json::from_str(raw).ok());
if let Ok(raw) = serde_json::to_string(¤t) {
session.metadata.insert(STATE_KEY.to_string(), raw);
}
let session_id = session.id.clone();
let drift_dir = app_data_dir.map(|dir| dir.join(DRIFT_SUBDIR).join(&session_id));
let Some(previous) = previous else {
if let Some(dir) = drift_dir.as_ref() {
write_last_sections(dir, sections);
}
tracing::debug!(
"[{}] prefix-cache baseline established at obs_round={} ({} sections, {} bytes)",
session_id,
obs_round,
sections.len(),
sections.iter().map(|s| s.content.len()).sum::<usize>(),
);
return;
};
let changes = diff_sections(&previous, ¤t);
if changes.is_empty() {
tracing::debug!(
"[{}] prefix-cache stable at obs_round={}",
session_id,
obs_round
);
return;
}
let shrunk: Vec<&SectionChange> = changes.iter().filter(|c| c.delta < 0).collect();
tracing::warn!(
"[{}] prefix-cache DRIFT at obs_round={}: changed={:?} shrunk={:?} (shrinks drop cached content and break the prefix cache)",
session_id,
obs_round,
changes
.iter()
.map(|c| format!("{}({:+})", c.name, c.delta))
.collect::<Vec<_>>(),
shrunk.iter().map(|c| c.name.as_str()).collect::<Vec<_>>(),
);
let Some(dir) = drift_dir else {
return;
};
let dumps = metadata_usize(session, DUMP_COUNT_KEY);
if dumps >= MAX_DUMPS_PER_SESSION {
tracing::debug!(
"[{}] prefix-cache drift dump cap ({}) reached; logging only",
session_id,
MAX_DUMPS_PER_SESSION
);
return;
}
let old_sections = read_last_sections(&dir);
write_drift_report(&dir, obs_round, &changes, sections, old_sections.as_deref());
write_session_snapshot(&dir, obs_round, session);
write_last_sections(&dir, sections);
session
.metadata
.insert(DUMP_COUNT_KEY.to_string(), (dumps + 1).to_string());
}
struct SectionChange {
name: String,
old_len: usize,
new_len: usize,
delta: i64,
old_sha: String,
new_sha: String,
}
fn diff_sections(previous: &[SectionState], current: &[SectionState]) -> Vec<SectionChange> {
let mut changes = Vec::new();
for cur in current {
let prev = previous.iter().find(|p| p.name == cur.name);
let (old_len, old_sha) = prev
.map(|p| (p.len, p.sha.clone()))
.unwrap_or((0, String::new()));
if old_sha != cur.sha {
changes.push(SectionChange {
name: cur.name.clone(),
old_len,
new_len: cur.len,
delta: cur.len as i64 - old_len as i64,
old_sha,
new_sha: cur.sha.clone(),
});
}
}
for prev in previous {
if !current.iter().any(|c| c.name == prev.name) {
changes.push(SectionChange {
name: prev.name.clone(),
old_len: prev.len,
new_len: 0,
delta: -(prev.len as i64),
old_sha: prev.sha.clone(),
new_sha: String::new(),
});
}
}
changes
}
#[derive(serde::Serialize, serde::Deserialize)]
struct StoredSection {
name: String,
content: String,
}
fn write_last_sections(dir: &Path, sections: &[StablePrefixSection]) {
let stored: Vec<StoredSection> = sections
.iter()
.map(|s| StoredSection {
name: s.name.to_string(),
content: s.content.clone(),
})
.collect();
write_json(&dir.join(LAST_SECTIONS_FILE), &stored);
}
fn read_last_sections(dir: &Path) -> Option<Vec<StoredSection>> {
let raw = std::fs::read_to_string(dir.join(LAST_SECTIONS_FILE)).ok()?;
serde_json::from_str(&raw).ok()
}
fn write_drift_report(
dir: &Path,
obs_round: usize,
changes: &[SectionChange],
current_sections: &[StablePrefixSection],
old_sections: Option<&[StoredSection]>,
) {
let total_old: usize = changes.iter().map(|c| c.old_len).sum();
let total_new: usize = changes.iter().map(|c| c.new_len).sum();
let section_reports: Vec<serde_json::Value> = changes
.iter()
.map(|c| {
let new_content = current_sections
.iter()
.find(|s| s.name == c.name)
.map(|s| s.content.clone());
let old_content = old_sections
.and_then(|olds| olds.iter().find(|s| s.name == c.name))
.map(|s| s.content.clone());
serde_json::json!({
"name": c.name,
"old_len": c.old_len,
"new_len": c.new_len,
"delta": c.delta,
"shrunk": c.delta < 0,
"old_sha": c.old_sha,
"new_sha": c.new_sha,
"old_content": old_content,
"new_content": new_content,
})
})
.collect();
let report = serde_json::json!({
"obs_round": obs_round,
"recorded_at": chrono::Utc::now().to_rfc3339(),
"changed_section_count": changes.len(),
"changed_total_old_len": total_old,
"changed_total_new_len": total_new,
"changed_total_delta": total_new as i64 - total_old as i64,
"any_shrunk": changes.iter().any(|c| c.delta < 0),
"old_content_available": old_sections.is_some(),
"sections": section_reports,
});
write_json(&dir.join(format!("round-{obs_round}.json")), &report);
}
fn write_session_snapshot(dir: &Path, obs_round: usize, session: &Session) {
write_json(&dir.join(format!("session-{obs_round}.json")), session);
}
fn write_json<T: serde::Serialize>(path: &Path, value: &T) {
if let Some(parent) = path.parent() {
if let Err(err) = std::fs::create_dir_all(parent) {
tracing::debug!(
"prefix-cache drift: mkdir {} failed: {}",
parent.display(),
err
);
return;
}
}
match serde_json::to_vec_pretty(value) {
Ok(bytes) => {
if let Err(err) = std::fs::write(path, bytes) {
tracing::debug!(
"prefix-cache drift: write {} failed: {}",
path.display(),
err
);
}
}
Err(err) => {
tracing::debug!(
"prefix-cache drift: serialize {} failed: {}",
path.display(),
err
);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn section(name: &'static str, content: &str) -> StablePrefixSection {
StablePrefixSection {
name,
content: content.to_string(),
}
}
#[test]
fn stable_prefix_writes_no_files_and_logs_stable() {
let dir = std::env::temp_dir().join("bamboo-prefix-drift-stable-test");
let _ = std::fs::remove_dir_all(&dir);
let mut session = Session::new("drift-stable", "model");
let sections = vec![section("base", "hello"), section("tool_guide", "guide")];
record_prefix_drift(&mut session, Some(&dir), §ions);
record_prefix_drift(&mut session, Some(&dir), §ions);
let session_dir = dir.join(DRIFT_SUBDIR).join("drift-stable");
assert!(!session_dir.join("round-2.json").exists());
assert_eq!(metadata_usize(&session, DUMP_COUNT_KEY), 0);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn shrink_is_detected_and_recorded_with_old_and_new_content() {
let dir = std::env::temp_dir().join("bamboo-prefix-drift-shrink-test");
let _ = std::fs::remove_dir_all(&dir);
let mut session = Session::new("drift-shrink", "model");
record_prefix_drift(
&mut session,
Some(&dir),
&[
section("base", "BASE"),
section("tool_guide", "long guide text"),
],
);
record_prefix_drift(
&mut session,
Some(&dir),
&[section("base", "BASE"), section("tool_guide", "short")],
);
let session_dir = dir.join(DRIFT_SUBDIR).join("drift-shrink");
let report_raw = std::fs::read_to_string(session_dir.join("round-2.json"))
.expect("drift report should exist");
let report: serde_json::Value = serde_json::from_str(&report_raw).unwrap();
assert_eq!(report["any_shrunk"], serde_json::json!(true));
assert_eq!(report["old_content_available"], serde_json::json!(true));
let sections = report["sections"].as_array().unwrap();
let guide = sections
.iter()
.find(|s| s["name"] == serde_json::json!("tool_guide"))
.unwrap();
assert_eq!(guide["shrunk"], serde_json::json!(true));
assert_eq!(guide["old_content"], serde_json::json!("long guide text"));
assert_eq!(guide["new_content"], serde_json::json!("short"));
assert!(session_dir.join("session-2.json").exists());
assert_eq!(metadata_usize(&session, DUMP_COUNT_KEY), 1);
let _ = std::fs::remove_dir_all(&dir);
}
}