crates/core/src/prefix_cache.rs
Rama main · réplica de lectura del repositorio original.
//! Prefix-cache stability manager (inspired by Reasonix's Pillar 1).
//!
//! DeepSeek's automatic prefix caching activates only when the *exact*
//! byte prefix of a request matches the prior request. Any system-prompt
//! drift, tool-list reordering, or message-rewriting busts the cache
//! for every token after the changed byte.
//!
//! This module provides a `PrefixStabilityManager` that:
//!
//! 1. **Fingerprints** the immutable prefix (system prompt + tool specs)
//! at session start, using SHA-256 for strong collision resistance.
//! 2. **Verifies** the current prefix against the pinned fingerprint before
//! every request.
//! 3. **Attributes** every change: a header change the engine declared
//! (`/model`, `/mode`, goal edits, MCP or deferred-tool activation,
//! session sync) re-pins under a logged reason; an undeclared change is
//! *drift* — it is recorded and reported, and the original pin stays so
//! later checks keep counting the miss instead of quietly adopting it.
//! 4. **Emits events** so the TUI can surface stability to the user.
//!
//! The invariant this guards: after session start, system and tools are
//! frozen bytes; history only grows; a miss is allowed only when we log why.
//!
//! ## Three-region model (from Reasonix)
//!
//! ```text
//! ┌─────────────────────────────────────────┐
//! │ IMMUTABLE PREFIX │ ← fixed for session
//! │ system + tool_specs │ cache hit candidate
//! ├─────────────────────────────────────────┤
//! │ APPEND-ONLY HISTORY │ ← grows monotonically
//! │ [assistant₁][tool₁][assistant₂]... │ preserves prefix of prior turns
//! ├─────────────────────────────────────────┤
//! │ LATEST USER TURN │ ← the only new content per request
//! └─────────────────────────────────────────┘
//! ```
use std::collections::hash_map::DefaultHasher;
use std::collections::{HashMap, VecDeque};
use std::hash::{Hash, Hasher};
use serde::{Deserialize, Serialize};
use crate::request::{SystemPrompt, Tool};
/// A snapshot of the immutable prefix's fingerprint.
///
/// Matching hashes show stable system text and the normalized OpenAI-style
/// tool catalog. They do not measure provider cache hits or fingerprint every
/// provider-specific wire transformation; request replay tests cover those.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PrefixFingerprint {
/// SHA-256 of the system prompt text.
pub system_sha256: String,
/// SHA-256 of the full tool catalog JSON (names, descriptions, schemas).
pub tools_sha256: String,
/// SHA-256 of system_sha256 ++ tools_sha256 (combined).
pub combined_sha256: String,
}
impl PrefixFingerprint {
/// Compute a fingerprint from system prompt text and tool list.
///
/// Tools are serialized to the same JSON shape the chat API receives
/// (`type`, `name`, `description`, `parameters`, `strict`) **in provider
/// wire order**, then SHA-256 hashed. Order is load-bearing: the provider
/// KV cache is order-sensitive, so sorting before hashing could read a
/// false-green while the real prefix cache misses every turn (ops C1,
/// DSH invariant). This also catches schema/description drift that
/// actually affects the API prefix, while ignoring internal-only fields
/// like `allowed_callers` (#2264).
///
/// This entry point shares a process-local [`ToolCatalogCache`] with
/// every other call, so a stable tool set (the common case after the
/// first turn of a session) avoids the per-tool JSON serialization
/// and join entirely. Callers that hold their own cache — e.g.
/// [`PrefixStabilityManager`] — should use
/// [`Self::compute_with_tool_cache`] to share *that* cache instead
/// and avoid the thread-local lookup.
#[cfg(test)]
pub fn compute(system_text: &str, tools: Option<&[Tool]>) -> Self {
let mut cache = ToolCatalogCache::new();
Self::compute_with_tool_cache(system_text, tools, &mut cache)
}
/// Compute a fingerprint while reusing a [`ToolCatalogCache`] for the
/// tool-side work. The cache holds the joined+SHA-256'd catalog
/// under a content-derived identity so the per-tool JSON serialization
/// and the join only run on the first call for a given tool set.
///
/// On a cache hit this function avoids the entire tool serialization
/// path, which can be 100+ microseconds for a 60-tool catalog.
pub fn compute_with_tool_cache(
system_text: &str,
tools: Option<&[Tool]>,
cache: &mut ToolCatalogCache,
) -> Self {
let system_sha256 = sha256_hex(system_text.as_bytes());
let tools_sha256 = match tools {
Some(tools) if !tools.is_empty() => {
// `fingerprint_for` consults the cache first; on a hit
// it returns the pre-computed hex digest directly.
cache.fingerprint_for(tools).sha256_hex
}
_ => sha256_hex(b""),
};
let combined = format!("{system_sha256}:{tools_sha256}");
let combined_sha256 = sha256_hex(combined.as_bytes());
Self {
system_sha256,
tools_sha256,
combined_sha256,
}
}
}
/// A change record describing what drifted in the prefix.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PrefixChange {
/// The old fingerprint (before the change).
pub old: PrefixFingerprint,
/// The new fingerprint (after the change).
pub new: PrefixFingerprint,
/// Whether the system prompt component changed.
pub system_changed: bool,
/// Whether the tool set component changed.
pub tools_changed: bool,
}
impl PrefixChange {
/// Returns a human-readable description of what changed.
pub fn description(&self) -> String {
let mut parts = Vec::new();
if self.system_changed {
parts.push("system prompt");
}
if self.tools_changed {
parts.push("tool set");
}
if parts.is_empty() {
return "unknown (fingerprint mismatch but no component detected)".to_string();
}
format!("prefix cache invalidated: {} changed", parts.join(" and "))
}
/// Returns a short label for TUI chip display.
pub fn label(&self) -> &'static str {
if self.system_changed && self.tools_changed {
"sys+tools"
} else if self.system_changed {
"sys"
} else if self.tools_changed {
"tools"
} else {
"prefix"
}
}
}
/// Monitors and manages prefix-cache stability across turns.
///
/// This is the core abstraction, mirroring Reasonix's `ImmutablePrefix`
/// concept but adapted to CodeWhale's existing architecture where the
/// system prompt is rebuilt each turn and tools are registered at startup.
///
/// Usage:
/// ```ignore
/// let mgr = PrefixStabilityManager::new(system_text, tools);
/// if mgr.check_and_update(system_text, tools) {
/// println!("Prefix is stable (cache-friendly)");
/// } else {
/// let change = mgr.last_change().unwrap();
/// println!("Prefix drifted: {}", change.description());
/// }
/// ```
#[derive(Debug, Clone)]
pub struct PrefixStabilityManager {
/// The pinned fingerprint from session start or last stabilization.
pinned: Option<PrefixFingerprint>,
/// The most recent fingerprint (computed during last check).
current: Option<PrefixFingerprint>,
/// The last detected change, if any.
last_change: Option<PrefixChange>,
/// Total number of prefix changes detected this session.
change_count: u64,
/// Total number of stability checks performed.
check_count: u64,
/// Why the current pin exists: `initial`, `resume`, or `change:<what>`.
pin_reason: Option<String>,
/// Bounded log of every attributed change and every undeclared drift.
history: VecDeque<PrefixHistoryEntry>,
/// Explanation of the most recent expected cache miss (a declared header
/// change, a history reset such as compaction, or undeclared drift).
last_miss_reason: Option<String>,
/// `<context_update>` snapshots appended this session (workspace drift
/// delivered as history, with the pinned header untouched).
context_update_count: u64,
/// Process-local cache for the tool-catalog JSON serialization. Avoids
/// re-running `logical_tool_fingerprint_json` + join on every `check_and_update`
/// when the tool set is unchanged (the common case once tools are
/// registered at session start).
tool_catalog_cache: ToolCatalogCache,
}
/// Maximum retained [`PrefixHistoryEntry`] records per session.
const PREFIX_HISTORY_CAP: usize = 32;
/// One attributed prefix event: a declared header change (re-pinned) or an
/// undeclared drift (pin kept).
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct PrefixHistoryEntry {
/// `change:<what>` for declared header changes, `drift:<component>` for
/// undeclared changes, `reset:<what>` for history resets.
pub reason: String,
/// Whether the pin was replaced by this event.
pub repinned: bool,
/// Combined SHA-256 before the event.
pub from_sha256: String,
/// Combined SHA-256 the request actually carried.
pub to_sha256: String,
}
/// Outcome of [`PrefixStabilityManager::check`].
#[derive(Debug, Clone)]
pub enum PrefixCheck {
/// The request prefix matches the pin byte-for-byte.
Stable,
/// The prefix changed and the engine declared why; the pin moved.
Repinned {
reason: String,
change: PrefixChange,
},
/// The prefix changed with no declared reason. The pin did NOT move.
Drift { change: PrefixChange },
}
/// Default capacity for the tool-catalog serialization cache. Sized for
/// "session + 1 or 2 forked subagent catalogs" without unbounded growth.
const TOOL_CATALOG_CACHE_CAPACITY: usize = 8;
/// Bounded LRU cache of `(tool_set_identity) -> sha256_hex`.
///
/// The cache key is a content-derived `u64` hash of the tool list (length +
/// per-tool `name` + `description` + serialized `input_schema`). On a hit,
/// `PrefixFingerprint::compute` skips the per-tool JSON serialization and
/// the join — a workload that can be 100+ microseconds for a
/// 60-tool catalog. On a miss, the work runs once and only the digest is
/// retained (#3854); the joined catalog string is ephemeral.
#[derive(Debug, Default, Clone)]
pub struct ToolCatalogCache {
by_identity: HashMap<u64, CachedCatalog>,
insertion_order: VecDeque<u64>,
capacity: usize,
}
/// One entry in [`ToolCatalogCache`]. Production only needs the pre-computed
/// SHA-256 digest of the in-order joined catalog.
#[derive(Debug, Clone)]
pub struct CachedCatalog {
/// SHA-256 hex digest of the newline-joined, in-order tool-catalog JSON.
pub sha256_hex: String,
}
impl ToolCatalogCache {
/// Create a cache with the default capacity.
#[must_use]
pub fn new() -> Self {
Self::with_capacity(TOOL_CATALOG_CACHE_CAPACITY)
}
/// Create a cache that holds at most `capacity` tool-set entries.
/// Smaller values save memory at the cost of more cache misses.
#[must_use]
pub fn with_capacity(capacity: usize) -> Self {
let cap = capacity.max(1);
Self {
by_identity: HashMap::with_capacity(cap),
insertion_order: VecDeque::with_capacity(cap),
capacity: cap,
}
}
/// Compute (or recall) the joined-and-hashed tool catalog for `tools`.
/// The cache is keyed on a content-derived `u64` identity so two `&[Tool]`
/// slices with the same payloads — in the same order — hit the same entry.
pub fn fingerprint_for(&mut self, tools: &[Tool]) -> CachedCatalog {
let identity = tool_set_identity(tools);
if let Some(cached) = self.by_identity.get(&identity) {
return cached.clone();
}
// Miss: serialize, join, hash — in wire order, never sorted. The
// provider cache is order-sensitive; a sorted fingerprint can say
// "stable" while the real prefix misses every turn (ops C1). Keep
// only the digest in the cache — the joined string is not needed on
// the hot path (#3854).
let serialized: Vec<String> = tools
.iter()
.filter_map(logical_tool_fingerprint_json)
.collect();
let joined = serialized.join("\n");
let entry = CachedCatalog {
sha256_hex: sha256_hex(joined.as_bytes()),
};
if self.by_identity.len() >= self.capacity
&& let Some(oldest) = self.insertion_order.pop_front()
{
self.by_identity.remove(&oldest);
}
self.by_identity.insert(identity, entry.clone());
self.insertion_order.push_back(identity);
entry
}
/// Drop every cached entry. Used by tool-registry mutation paths
/// (e.g. plugin hot-reload, MCP attach) when the caller cannot
/// easily prove the tool set is unchanged.
pub fn invalidate(&mut self) {
self.by_identity.clear();
self.insertion_order.clear();
}
/// Returns the number of cached entries.
#[must_use]
pub fn len(&self) -> usize {
self.by_identity.len()
}
/// Returns `true` if the cache has no entries.
#[must_use]
pub fn is_empty(&self) -> bool {
self.by_identity.is_empty()
}
/// Returns `(current_entries, capacity)` for observability. Surfaced via
/// the `/status` chip in a follow-up; tests exercise the path.
#[must_use]
pub fn stats(&self) -> (usize, usize) {
(self.len(), self.capacity)
}
}
/// Content-derived identity for a tool slice. Order-sensitive: two slices
/// with the same tools in different orders produce different identities.
/// (The downstream fingerprint itself is order-insensitive — the sort in
/// `fingerprint_for` takes care of that — but the cache key matches the
/// input order so re-registration of the same set in the same order hits.)
fn tool_set_identity(tools: &[Tool]) -> u64 {
let mut hasher = DefaultHasher::new();
tools.len().hash(&mut hasher);
for tool in tools {
tool.name.hash(&mut hasher);
tool.description.hash(&mut hasher);
// `strict` participates in `logical_tool_fingerprint_json` output (it is
// logical catalog intent), so it MUST be part of the
// identity. Omitting it lets two semantically different catalogs
// collide and serve a stale fingerprint.
tool.strict.hash(&mut hasher);
// Walk the schema JSON directly instead of materializing it as a
// String. For a 60-tool catalog this saves ~25-40 KB of allocation
// on every cache miss.
hash_json_value(&tool.input_schema, &mut hasher);
}
hasher.finish()
}
/// Fold a `serde_json::Value` into the hasher without allocating a
/// `String`. Numeric variants are hashed via their bit pattern so `1` and
/// `1.0` produce distinct identities (matching the JSON spec).
fn hash_json_value<H: Hasher>(value: &serde_json::Value, state: &mut H) {
match value {
serde_json::Value::Null => 0u8.hash(state),
serde_json::Value::Bool(b) => {
1u8.hash(state);
b.hash(state);
}
serde_json::Value::Number(n) => {
2u8.hash(state);
if let Some(i) = n.as_i64() {
i.hash(state);
} else if let Some(u) = n.as_u64() {
u.hash(state);
} else if let Some(f) = n.as_f64() {
f.to_bits().hash(state);
}
}
serde_json::Value::String(s) => {
3u8.hash(state);
s.hash(state);
}
serde_json::Value::Array(arr) => {
4u8.hash(state);
arr.len().hash(state);
for v in arr {
hash_json_value(v, state);
}
}
serde_json::Value::Object(obj) => {
5u8.hash(state);
obj.len().hash(state);
// Iterate by sorted key so `{"a":1,"b":2}` and `{"b":2,"a":1}`
// collide — the wire format already canonicalizes via the
// `serde_json` Map ordering, but a defensively-sorted view
// future-proofs against schema serializers that emit
// declaration order.
let mut entries: Vec<(&String, &serde_json::Value)> = obj.iter().collect();
entries.sort_by(|a, b| a.0.cmp(b.0));
for (k, v) in entries {
k.hash(state);
hash_json_value(v, state);
}
}
}
}
/// Process-local fallback cache used by `PrefixFingerprint::compute`
/// (when available). Callers that maintain their own cache (e.g.
/// [`PrefixStabilityManager`]) should prefer
/// [`PrefixFingerprint::compute_with_tool_cache`] and pass the cache in
/// directly, both to share state and to avoid the thread-local lookup
/// on the hot path.
impl PrefixStabilityManager {
/// Create a new manager and immediately pin the first fingerprint.
pub fn new(system_text: &str, tools: Option<&[Tool]>) -> Self {
let mut cache = ToolCatalogCache::new();
let fp = PrefixFingerprint::compute_with_tool_cache(system_text, tools, &mut cache);
Self {
pinned: Some(fp.clone()),
current: Some(fp),
last_change: None,
change_count: 0,
check_count: 0,
pin_reason: Some("initial".to_string()),
history: VecDeque::new(),
last_miss_reason: None,
context_update_count: 0,
tool_catalog_cache: cache,
}
}
/// Create a manager in "unpinned" state — no initial fingerprint.
/// Call `pin()` or `check_and_update()` to establish the baseline.
pub fn new_unpinned() -> Self {
Self {
pinned: None,
current: None,
last_change: None,
change_count: 0,
check_count: 0,
pin_reason: None,
history: VecDeque::new(),
last_miss_reason: None,
context_update_count: 0,
tool_catalog_cache: ToolCatalogCache::new(),
}
}
/// Explicitly pin a fingerprint, replacing any prior pinned state.
/// Returns `true` if this is the first pin, or `false` if replacing.
/// Note: does NOT increment `check_count` — that counter is reserved
/// for `check_and_update` calls so `stability_ratio()` stays accurate.
pub fn pin(&mut self, system_text: &str, tools: Option<&[Tool]>) -> bool {
self.pin_with_reason(system_text, tools, "initial")
}
/// Pin under an explicit reason (`initial`, `resume`, `change:<what>`).
pub fn pin_with_reason(
&mut self,
system_text: &str,
tools: Option<&[Tool]>,
reason: &str,
) -> bool {
let fp = PrefixFingerprint::compute_with_tool_cache(
system_text,
tools,
&mut self.tool_catalog_cache,
);
let was_unpinned = self.pinned.is_none();
self.pinned = Some(fp.clone());
self.current = Some(fp);
self.pin_reason = Some(reason.to_string());
was_unpinned
}
/// Record an expected miss that is not a header change (compaction,
/// `/clear`, an edited turn). The pin is untouched; the reason is kept so
/// `/cache stats` can explain the next low hit-rate turn.
pub fn note_history_reset(&mut self, what: &str) {
let hash = self
.pinned
.as_ref()
.map(|fp| fp.combined_sha256.clone())
.unwrap_or_default();
self.push_history(PrefixHistoryEntry {
reason: format!("reset:{what}"),
repinned: false,
from_sha256: hash.clone(),
to_sha256: hash,
});
self.last_miss_reason = Some(format!("reset:{what}"));
}
/// Record that workspace drift was delivered as a `<context_update>`
/// history append. Not a miss: the pin and the prefix are unchanged.
pub fn note_context_update(&mut self) {
self.context_update_count = self.context_update_count.saturating_add(1);
let hash = self
.pinned
.as_ref()
.map(|fp| fp.combined_sha256.clone())
.unwrap_or_default();
self.push_history(PrefixHistoryEntry {
reason: "context_update".to_string(),
repinned: false,
from_sha256: hash.clone(),
to_sha256: hash,
});
}
/// Number of `<context_update>` snapshots appended this session.
pub fn context_update_count(&self) -> u64 {
self.context_update_count
}
fn push_history(&mut self, entry: PrefixHistoryEntry) {
if self.history.len() >= PREFIX_HISTORY_CAP {
self.history.pop_front();
}
self.history.push_back(entry);
}
/// Verify the request prefix against the pin, attributing any change.
///
/// `declared_change` names a header change the engine performed on
/// purpose (`model`, `mode`, `goal`, `tools:+web_fetch`, `mcp`, …). When
/// the prefix changed and a reason is declared, the pin moves and the
/// change is logged as `change:<reason>`. When it changed with no
/// declared reason, that is drift: it is logged as `drift:<component>`
/// and the pin stays put, so the same undeclared prefix keeps counting as
/// a miss instead of becoming the new baseline.
pub fn check(
&mut self,
system_text: &str,
tools: Option<&[Tool]>,
declared_change: Option<&str>,
) -> PrefixCheck {
let fp = PrefixFingerprint::compute_with_tool_cache(
system_text,
tools,
&mut self.tool_catalog_cache,
);
let old_fp = self.current.replace(fp.clone());
self.check_count += 1;
let pinned = match &self.pinned {
Some(p) => p.clone(),
None => {
self.pinned = Some(fp);
self.pin_reason = Some(declared_change.unwrap_or("initial").to_string());
self.last_change = None;
return PrefixCheck::Stable;
}
};
if fp.combined_sha256 == pinned.combined_sha256 {
return PrefixCheck::Stable;
}
let old = old_fp.unwrap_or_else(|| pinned.clone());
let system_changed = fp.system_sha256 != pinned.system_sha256;
let tools_changed = fp.tools_sha256 != pinned.tools_sha256;
let change = PrefixChange {
old,
new: fp.clone(),
system_changed,
tools_changed,
};
self.last_change = Some(change.clone());
self.change_count += 1;
match declared_change {
Some(reason) => {
let reason = format!("change:{reason}");
self.push_history(PrefixHistoryEntry {
reason: reason.clone(),
repinned: true,
from_sha256: pinned.combined_sha256.clone(),
to_sha256: fp.combined_sha256.clone(),
});
self.last_miss_reason = Some(reason.clone());
self.pinned = Some(fp);
self.pin_reason = Some(reason.clone());
PrefixCheck::Repinned { reason, change }
}
None => {
let reason = format!("drift:{}", change.label());
self.push_history(PrefixHistoryEntry {
reason: reason.clone(),
repinned: false,
from_sha256: pinned.combined_sha256.clone(),
to_sha256: fp.combined_sha256.clone(),
});
self.last_miss_reason = Some(reason);
PrefixCheck::Drift { change }
}
}
}
/// Why the current pin exists.
pub fn pin_reason(&self) -> Option<&str> {
self.pin_reason.as_deref()
}
/// Attributed change/drift/reset history, oldest first.
pub fn history(&self) -> impl Iterator<Item = &PrefixHistoryEntry> {
self.history.iter()
}
/// Explanation of the most recent expected miss, if any.
pub fn last_miss_reason(&self) -> Option<&str> {
self.last_miss_reason.as_deref()
}
/// Check whether the current prefix matches the pinned fingerprint
/// without a declared header change.
///
/// - `Ok(true)` when the prefix is stable (or this was the first pin).
/// - `Err(change)` when the prefix drifted. The pin is **kept**: an
/// undeclared change never becomes the new baseline. Use [`Self::check`]
/// with a declared reason to move the pin on purpose.
pub fn check_and_update(
&mut self,
system_text: &str,
tools: Option<&[Tool]>,
) -> Result<bool, Box<PrefixChange>> {
match self.check(system_text, tools, None) {
PrefixCheck::Stable => Ok(true),
PrefixCheck::Repinned { change, .. } | PrefixCheck::Drift { change } => {
Err(Box::new(change))
}
}
}
/// Returns the most recent prefix change, if any.
pub fn last_change(&self) -> Option<&PrefixChange> {
self.last_change.as_ref()
}
/// Returns the pinned fingerprint.
pub fn pinned_fingerprint(&self) -> Option<&PrefixFingerprint> {
self.pinned.as_ref()
}
/// Returns the current (most recently computed) fingerprint.
pub fn current_fingerprint(&self) -> Option<&PrefixFingerprint> {
self.current.as_ref()
}
/// Returns the total number of prefix changes detected.
pub fn change_count(&self) -> u64 {
self.change_count
}
/// Returns the total number of stability checks performed.
pub fn check_count(&self) -> u64 {
self.check_count
}
/// Returns the prefix stability rate as a fraction (0.0 – 1.0).
/// 1.0 means the prefix has never changed. Returns 1.0 when no
/// checks have been performed (to avoid division by zero).
pub fn stability_ratio(&self) -> f64 {
if self.check_count == 0 {
1.0
} else {
let stable_checks = self.check_count - self.change_count;
stable_checks as f64 / self.check_count as f64
}
}
/// Returns a human-readable stability summary.
pub fn summary(&self) -> String {
let pct = self.stability_ratio() * 100.0;
let pinned_short = self
.pinned
.as_ref()
.map(|fp| {
if fp.combined_sha256.len() >= 12 {
&fp.combined_sha256[..12]
} else {
&fp.combined_sha256
}
})
.unwrap_or("none");
format!(
"Prefix stability: {pct:.1}% ({stable}/{total} checks stable) | fingerprint: {pinned_short} | changes: {changes}",
pct = pct,
stable = self.check_count.saturating_sub(self.change_count),
total = self.check_count,
pinned_short = pinned_short,
changes = self.change_count,
)
}
}
/// Serialize the logical tool fields used by this stability fingerprint.
/// Provider wire projection and omission are measured by the prepared request.
fn logical_tool_fingerprint_json(tool: &Tool) -> Option<String> {
let mut value = serde_json::json!({
"type": "function",
"function": {
"name": tool.name,
"description": tool.description,
"parameters": tool.input_schema,
}
});
if let Some(strict) = tool.strict
&& let Some(function) = value.get_mut("function")
{
function["strict"] = serde_json::json!(strict);
}
serde_json::to_string(&value).ok()
}
/// Compute the SHA-256 hex digest of a byte slice.
fn sha256_hex(bytes: &[u8]) -> String {
use sha2::{Digest, Sha256};
use std::fmt::Write;
let mut hex = String::with_capacity(64);
for byte in Sha256::digest(bytes) {
let _ = write!(&mut hex, "{byte:02x}");
}
hex
}
/// Bounded line delta between the session context the model last saw and a
/// fresh composition, rendered as a `<context_update>` user-role message.
///
/// The pinned system prompt is never rewritten; this is how workspace,
/// instruction, skills, memory, and goal drift reaches the model as a normal
/// history append. Returns `None` when the two texts are line-identical (only
/// whitespace/ordering noise), so no empty update is ever sent.
pub const CONTEXT_UPDATE_MAX_LINES: usize = 80;
pub const CONTEXT_UPDATE_MAX_BYTES: usize = 6_000;
/// The fixed opening every [`context_update_message`] body starts with.
///
/// Readers that must tell the engine's drift notices from a person's own
/// words recognize the envelope by it — see `is_context_update_envelope`.
pub const CONTEXT_UPDATE_HEADER: &str = concat!(
"<context_update>\n",
"Session context changed since it was pinned; the pinned system ",
"prompt is unchanged. Delta (+ added, - removed):\n",
);
/// The fixed closing every [`context_update_message`] body ends with.
pub const CONTEXT_UPDATE_CLOSE: &str = "</context_update>";
/// True when `text` is one whole `<context_update>` envelope exactly as
/// [`context_update_message`] renders it: the engine's fixed header, then the
/// delta, then the fixed close, and nothing of a person's own around it.
///
/// The drift notice is appended to the model-visible history by the engine,
/// between turn records, so no reconstruction from records can reproduce it —
/// history alignments must skip it rather than read it as a user prompt. A
/// person quoting or asking about a notice carries their own words outside the
/// envelope, which fails either end of this check and keeps their turn whole.
pub fn is_context_update_envelope(text: &str) -> bool {
text.starts_with(CONTEXT_UPDATE_HEADER) && text.ends_with(CONTEXT_UPDATE_CLOSE)
}
pub fn context_update_message(known: &str, current: &str) -> Option<String> {
use std::collections::HashMap;
let mut counts: HashMap<&str, i64> = HashMap::new();
for line in known.lines() {
*counts.entry(line).or_insert(0) -= 1;
}
for line in current.lines() {
*counts.entry(line).or_insert(0) += 1;
}
let mut added: Vec<&str> = Vec::new();
for line in current.lines() {
if let Some(count) = counts.get_mut(line)
&& *count > 0
{
*count -= 1;
if !line.trim().is_empty() {
added.push(line);
}
}
}
let mut removed: Vec<&str> = Vec::new();
for line in known.lines() {
if let Some(count) = counts.get_mut(line)
&& *count < 0
{
*count += 1;
if !line.trim().is_empty() {
removed.push(line);
}
}
}
if added.is_empty() && removed.is_empty() {
return None;
}
let mut out = String::from(CONTEXT_UPDATE_HEADER);
let mut lines_written = 0usize;
let mut truncated = 0usize;
for (sign, group) in [("+ ", &added), ("- ", &removed)] {
for line in group {
if lines_written >= CONTEXT_UPDATE_MAX_LINES
|| out.len() + sign.len() + line.len() + 1 > CONTEXT_UPDATE_MAX_BYTES
{
truncated += 1;
continue;
}
out.push_str(sign);
out.push_str(line);
out.push('\n');
lines_written += 1;
}
}
if truncated > 0 {
out.push_str(&format!(
"(+{truncated} more changed lines; re-read the files you need)\n"
));
}
out.push_str(CONTEXT_UPDATE_CLOSE);
Some(out)
}
/// Extract the system prompt text from an optional SystemPrompt,
/// returning an owned String. This is used for prefix fingerprinting
/// and avoids lifetime/leak issues with the rare SystemPrompt::Blocks case.
pub fn system_prompt_text(system: Option<&SystemPrompt>) -> String {
match system {
Some(SystemPrompt::Text(text)) => text.clone(),
Some(SystemPrompt::Blocks(blocks)) => {
let mut text = String::new();
for block in blocks {
text.push_str(&block.text);
text.push('\n');
}
text
}
None => String::new(),
}
}
#[cfg(test)]
mod tests {
use super::*;
fn make_tool(name: &str) -> Tool {
Tool {
name: name.to_string(),
description: String::new(),
input_schema: serde_json::Value::Null,
tool_type: None,
allowed_callers: None,
defer_loading: None,
input_examples: None,
strict: None,
cache_control: None,
}
}
#[test]
fn same_prefix_produces_same_fingerprint() {
let a = PrefixFingerprint::compute("hello world", None);
let b = PrefixFingerprint::compute("hello world", None);
assert_eq!(a.combined_sha256, b.combined_sha256);
}
#[test]
fn different_system_produces_different_fingerprint() {
let a = PrefixFingerprint::compute("hello", None);
let b = PrefixFingerprint::compute("world", None);
assert_ne!(a.combined_sha256, b.combined_sha256);
}
#[test]
fn tool_order_affects_fingerprint() {
// The provider KV cache is order-sensitive; sorting before hashing
// could read a false-green while the real prefix cache missed every
// turn (ops C1).
let tools_a = vec![make_tool("read_file"), make_tool("write_file")];
let tools_b = vec![make_tool("write_file"), make_tool("read_file")];
let a = PrefixFingerprint::compute("system", Some(&tools_a));
let b = PrefixFingerprint::compute("system", Some(&tools_b));
assert_ne!(a.combined_sha256, b.combined_sha256);
}
#[test]
fn different_tools_produce_different_fingerprint() {
let tools_a = vec![make_tool("read_file")];
let tools_b = vec![make_tool("write_file")];
let a = PrefixFingerprint::compute("system", Some(&tools_a));
let b = PrefixFingerprint::compute("system", Some(&tools_b));
assert_ne!(a.combined_sha256, b.combined_sha256);
}
#[test]
fn manager_starts_stable() {
let mut mgr = PrefixStabilityManager::new("system prompt", None);
assert!(mgr.check_and_update("system prompt", None).unwrap());
assert_eq!(mgr.change_count(), 0);
assert_eq!(mgr.check_count(), 1);
}
#[test]
fn manager_detects_change() {
let mut mgr = PrefixStabilityManager::new("system prompt", None);
let result = mgr.check_and_update("different prompt", None);
assert!(result.is_err());
assert_eq!(mgr.change_count(), 1);
let change = mgr.last_change().unwrap();
assert!(change.system_changed);
assert!(!change.tools_changed);
}
#[test]
fn manager_detects_tool_change() {
let tools_a = vec![make_tool("read_file")];
let tools_b = vec![make_tool("write_file")];
let mut mgr = PrefixStabilityManager::new("system", Some(&tools_a));
let result = mgr.check_and_update("system", Some(&tools_b));
assert!(result.is_err());
let change = mgr.last_change().unwrap();
assert!(!change.system_changed);
assert!(change.tools_changed);
}
#[test]
fn undeclared_drift_never_moves_the_pin() {
let mut mgr = PrefixStabilityManager::new("old", None);
assert_eq!(mgr.pin_reason(), Some("initial"));
assert!(mgr.check_and_update("new", None).is_err());
// The pin stays on "old": the same undeclared prefix is still a miss.
assert!(mgr.check_and_update("new", None).is_err());
assert!(mgr.check_and_update("old", None).unwrap());
assert_eq!(mgr.change_count(), 2);
assert_eq!(mgr.last_miss_reason(), Some("drift:sys"));
let history: Vec<_> = mgr.history().collect();
assert_eq!(history.len(), 2);
assert!(history.iter().all(|entry| !entry.repinned));
assert_eq!(mgr.pin_reason(), Some("initial"));
}
#[test]
fn declared_header_change_repins_under_a_logged_reason() {
let mut mgr = PrefixStabilityManager::new("old", None);
match mgr.check("new", None, Some("model")) {
PrefixCheck::Repinned { reason, change } => {
assert_eq!(reason, "change:model");
assert!(change.system_changed);
}
other => panic!("expected repin, got {other:?}"),
}
assert_eq!(mgr.pin_reason(), Some("change:model"));
assert!(matches!(mgr.check("new", None, None), PrefixCheck::Stable));
assert!(matches!(
mgr.check("newer", None, None),
PrefixCheck::Drift { .. }
));
assert_eq!(mgr.pin_reason(), Some("change:model"));
let reasons: Vec<&str> = mgr.history().map(|e| e.reason.as_str()).collect();
assert_eq!(reasons, vec!["change:model", "drift:sys"]);
}
#[test]
fn declared_reason_on_a_stable_prefix_is_a_noop() {
let mut mgr = PrefixStabilityManager::new("same", None);
assert!(matches!(
mgr.check("same", None, Some("mode")),
PrefixCheck::Stable
));
assert_eq!(mgr.change_count(), 0);
assert_eq!(mgr.pin_reason(), Some("initial"));
}
#[test]
fn context_update_message_reports_added_and_removed_lines_bounded() {
let known = "## Files\nsrc/a.rs\nsrc/b.rs\n## Instructions\nbe kind\n";
let current = "## Files\nsrc/a.rs\nsrc/b.rs\nsrc/c.rs\n## Instructions\nbe precise\n";
let update = context_update_message(known, current).expect("delta");
assert!(update.starts_with("<context_update>"));
assert!(update.ends_with("</context_update>"));
assert!(update.contains("+ src/c.rs"));
assert!(update.contains("+ be precise"));
assert!(update.contains("- be kind"));
assert!(!update.contains("- src/a.rs"));
assert!(context_update_message(known, known).is_none());
// The recognizer answers for every body the builder can render, so a
// history alignment can skip the engine's notices without guessing at
// their shape, while a person's words around one stay their own.
assert!(is_context_update_envelope(&update));
let quoted = format!("what does this mean?\n{update}");
assert!(!is_context_update_envelope("ed));
let truncated = &update[..update.len() - CONTEXT_UPDATE_CLOSE.len()];
assert!(!is_context_update_envelope(truncated));
let mut big = String::new();
for i in 0..500 {
big.push_str(&format!("line {i}\n"));
}
let bounded = context_update_message("", &big).expect("delta");
assert!(
bounded.len() <= CONTEXT_UPDATE_MAX_BYTES + 128,
"{}",
bounded.len()
);
assert!(bounded.contains("more changed lines"));
}
#[test]
fn context_update_is_logged_but_is_not_a_miss() {
let mut mgr = PrefixStabilityManager::new("sys", None);
mgr.note_context_update();
assert_eq!(mgr.context_update_count(), 1);
assert_eq!(mgr.last_miss_reason(), None);
assert!(matches!(mgr.check("sys", None, None), PrefixCheck::Stable));
}
#[test]
fn history_reset_is_logged_without_moving_the_pin() {
let mut mgr = PrefixStabilityManager::new("sys", None);
mgr.note_history_reset("compaction");
assert_eq!(mgr.last_miss_reason(), Some("reset:compaction"));
assert!(matches!(mgr.check("sys", None, None), PrefixCheck::Stable));
assert_eq!(mgr.history().count(), 1);
}
#[test]
fn stability_ratio_is_one_for_no_changes() {
let mut mgr = PrefixStabilityManager::new("hello", None);
mgr.check_and_update("hello", None).unwrap();
mgr.check_and_update("hello", None).unwrap();
assert!((mgr.stability_ratio() - 1.0).abs() < f64::EPSILON);
assert_eq!(mgr.check_count(), 2);
assert_eq!(mgr.change_count(), 0);
}
#[test]
fn stability_ratio_reflects_change_rate() {
let mut mgr = PrefixStabilityManager::new("hello", None);
mgr.check_and_update("hello", None).unwrap(); // check 1: stable
let _ = mgr.check("world", None, Some("model")); // check 2: declared change
mgr.check_and_update("world", None).unwrap(); // check 3: stable
// 2 stable out of 3 checks = 0.666...
// (check_count=0 at start, so 3 checks: 3 checks - 1 change = 2 stable)
assert!((mgr.stability_ratio() - 2.0 / 3.0).abs() < 0.01);
assert_eq!(mgr.check_count(), 3);
assert_eq!(mgr.change_count(), 1);
}
#[test]
fn empty_tools_and_none_tools_produce_same_hash() {
let empty = PrefixFingerprint::compute("system", Some(&[]));
let none = PrefixFingerprint::compute("system", None);
// Both should produce sha256(b"") for the tool component
assert_eq!(empty.tools_sha256, none.tools_sha256);
}
#[test]
fn empty_system_produces_sha256_of_empty_string() {
let fp = PrefixFingerprint::compute("", None);
let expected = sha256_hex(b"");
assert_eq!(fp.system_sha256, expected);
}
#[test]
fn prefix_change_description_is_informative() {
let old = PrefixFingerprint::compute("old", None);
let new = PrefixFingerprint::compute("new", None);
let change = PrefixChange {
old,
new,
system_changed: true,
tools_changed: false,
};
assert_eq!(
change.description(),
"prefix cache invalidated: system prompt changed"
);
assert_eq!(change.label(), "sys");
}
#[test]
fn new_unpinned_has_no_change_history() {
let mut mgr = PrefixStabilityManager::new_unpinned();
assert!(mgr.pinned_fingerprint().is_none());
assert!(mgr.current_fingerprint().is_none());
assert!(mgr.last_change().is_none());
assert_eq!(mgr.change_count(), 0);
assert_eq!(mgr.check_count(), 0);
// First check should pin automatically and count as a check.
assert!(mgr.check_and_update("hello", None).unwrap());
assert!(mgr.pinned_fingerprint().is_some());
assert_eq!(mgr.check_count(), 1);
}
#[test]
fn fingerprint_detects_schema_change_not_just_name_change() {
let tool_a = make_tool("my_tool");
let mut tool_a_v2 = make_tool("my_tool");
tool_a_v2.description = "updated description".to_string();
let a = PrefixFingerprint::compute("system", Some(&[tool_a]));
let b = PrefixFingerprint::compute("system", Some(&[tool_a_v2]));
// Same name, different description — must produce different hash.
assert_ne!(a.tools_sha256, b.tools_sha256);
assert_ne!(a.combined_sha256, b.combined_sha256);
}
#[test]
fn system_prompt_text_returns_empty_for_none() {
assert_eq!(system_prompt_text(None), "");
}
// ── ToolCatalogCache tests ──────────────────────────────────
#[test]
fn tool_catalog_cache_miss_then_hit_returns_same_digest() {
let mut cache = ToolCatalogCache::new();
let tools = vec![make_tool("read_file"), make_tool("write_file")];
let first = cache.fingerprint_for(&tools);
assert_eq!(cache.len(), 1);
let second = cache.fingerprint_for(&tools);
assert_eq!(cache.len(), 1, "second call should be a cache hit");
assert_eq!(first.sha256_hex, second.sha256_hex);
}
#[test]
fn tool_catalog_cache_different_tool_sets_dont_collide() {
let mut cache = ToolCatalogCache::new();
let a = vec![make_tool("read_file")];
let b = vec![make_tool("write_file")];
let entry_a = cache.fingerprint_for(&a);
let entry_b = cache.fingerprint_for(&b);
assert_eq!(cache.len(), 2);
assert_ne!(entry_a.sha256_hex, entry_b.sha256_hex);
}
#[test]
fn tool_catalog_cache_fingerprint_is_order_sensitive_like_the_provider_cache() {
// The identity hash includes the input order so re-registering the
// same set with a different permutation produces a separate cache
// entry. The digest is now order-sensitive too: the provider KV cache
// is order-sensitive, so a sorted fingerprint read a false-green while
// the real prefix missed every turn (ops C1). Different wire order =
// different prefix = different fingerprint.
let mut cache = ToolCatalogCache::new();
let a = vec![make_tool("read_file"), make_tool("write_file")];
let b = vec![make_tool("write_file"), make_tool("read_file")];
let entry_a = cache.fingerprint_for(&a);
let entry_b = cache.fingerprint_for(&b);
assert_ne!(entry_a.sha256_hex, entry_b.sha256_hex);
assert_eq!(cache.len(), 2);
// Re-requesting the original order returns the cached digest.
let again = cache.fingerprint_for(&a);
assert_eq!(again.sha256_hex, entry_a.sha256_hex);
}
#[test]
fn tool_catalog_cache_detects_schema_change() {
let mut cache = ToolCatalogCache::new();
let tool_v1 = make_tool("t");
let mut tool_v2 = make_tool("t");
tool_v2.description = "updated".to_string();
let entry_v1 = cache.fingerprint_for(&[tool_v1]);
let entry_v2 = cache.fingerprint_for(&[tool_v2]);
assert_ne!(entry_v1.sha256_hex, entry_v2.sha256_hex);
assert_eq!(cache.len(), 2);
}
#[test]
fn tool_catalog_cache_respects_capacity() {
let mut cache = ToolCatalogCache::with_capacity(2);
cache.fingerprint_for(&[make_tool("a")]);
cache.fingerprint_for(&[make_tool("b")]);
cache.fingerprint_for(&[make_tool("c")]);
assert_eq!(cache.len(), 2);
// The first entry was evicted; a re-query for it should miss.
let re_entry = cache.fingerprint_for(&[make_tool("a")]);
// After the re-query, the cache has [b, c, a] — 3 entries? No,
// capacity 2 means oldest is evicted when we insert the 3rd unique.
// After inserting a, the cache holds the most recent 2: {c, a}.
assert_eq!(cache.len(), 2);
// The returned digest should match a fresh fingerprint of the same set.
let fresh = cache.fingerprint_for(&[make_tool("a")]);
assert_eq!(re_entry.sha256_hex, fresh.sha256_hex);
}
#[test]
fn tool_catalog_cache_invalidate_clears_all() {
let mut cache = ToolCatalogCache::new();
cache.fingerprint_for(&[make_tool("a")]);
cache.fingerprint_for(&[make_tool("b")]);
cache.invalidate();
assert!(cache.is_empty());
assert_eq!(cache.len(), 0);
}
#[test]
fn tool_catalog_cache_empty_slice_uses_zero_capacity_path() {
// Empty input is fine — should produce a stable, non-empty digest.
let mut cache = ToolCatalogCache::new();
let entry = cache.fingerprint_for(&[]);
assert!(!entry.sha256_hex.is_empty());
let again = cache.fingerprint_for(&[]);
assert_eq!(entry.sha256_hex, again.sha256_hex);
}
#[test]
fn compute_with_tool_cache_matches_compute_uncached() {
// The cached and uncached paths must produce identical fingerprints
// for the same inputs — otherwise we'd silently corrupt the prefix
// cache and invalidate every request.
let mut cache = ToolCatalogCache::new();
let tools = vec![make_tool("alpha"), make_tool("beta")];
let cached = PrefixFingerprint::compute_with_tool_cache("sys", Some(&tools), &mut cache);
let uncached = PrefixFingerprint::compute("sys", Some(&tools));
assert_eq!(cached.combined_sha256, uncached.combined_sha256);
assert_eq!(cached.tools_sha256, uncached.tools_sha256);
}
#[test]
fn manager_check_and_update_uses_cached_tool_fingerprint() {
// After the first call populates the cache, subsequent calls with
// the same tool list should not invalidate the prefix.
let tools = vec![make_tool("t1")];
let mut mgr = PrefixStabilityManager::new("sys", Some(&tools));
assert!(mgr.check_and_update("sys", Some(&tools)).is_ok());
assert!(mgr.check_and_update("sys", Some(&tools)).is_ok());
assert_eq!(mgr.change_count(), 0);
}
}