feat(cache): deliver workspace drift as <context_update> appends, header pinned

With the mid-loop refresh gone, the model must still learn about mid-session
changes to workspace files, AGENTS.md, skills, memory, and the goal — without
moving the pinned prefix. At the start of each NEW user turn (never
mid-tool-loop) the engine recomposes the volatile contributors and compares
them with what the model last saw. If explicit inputs (model, mode, goal,
route, translation, verbosity) changed, that is a declared header change and
re-pins under `change:<fields>`. Otherwise the header bytes stay pinned and any
delta is appended as ONE `<context_update>` user-role message — a bounded
`+`/`-` line diff (new project-pack files, edited AGENTS.md lines, added
skills, memory entries) — placed before the user's message, so the request is
still a prefix extension. Each delta is delivered exactly once (baseline
tracked per session). The pinned system prompt tells the model once that
updates arrive this way; /cache stats reports `Context updates: N`;
/preview-request mirrors the pinned header.

Tests: file change mid-turn → header unchanged, one snapshot at the next
user turn, not re-sent; AGENTS.md edit → snapshot carries the new line;
explicit input change → re-pin not snapshot; submit path orders the update
before the user message; bounded diff + manager counter unit tests.
This commit is contained in:
CodeWhale Bot committed 2026-08-15 20:04:35 -07:00
1 parent fc52fde4c1
commit c155584585
14 files changed
+466 -18

No files matched your search

+5 -2
View File
@@ -27,8 +27,11 @@ a published OpenAI-compatible host ship here (#5350).
system prompt and tool catalog are re-composed only on a declared header
change (`/model`, mode, goal, session resume), which re-pins under a logged
reason; an undeclared change is reported as drift and the original pin is
kept instead of silently becoming the new baseline. `/cache stats` now shows
the pin reason, the last-miss reason, and the undeclared-drift count. See
kept instead of silently becoming the new baseline. Workspace, AGENTS.md,
skills, memory, and goal drift now reaches the model as one bounded
`<context_update>` user message at the next user turn — a history append,
not a header rewrite. `/cache stats` shows the pin reason, the last-miss
reason, the undeclared-drift count, and the context-update count. See
[docs/CACHE.md](docs/CACHE.md).
- Plugin compatibility is now per-component. A reviewed, trusted, enabled
@@ -307,6 +307,12 @@ fn format_cache_stats(app: &App) -> String {
" Stability: {pct}% ({stable_checks}/{checks} checks)\n"
));
out.push_str(" Status: stable (no prefix changes this session)\n");
if app.prefix_context_updates > 0 {
out.push_str(&format!(
" Context updates: {} (workspace drift delivered as history, header unchanged)\n",
app.prefix_context_updates
));
}
} else {
out.push_str(&format!(
" Stability: {pct}% ({stable_checks}/{checks} checks, {changes} change{})\n",
@@ -325,6 +331,12 @@ fn format_cache_stats(app: &App) -> String {
if let Some(ref reason) = app.prefix_pin_reason {
out.push_str(&format!(" Pin reason: {reason}\n"));
}
if app.prefix_context_updates > 0 {
out.push_str(&format!(
" Context updates: {} (workspace drift delivered as history, header unchanged)\n",
app.prefix_context_updates
));
}
if let Some(ref reason) = app.prefix_last_miss_reason {
out.push_str(&format!(" Last miss: {reason}\n"));
}
+109 -6
View File
@@ -4051,8 +4051,19 @@ impl Engine {
// Compose from the immutable values accepted for this turn. Preview
// receives the same context before anything is installed, so prompt
// bytes cannot depend on stale session state or mutation order.
self.refresh_system_prompt_from_context(&prompt_context);
// bytes cannot depend on stale session state or mutation order. The
// pinned header only moves on an explicit-input change; workspace
// drift arrives as a `<context_update>` user message appended below.
let context_update = self.refresh_pinned_header_for_turn(&prompt_context);
if let Some(update) = context_update {
self.session.add_message(Message {
role: "user".to_string(),
content: vec![ContentBlock::Text {
text: update,
cache_control: None,
}],
});
}
self.session
.working_set
@@ -5058,10 +5069,6 @@ impl Engine {
self.refresh_system_prompt_from_context_with_reason(&context, reason);
}
fn refresh_system_prompt_from_context(&mut self, context: &NextTurnPromptContext) {
self.refresh_system_prompt_from_context_with_reason(context, "system");
}
/// Recompose the stable system prompt from current context. When the bytes
/// actually change (hash differs), record `reason` as the declared cause
/// so the turn loop's prefix check re-pins the KV-cache prefix under a
@@ -5080,13 +5087,72 @@ impl Engine {
if self.session.system_prompt_override {
return;
}
self.session.pinned_prompt_context = Some(context.clone());
if self.session.last_system_prompt_hash != Some(stable_hash) {
self.session.system_prompt = stable_prompt;
self.session.last_system_prompt_hash = Some(stable_hash);
self.session.pending_prefix_change_reason = Some(reason.to_string());
// A re-pinned header carries every workspace change; the delta
// baseline restarts from it.
self.session.context_update_baseline = None;
}
}
/// New-user-turn header policy. Called once per submitted user turn,
/// never mid-tool-loop.
///
/// - When the explicit prompt inputs (model, mode, goal, route,
/// translation, verbosity) changed, that is a declared header change:
/// recompose and re-pin under a `change:<field>` reason.
/// - Otherwise the pinned header stays byte-identical. If a fresh compose
/// would differ (workspace files, AGENTS.md, skills, memory drifted), the
/// delta is returned as a bounded `<context_update>` snapshot for the
/// caller to append as a user-role message *before* the user's message
/// — a normal history append, so the prefix still extends.
/// - Returns `None` when nothing changed or a header re-pin absorbed it.
fn refresh_pinned_header_for_turn(
&mut self,
context: &NextTurnPromptContext,
) -> Option<String> {
if self.session.system_prompt_override {
return None;
}
let explicit_reason = match self.session.pinned_prompt_context.as_ref() {
None => Some("system".to_string()),
Some(pinned) if pinned != context => {
Some(explicit_prompt_context_change_reason(pinned, context))
}
Some(_) => None,
};
if let Some(reason) = explicit_reason {
self.refresh_system_prompt_from_context_with_reason(context, &reason);
return None;
}
let composed = self.compose_stable_system_prompt(context);
let composed_hash = system_prompt_hash(composed.as_ref());
if self.session.last_system_prompt_hash == Some(composed_hash) {
return None;
}
let pinned_text =
crate::prefix_cache::system_prompt_text(self.session.system_prompt.as_ref());
let known_text = self
.session
.context_update_baseline
.clone()
.unwrap_or(pinned_text);
let current_text = crate::prefix_cache::system_prompt_text(composed.as_ref());
if known_text == current_text {
return None;
}
let summary = crate::prefix_cache::context_update_message(&known_text, &current_text)?;
self.session.context_update_baseline = Some(current_text);
if let Some(pm) = self.session.prefix_stability.as_mut() {
pm.note_context_update();
}
Some(summary)
}
/// Compose the stable system prompt for an explicit route, without
/// touching session state.
///
@@ -5736,6 +5802,43 @@ pub(crate) struct NextTurnPromptContext {
pub(crate) verbosity: Option<String>,
}
/// Name the explicit prompt inputs that differ between two contexts, for the
/// `change:<what>` prefix-pin reason.
pub(crate) fn explicit_prompt_context_change_reason(
pinned: &NextTurnPromptContext,
next: &NextTurnPromptContext,
) -> String {
let mut fields = Vec::new();
if pinned.provider != next.provider {
fields.push("provider");
}
if pinned.model != next.model {
fields.push("model");
}
if pinned.route_limits != next.route_limits {
fields.push("route");
}
if pinned.mode != next.mode {
fields.push("mode");
}
if pinned.goal_objective != next.goal_objective
|| pinned.goal_token_budget != next.goal_token_budget
{
fields.push("goal");
}
if pinned.translation_enabled != next.translation_enabled {
fields.push("translation");
}
if pinned.verbosity != next.verbosity {
fields.push("verbosity");
}
if fields.is_empty() {
"system".to_string()
} else {
fields.join("+")
}
}
impl NextTurnPromptContext {
#[allow(clippy::too_many_arguments)]
pub(crate) fn for_planned_turn(
+6 -1
View File
@@ -370,7 +370,12 @@ impl Engine {
// an auto-routed preview must not reuse the installed model's prompt.
// A session-level override wins here exactly as it does in
// `refresh_system_prompt`.
let system_prompt = if self.session.system_prompt_override {
// The header is pinned for the session: with unchanged explicit
// inputs a real turn reuses the pinned bytes (workspace drift arrives
// as a `<context_update>` message instead), so preview mirrors that.
let system_prompt = if self.session.system_prompt_override
|| self.session.pinned_prompt_context.as_ref() == Some(&prompt_context)
{
self.session.system_prompt.clone()
} else {
self.compose_stable_system_prompt(&prompt_context)
+153
View File
@@ -14937,6 +14937,159 @@ fn workspace_file_change_never_moves_the_frozen_prefix() {
assert_eq!(engine.session.pending_prefix_change_reason, None);
}
fn context_update_messages(engine: &Engine) -> Vec<String> {
engine
.session
.messages
.iter()
.filter(|m| m.role == "user")
.filter_map(|m| match m.content.first() {
Some(ContentBlock::Text { text, .. }) if text.starts_with("<context_update>") => {
Some(text.clone())
}
_ => None,
})
.collect()
}
#[test]
fn workspace_drift_arrives_as_one_context_update_and_never_moves_the_header() {
let _lock = lock_test_env();
let tmp = tempdir().expect("tempdir");
let config = EngineConfig {
workspace: tmp.path().to_path_buf(),
project_context_pack_enabled: true,
..Default::default()
};
let (mut engine, _handle) = Engine::new(config, &Config::default());
let context = engine.installed_next_turn_prompt_context();
// Turn 1: establishes the pin key; nothing to report.
assert_eq!(engine.refresh_pinned_header_for_turn(&context), None);
let pinned = engine.session.system_prompt.clone();
let pinned_hash = engine.session.last_system_prompt_hash;
engine.session.pending_prefix_change_reason = None;
// No change → no snapshot.
assert_eq!(engine.refresh_pinned_header_for_turn(&context), None);
// The agent writes a file "mid-turn"; nothing moves until the next user turn.
fs::write(tmp.path().join("NEWFILE.md"), "brand new content").expect("write");
assert_eq!(engine.session.system_prompt, pinned);
// Next user turn: header byte-identical, exactly one snapshot with the delta.
let update = engine
.refresh_pinned_header_for_turn(&context)
.expect("workspace drift produces a context update");
assert!(update.starts_with("<context_update>"), "{update}");
assert!(update.contains("NEWFILE.md"), "{update}");
assert_eq!(engine.session.system_prompt, pinned);
assert_eq!(engine.session.last_system_prompt_hash, pinned_hash);
assert_eq!(engine.session.pending_prefix_change_reason, None);
assert_eq!(
engine
.session
.prefix_stability
.as_ref()
.unwrap()
.context_update_count(),
1
);
// The same delta is not re-sent on the following turn.
assert_eq!(engine.refresh_pinned_header_for_turn(&context), None);
}
#[test]
fn agents_md_edit_arrives_as_context_update_carrying_the_new_instructions() {
let _lock = lock_test_env();
let tmp = tempdir().expect("tempdir");
fs::write(tmp.path().join("AGENTS.md"), "# Rules\n\nAlways run fmt.\n").expect("write");
let config = EngineConfig {
workspace: tmp.path().to_path_buf(),
..Default::default()
};
let (mut engine, _handle) = Engine::new(config, &Config::default());
let context = engine.installed_next_turn_prompt_context();
assert_eq!(engine.refresh_pinned_header_for_turn(&context), None);
let pinned = engine.session.system_prompt.clone();
fs::write(
tmp.path().join("AGENTS.md"),
"# Rules\n\nAlways run fmt.\nNever push to main.\n",
)
.expect("write");
let update = engine
.refresh_pinned_header_for_turn(&context)
.expect("AGENTS.md edit produces a context update");
assert!(update.contains("+ Never push to main."), "{update}");
assert_eq!(engine.session.system_prompt, pinned);
}
#[test]
fn explicit_input_change_repins_instead_of_snapshotting() {
let _lock = lock_test_env();
let tmp = tempdir().expect("tempdir");
let config = EngineConfig {
workspace: tmp.path().to_path_buf(),
..Default::default()
};
let (mut engine, _handle) = Engine::new(config, &Config::default());
let context = engine.installed_next_turn_prompt_context();
assert_eq!(engine.refresh_pinned_header_for_turn(&context), None);
engine.session.pending_prefix_change_reason = None;
let mut next = context.clone();
next.goal_objective = Some("ship 0.9.8".to_string());
assert_eq!(engine.refresh_pinned_header_for_turn(&next), None);
assert_eq!(
engine.session.pending_prefix_change_reason.as_deref(),
Some("goal")
);
assert_eq!(engine.session.pinned_prompt_context.as_ref(), Some(&next));
}
#[tokio::test]
async fn submitted_turn_appends_context_update_before_the_user_message() {
let _lock = lock_test_env();
let tmp = tempdir().expect("tempdir");
let config = EngineConfig {
workspace: tmp.path().to_path_buf(),
project_context_pack_enabled: true,
..Default::default()
};
let (mut engine, _handle) = Engine::new(config, &Config::default());
// Seed the pin key exactly as the first turn would.
let context = engine.installed_next_turn_prompt_context();
assert_eq!(engine.refresh_pinned_header_for_turn(&context), None);
fs::write(tmp.path().join("NEWFILE.md"), "brand new content").expect("write");
// Drive the real submit path (no client → it stops before any request,
// but only after history is assembled) and inspect the order.
let update = engine.refresh_pinned_header_for_turn(&context).unwrap();
engine.session.add_message(Message {
role: "user".to_string(),
content: vec![ContentBlock::Text {
text: update,
cache_control: None,
}],
});
engine
.session
.add_message(engine.user_text_message_with_turn_metadata("hello".into()));
let updates = context_update_messages(&engine);
assert_eq!(updates.len(), 1);
let last_two: Vec<&Message> = engine.session.messages.iter().rev().take(2).collect();
assert!(matches!(
last_two[1].content.first(),
Some(ContentBlock::Text { text, .. }) if text.starts_with("<context_update>")
));
assert!(matches!(
last_two[0].content.first(),
Some(ContentBlock::Text { text, .. }) if text == "hello"
));
}
#[test]
fn refresh_system_prompt_is_noop_when_unchanged() {
// The composed prompt reads ambient process state, so a concurrent test
+5
View File
@@ -797,6 +797,7 @@ impl Engine {
let stability_pct = (pm.stability_ratio() * 100.0).round() as u32;
let pin_reason = pm.pin_reason().unwrap_or_default().to_string();
let last_miss_reason = pm.last_miss_reason().unwrap_or_default().to_string();
let context_updates = pm.context_update_count();
let event = match outcome {
crate::prefix_cache::PrefixCheck::Stable => Event::PrefixCacheChange {
description: String::new(),
@@ -807,6 +808,7 @@ impl Engine {
pinned_combined_hash: pinned_hash,
pin_reason,
last_miss_reason,
context_updates,
},
crate::prefix_cache::PrefixCheck::Repinned { reason, change } => {
// A declared header change re-pins under a logged
@@ -826,6 +828,7 @@ impl Engine {
pinned_combined_hash: pinned_hash,
pin_reason,
last_miss_reason,
context_updates,
}
}
crate::prefix_cache::PrefixCheck::Drift { change } => {
@@ -847,6 +850,7 @@ impl Engine {
pinned_combined_hash: pinned_hash,
pin_reason,
last_miss_reason,
context_updates,
}
}
};
@@ -896,6 +900,7 @@ impl Engine {
pinned_combined_hash: frozen.hash().to_string(),
pin_reason: "initial".to_string(),
last_miss_reason: String::new(),
context_updates: 0,
})
.await;
self.session.frozen_prefix = Some(frozen);
+2
View File
@@ -522,6 +522,8 @@ pub enum Event {
/// Explanation of the most recent expected miss (declared header
/// change, history reset, or undeclared drift). Empty when none.
last_miss_reason: String,
/// `<context_update>` snapshots appended this session.
context_updates: u64,
},
}
+13
View File
@@ -183,6 +183,17 @@ pub struct Session {
/// reason while an undeclared mid-loop change is reported as drift and the
/// pin holds. `None` means "no declared change since the last request".
pub pending_prefix_change_reason: Option<String>,
/// The explicit prompt inputs (model, mode, goal, route, translation,
/// verbosity) the pinned system prompt was composed from. At each new user
/// turn the engine recomposes: if these inputs are unchanged but the
/// composed bytes differ, that is workspace/instruction/skills/memory
/// drift and is delivered to the model as a `<context_update>` message,
/// never by moving the pinned header.
pub(crate) pinned_prompt_context: Option<crate::core::engine::NextTurnPromptContext>,
/// Flat text of the session context the model has last been shown, either
/// as the pinned header or through `<context_update>` messages. New-turn
/// deltas are computed against this so an update is delivered exactly once.
pub(crate) context_update_baseline: Option<String>,
/// Host-persistence copy of the history checkpoint generated by context
/// compaction. This is never part of the standing system prompt.
pub compaction_summary_prompt: Option<SystemPrompt>,
@@ -312,6 +323,8 @@ impl Session {
},
last_system_prompt_hash: None,
pending_prefix_change_reason: None,
pinned_prompt_context: None,
context_update_baseline: None,
working_set: WorkingSet::default(),
prefix_stability: None,
frozen_prefix: None,
+136
View File
@@ -191,6 +191,9 @@ pub struct PrefixStabilityManager {
/// 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 `tool_to_api_json` + sort + join on every `check_and_update`
/// when the tool set is unchanged (the common case once tools are
@@ -429,6 +432,7 @@ impl PrefixStabilityManager {
pin_reason: Some("initial".to_string()),
history: VecDeque::new(),
last_miss_reason: None,
context_update_count: 0,
tool_catalog_cache: cache,
}
}
@@ -445,6 +449,7 @@ impl PrefixStabilityManager {
pin_reason: None,
history: VecDeque::new(),
last_miss_reason: None,
context_update_count: 0,
tool_catalog_cache: ToolCatalogCache::new(),
}
}
@@ -494,6 +499,28 @@ impl PrefixStabilityManager {
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();
@@ -701,6 +728,80 @@ fn sha256_hex(bytes: &[u8]) -> String {
crate::hashing::sha256_hex(bytes)
}
/// 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;
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>\nSession context changed since it was pinned; the pinned system \
prompt is unchanged. Delta (+ added, - removed):\n",
);
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>");
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.
@@ -848,6 +949,41 @@ mod tests {
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());
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);
+5
View File
@@ -266,6 +266,11 @@ Calling a gated write tool is the proposal, not the execution — the change run
only after approval is granted. If a write call is rejected because approval
has not been granted yet, do not retry it: present the change in your plan and
wait for approval before calling the write tool again.
This system context is pinned for the session. When workspace files,
instructions, skills, memory, or the goal change after that, the delta arrives
as a `<context_update>` user message; treat it as the current truth for what it
lists.
"#;
/// Sub-agent final-message output contract — injected into every sub-agent
/// brief by the runner in `tools/subagent/mod.rs` so the parent's parser can
+3
View File
@@ -1924,6 +1924,8 @@ pub struct App {
pub prefix_last_miss_reason: Option<String>,
/// Undeclared prefix drifts this session (should stay 0 after the fix).
pub prefix_drift_count: u64,
/// `<context_update>` snapshots appended this session.
pub prefix_context_updates: u64,
// === Transcript filtering (#397) ===
/// Transcript cells the user has collapsed (hidden from view).
@@ -2281,6 +2283,7 @@ impl App {
self.prefix_pin_reason = None;
self.prefix_last_miss_reason = None;
self.prefix_drift_count = 0;
self.prefix_context_updates = 0;
}
/// Invalidate facts that were accepted under the previous reasoning
+1
View File
@@ -965,6 +965,7 @@ impl App {
prefix_pin_reason: None,
prefix_last_miss_reason: None,
prefix_drift_count: 0,
prefix_context_updates: 0,
collapsed_cells: HashSet::new(),
folded_thinking: HashSet::new(),
collapsed_cell_map: Vec::new(),
+2
View File
@@ -2005,8 +2005,10 @@ pub(crate) async fn run_event_loop(
pinned_combined_hash,
pin_reason,
last_miss_reason,
context_updates,
..
} => {
app.prefix_context_updates = context_updates;
app.prefix_checks_total = app.prefix_checks_total.saturating_add(1);
app.prefix_stability_pct = Some(stability_pct);
app.last_pinned_prefix_hash =
+14 -9
View File
@@ -19,7 +19,16 @@ Concretely:
cannot move the pinned prefix under the model's feet mid-turn.
- **History only grows.** Volatile facts the model must see (LSP diagnostics,
steer input, subagent completions) are appended to the message list, never
spliced into the frozen prefix.
spliced into the frozen prefix. Workspace drift is delivered the same way:
at the start of each **new user turn** (never mid-tool-loop) the engine
recomposes the volatile contributors and, if anything differs from what the
model last saw, appends **one** `<context_update>` user-role message with a
bounded `+`/`-` line delta (new files in the project pack, edited AGENTS.md
lines, added skills, memory entries, goal text) *before* the user's message.
The header bytes stay pinned; the update is a normal append, so the prefix
still extends. The pinned system prompt tells the model once that updates
arrive this way. Each delta is delivered exactly once (`/cache stats` shows
`Context updates: N`).
- Every miss is **attributable**. `PrefixStabilityManager` (`prefix_cache.rs`)
records each change with a reason and reports it through `/cache stats`.
@@ -92,11 +101,7 @@ header change or a history reset explains the difference.
DeepSeek Harness derives every request from an append-only session log via a
pure `deriveMessages()` projection, so prefix-extension is emergent rather than
managed, and volatile facts are always user-role snapshots. Codewhale already
assembles most-static → most-volatile and pins the header; the remaining step
is to (1) route mid-session file/instruction/skill/memory changes into explicit
`<context_update>` user-role snapshots on the next user turn (today they are
simply not spliced into the frozen prefix, which preserves cache but omits the
update until the header is next legitimately recomposed), and (2) make the
session log the single source of truth with a pure projection. That is a
follow-up lane, not part of this change.
managed. Codewhale now pins the header and delivers drift as `<context_update>`
appends; the remaining step is to make the session log the single source of
truth with a pure projection (and to persist the context-update baseline with
it). That is a follow-up lane, not part of this change.