mirror of
https://github.com/codewhale-hq/Codewhale.git
synced 2026-11-11 01:51:34 -10:00
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:
1 parent
fc52fde4c1
commit
c155584585
14 files changed
+466
-18
No files matched your search
+5
-2
@@ -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"));
|
||||
}
|
||||
|
||||
@@ -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, ¤t_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(
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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
@@ -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.
|
||||
Reference in new issue
Block a user