compaction: token-budgeted recent tail, older-span summary, think-tag-safe validation

Adapts the DSH/Pi compaction shape inside the existing Engine owner: prune bulky
tool output first, keep a token-priced raw tail cut on complete tool exchanges,
summarize the older span plus prior checkpoint, save original history before
commit. The summary validator now strips leaked think/reasoning spans (including
unmatched tags) before the placeholder check, so a bare closing tag no longer
passes as a summary. The lexical number/backtick checker and the duplicate
TypeScript survival validator are removed; one Rust validator remains. Turn
calibration learns only upward from reported usage and a failed automatic
summary stops the turn instead of looping.

Verification: NOT compiled or tested in this tree (uncompiled batch; build owner
is the Review peer's in-flight tui build). Local commit only, not pushed. Known
open: whole-conversation fallback when no older span exists.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
This commit is contained in:
CodeWhale BotandClaude Sonnet 5.5 committed 2026-10-29 03:09:02 -07:00
1 parent 89f70b46da
commit 2166fc24cb
12 files changed
+446 -519

No files matched your search

-1
View File
@@ -29,7 +29,6 @@ exclude = [
"src/skills/catalog_matrix.rs",
"src/compaction/survival_contract.rs",
"src/compaction/SURVIVAL_CONTRACT.md",
"src/compaction/validate_survival_contract.mjs",
"src/compaction/fixtures/**",
"src/commands/contract/config_policy_baseline.rs",
"src/commands/contract/fixtures/**",
+201 -133
View File
@@ -101,6 +101,7 @@ pub trait CompactionNoticeSink: Send + Sync + std::fmt::Debug {
#[derive(Clone)]
pub struct PreparedCompactionEnvelope {
pub config: CompactionConfig,
pub(crate) input_token_ratio: (usize, usize),
/// Durable handoff owner; set by the engine, never added to the stable prefix.
pub session_id: Option<String>,
/// Exact tool prefix of the interrupted request. Tool execution remains
@@ -122,6 +123,7 @@ impl std::fmt::Debug for PreparedCompactionEnvelope {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("PreparedCompactionEnvelope")
.field("config", &self.config)
.field("input_token_ratio", &self.input_token_ratio)
.field("session_id", &self.session_id)
.field("tools", &self.tools)
.field("reasoning_effort", &self.reasoning_effort)
@@ -136,6 +138,7 @@ impl PartialEq for PreparedCompactionEnvelope {
/// the same config are equal whether or not their host delivers notices.
fn eq(&self, other: &Self) -> bool {
self.config == other.config
&& self.input_token_ratio == other.input_token_ratio
&& self.session_id == other.session_id
&& self.tools == other.tools
&& self.reasoning_effort == other.reasoning_effort
@@ -148,6 +151,7 @@ impl PreparedCompactionEnvelope {
pub fn new(config: CompactionConfig) -> Self {
Self {
config,
input_token_ratio: (1, 1),
session_id: None,
tools: None,
reasoning_effort: None,
@@ -205,7 +209,7 @@ pub enum CompactionFailureKind {
Deterministic,
/// May resolve on retry (network, rate limit, timeout).
Transient,
/// Context overflow — drop the oldest history item and retry.
/// Context overflow — the selected summary span does not fit the route.
ContextOverflow,
}
@@ -233,57 +237,31 @@ pub(crate) const COMPACT_RETAINED_USER_MESSAGE_MAX_TOKENS: usize =
/// final user message. The headings are requested, not enforced:
/// `validate_compaction_summary` only rejects corrupt output, so a provider
/// that drifts from the layout still produces a usable note.
pub(crate) const COMPACT_PROMPT_OPENING: &str = "Write a handoff note so this session's work can \
continue after its earlier turns are condensed to make room.";
pub(crate) const COMPACT_PROMPT_OPENING: &str = "Summarize the earlier conversation so the user's \
task can continue alongside the retained recent messages.";
/// The note's layout, shared by the first request and the quality retry.
/// Each entry is a heading and what belongs under it.
const HANDOFF_SECTIONS: [(&str, &str); 10] = [
const HANDOFF_SECTIONS: [(&str, &str); 4] = [
(
"Objective",
"what the user wants now, in their words where it matters; say if the goal changed and \
what replaced it.",
"Goal and constraints",
"the user's goal, corrections, decisions and explicit permissions or limits. Preserve \
important user wording exactly; never infer permission.",
),
(
"User direction",
"corrections, preferences and decisions, newest last; quote exactly anything the user \
corrected or insisted on.",
"Progress and evidence",
"work actually observed, with the supporting tool results and exact references. \
Distinguish verified results from assistant claims and earlier summary notes.",
),
(
"Permissions and limits",
"what the user explicitly allowed (pushing, deleting, spending, contacting someone) and \
what they ruled out. Record only what the user said; never infer a permission.",
"Open work",
"unfinished requests, failures, unresolved decisions and processes that may still be \
running, with their check or stop handles when observed.",
),
(
"Done",
"finished work with evidence: commands and results, commits, test counts. Separate \
verified from assumed.",
),
(
"Changed files",
"each path and its state: edited, created or deleted; committed or not; which branch.",
),
(
"Still running",
"shell commands, servers and ports started in this session that may still be live, each \
with the command or ID that checks or stops it. Leave agents out; their status is reported \
separately.",
),
(
"Verification left",
"exact commands or checks still needed, and the last failure verbatim.",
),
(
"Open questions",
"what waits on the user or is undecided, and what it blocks.",
),
(
"Next action",
"the one next step, concrete enough to start without rereading history.",
),
(
"Reference",
"exact paths, identifiers, URLs, values and short snippets the work depends on.",
"Next steps and references",
"the next concrete actions and the exact paths, identifiers, commands and values \
needed to continue. Keep proposed actions distinct from completed work.",
),
];
@@ -293,15 +271,15 @@ finished or superseded. Keep quoted user wording exact rather than paraphrasing
copy the note's opening or closing lines or the user-pinned anchors; Codewhale adds those itself.";
const HANDOFF_CLOSING_RULE: &str = "Write about the task, not about condensing context. Be \
specific and brief. Do not call tools.";
specific and brief. Copy commands, paths, commit IDs and numeric results exactly from the \
conversation; never invent them. A claim about tests, commits or file state needs the actual \
tool result, not an earlier assistant claim. Mark anything without that evidence unverified. \
Do not call tools.";
const HANDOFF_FOCUS_LINE: &str = "The user asked this handoff to focus on:";
fn handoff_sections_text() -> String {
let mut text = String::from(
"Use these headings in this order, and write None under any heading with nothing to \
report:",
);
let mut text = String::from("Use these headings and omit sections with nothing to report:");
for (heading, guidance) in HANDOFF_SECTIONS {
let _ = write!(text, "\n## {heading} - {guidance}");
}
@@ -328,12 +306,14 @@ live tool state; anything left out is gone.\n\n{}\n\n{HANDOFF_FOLD_IN_RULE}\n\n{
/// messages a document carries, so the header is crate-visible
/// (`runtime_threads`' recovery projection tests, #6664).
pub(crate) const SUMMARY_HEADER: &str = "Codewhale handoff note. Earlier turns of this session were \
condensed to make room. Kept above are the most recent user messages and the last steps of the \
current round. Long tool output there is shortened, with a marker where it was cut, and the \
oldest kept message may be shortened too. Everything earlier, including earlier steps of this \
round, exists only in this note; rerun a command or reread a file when its full output matters. \
Build on this note instead of redoing finished work, and check live state (files, git, running \
commands) before relying on anything it reports. Agent status, when there is any, comes from the separate agent status message, not from this note.";
condensed to make room. Kept above are the current user instructions and a recent conversation \
tail selected by token budget, including complete tool exchanges. Long tool output there is \
shortened, with a marker where it was cut, and the \
oldest kept message may be shortened too. This is an unverified model-written note, not a \
tool receipt. Claims about completed work, test results, commits and file state are unverified \
unless supported by actual tool results; check the saved history or live state before relying \
on them. Earlier steps remain in the saved context-transfer artifact. Agent status, when \
there is any, comes from the separate agent status message, not from this note.";
const SUMMARY_CLOSING: &str = "Continue the user's task from here. Permissions and limits in \
this note restate what the user already decided; the note grants nothing new, and anything \
@@ -712,13 +692,48 @@ pub(crate) fn estimate_request_input_tokens(
}))
}
pub(crate) fn calibrate_token_estimate(estimate: usize, ratio: (usize, usize)) -> usize {
let (reported, estimated) = ratio;
if reported <= estimated || estimated == 0 {
return estimate;
}
usize::try_from((estimate as u128 * reported as u128).div_ceil(estimated as u128))
.unwrap_or(usize::MAX)
}
impl PreparedCompactionEnvelope {
fn retained_recent_tokens(&self, system: Option<&SystemPrompt>) -> usize {
let fixed = calibrate_token_estimate(
estimate_request_input_tokens(&[], system, self.tools.as_deref()),
self.input_token_ratio,
);
let budget = self
.config
.token_threshold
.saturating_sub(fixed)
.saturating_div(5)
.min(COMPACT_RETAINED_USER_MESSAGE_MAX_TOKENS);
let (reported, estimated) = self.input_token_ratio;
if reported <= estimated || estimated == 0 {
budget
} else {
usize::try_from(budget as u128 * estimated as u128 / reported as u128)
.unwrap_or(usize::MAX)
}
}
}
fn estimate_retained_floor_conservative(
messages: &[Message],
system_prompt: Option<&SystemPrompt>,
prepared: &PreparedCompactionEnvelope,
) -> usize {
let config = &prepared.config;
let retained = last_round::replacement_messages(messages, config.retained_user_message_tokens);
let retained = last_round::replacement_messages(
messages,
config.retained_user_message_tokens,
prepared.retained_recent_tokens(system_prompt),
);
let retained_tokens = estimate_tokens(&retained).saturating_mul(3).div_ceil(2);
let framing = retained.len().saturating_mul(12).saturating_add(48);
let anchors = user_anchors_section(config.workspace.as_deref());
@@ -730,12 +745,18 @@ fn estimate_retained_floor_conservative(
// scaffolding here; the model owns the concise summary length, just as it
// owns the answer length on an ordinary turn.
let retained_system_prompt = strip_compaction_summaries(system_prompt);
retained_tokens
let floor = retained_tokens
.saturating_add(estimate_system_tokens_conservative(
retained_system_prompt.as_ref(),
))
.saturating_add(framing)
.saturating_add(summary_scaffolding_tokens)
.saturating_add(estimate_request_input_tokens(
&[],
None,
prepared.tools.as_deref(),
));
calibrate_token_estimate(floor, prepared.input_token_ratio)
}
/// Whether the current canonical request has reached the configured automatic
@@ -858,7 +879,10 @@ pub fn compaction_decision_with_billed(
.and_then(|tokens| usize::try_from(tokens).ok())
.unwrap_or(0);
let estimated: Option<usize> = if billed < config.token_threshold {
let estimate = estimate_input_tokens_for_pressure(messages, system_prompt);
let estimate = calibrate_token_estimate(
estimate_request_input_tokens(messages, system_prompt, prepared.tools.as_deref()),
prepared.input_token_ratio,
);
if estimate.max(billed) < config.token_threshold {
return CompactionDecision::NotNeeded;
}
@@ -873,14 +897,24 @@ pub fn compaction_decision_with_billed(
// Project that outcome from the measured plan — per-block deltas use the
// estimator's own arithmetic, so this equals re-estimating a pruned copy
// without cloning a multi-megabyte transcript on every step.
let prune_plan = plan_tool_result_prunes(messages, KEEP_RECENT_MESSAGES);
let recent_tokens = prepared.retained_recent_tokens(system_prompt);
let protected_window = messages
.len()
.saturating_sub(last_round::recent_tail_start(messages, recent_tokens));
let prune_plan = plan_tool_result_prunes(messages, protected_window);
if !prune_plan.is_empty() {
let estimate = match estimated {
Some(value) => value,
None => estimate_input_tokens_for_pressure(messages, system_prompt),
None => calibrate_token_estimate(
estimate_request_input_tokens(messages, system_prompt, prepared.tools.as_deref()),
prepared.input_token_ratio,
),
};
let reclaimed_tokens: usize = prune_plan.iter().map(PlannedPrune::tokens_reclaimed).sum();
let projected = estimate.saturating_sub(reclaimed_tokens);
let projected = estimate.saturating_sub(calibrate_token_estimate(
reclaimed_tokens,
prepared.input_token_ratio,
));
if projected < config.token_threshold.saturating_mul(4) / 5 {
return CompactionDecision::Compact;
}
@@ -1385,7 +1419,17 @@ pub async fn compact_messages_safe(
}
let config = &prepared.config;
let was_over_threshold = compaction_pressure_reached(messages, system_prompt, config);
let recent_tokens = prepared.retained_recent_tokens(system_prompt);
let protected_window = messages
.len()
.saturating_sub(last_round::recent_tail_start(messages, recent_tokens));
let estimate = |messages: &[Message]| {
calibrate_token_estimate(
estimate_request_input_tokens(messages, system_prompt, prepared.tools.as_deref()),
prepared.input_token_ratio,
)
};
let was_over_threshold = estimate(messages) >= config.token_threshold;
// Leave room for useful work after a local prune. Clearing the trigger
// by a few tokens caused another prefix rewrite on the next tool result.
let prune_target = config.token_threshold.saturating_mul(4) / 5;
@@ -1394,7 +1438,7 @@ pub async fn compact_messages_safe(
let mut next_stop_check_bytes = 0usize;
let pruned_bytes = prune_tool_results_until(
&mut pruned_messages,
KEEP_RECENT_MESSAGES,
protected_window,
|candidate_messages, bytes_saved| {
if !was_over_threshold || bytes_saved < next_stop_check_bytes {
return false;
@@ -1404,17 +1448,14 @@ pub async fn compact_messages_safe(
// The check itself is a full compaction-plan pass, so bound it by saved
// bytes instead of running it after every candidate in huge sessions.
next_stop_check_bytes = bytes_saved.saturating_add(TOOL_PRUNE_STOP_CHECK_BYTES);
now_under_threshold =
estimate_input_tokens_for_pressure(candidate_messages, system_prompt)
< prune_target;
now_under_threshold = estimate(candidate_messages) < prune_target;
now_under_threshold
},
);
if was_over_threshold && pruned_bytes > 0 && !now_under_threshold {
// The throttled in-loop check may skip the exact candidate that clears the
// budget. Do one final pass so a successful local prune still avoids LLM compaction.
now_under_threshold =
estimate_input_tokens_for_pressure(&pruned_messages, system_prompt) < prune_target;
now_under_threshold = estimate(&pruned_messages) < prune_target;
}
if pruned_bytes > 0 {
@@ -1423,7 +1464,7 @@ pub async fn compact_messages_safe(
));
if was_over_threshold && now_under_threshold {
let kept = sanitize_retained_messages(pruned_messages);
last_round::validate_last_round_coverage(messages, &kept)?;
last_round::validate_retained_coverage(messages, &kept, recent_tokens)?;
let coverage = last_round::measure_coverage(
messages,
&kept,
@@ -1453,16 +1494,11 @@ pub async fn compact_messages_safe(
match compact_messages_with_metadata(
client,
// If a local prune cannot clear pressure, summarize the original
// evidence. Pruning first both erased facts the handoff needs and
// invalidated the cached history prefix for the summary request.
// The selected older span uses original evidence, including prior
// checkpoints. The recent tail survives separately in replacement.
messages,
config,
system_prompt,
prepared.tools.as_deref(),
prepared.reasoning_effort.as_deref(),
prepared.image_cap,
prepared.notice_sink.as_deref(),
prepared,
&mut quality_retries,
invocation_usage,
)
@@ -1470,12 +1506,8 @@ pub async fn compact_messages_safe(
{
Ok((msgs, prompt, mut coverage)) => {
let kept = sanitize_retained_messages(msgs);
last_round::validate_last_round_coverage(messages, &kept)?;
if config.enabled
&& compaction_pressure_reached(messages, system_prompt, config)
&& estimate_input_tokens_for_pressure(&kept, system_prompt)
>= estimate_input_tokens_for_pressure(messages, system_prompt)
{
last_round::validate_retained_coverage(messages, &kept, recent_tokens)?;
if config.enabled && was_over_threshold && estimate(&kept) >= estimate(messages) {
anyhow::bail!(
"Making room did not shrink the context; the original conversation was preserved."
);
@@ -1639,12 +1671,8 @@ async fn compact_messages(
let (messages, summary_prompt, _coverage) = compact_messages_with_metadata(
client,
messages,
config,
None,
None,
None,
None,
None,
&PreparedCompactionEnvelope::new(config.clone()),
&mut quality_retries,
&mut invocation_usage,
)
@@ -1655,12 +1683,8 @@ async fn compact_messages(
async fn compact_messages_with_metadata(
client: &dyn ModelClient,
messages: &[Message],
config: &CompactionConfig,
system_prompt: Option<&SystemPrompt>,
tools: Option<&[Tool]>,
reasoning_effort: Option<&str>,
image_cap: Option<usize>,
notice_sink: Option<&dyn CompactionNoticeSink>,
prepared: &PreparedCompactionEnvelope,
quality_retries: &mut u32,
invocation_usage: &mut Usage,
) -> Result<(Vec<Message>, Option<SystemPrompt>, CompactionCoverage)> {
@@ -1668,15 +1692,26 @@ async fn compact_messages_with_metadata(
return Ok((Vec::new(), None, CompactionCoverage::default()));
}
let config = &prepared.config;
let recent_tokens = prepared.retained_recent_tokens(system_prompt);
let summary_end = last_round::recent_tail_start(messages, recent_tokens);
let post_provenance = history_is_post_provenance(messages);
let mut summary_messages = messages
.iter()
.enumerate()
.filter(|(idx, message)| {
*idx < summary_end || is_compaction_checkpoint_message_in_era(message, post_provenance)
})
.map(|(_, message)| message.clone())
.collect::<Vec<_>>();
if summary_messages.is_empty() {
summary_messages = messages.to_vec();
}
let summary = create_summary(
client,
messages,
config,
&summary_messages,
system_prompt,
tools,
reasoning_effort,
image_cap,
notice_sink,
prepared,
quality_retries,
invocation_usage,
)
@@ -1696,6 +1731,7 @@ async fn compact_messages_with_metadata(
&checkpoint_text,
pinned_anchors_text(config.workspace.as_deref()).as_deref(),
config.retained_user_message_tokens,
recent_tokens,
)?;
let mut coverage = last_round::measure_coverage(
messages,
@@ -1785,8 +1821,9 @@ fn with_instructions_and_focus(
prompt
}
fn validate_compaction_summary(summary: &str) -> Result<()> {
let trimmed = summary.trim();
fn validate_compaction_summary(summary: &str) -> Result<String> {
let cleaned = crate::session_manager::strip_thinking_tags(summary);
let trimmed = cleaned.trim();
if trimmed.is_empty() {
anyhow::bail!("The summary for making room was unusable: no text was returned.");
}
@@ -1818,12 +1855,12 @@ fn validate_compaction_summary(summary: &str) -> Result<()> {
) {
anyhow::bail!("The summary for making room was unusable: a placeholder was returned.");
}
Ok(())
Ok(trimmed.to_string())
}
/// The summary request for one compaction pass: the parent turn's exact
/// request inputs — model, system prompt, tools, reasoning tier, and stored
/// history in order — plus one trailing user instruction. Only per-request
/// selected older history in order — plus one trailing user instruction. Only per-request
/// controls differ (output cap, streaming, tool choice), so the provider's
/// prefix cache covers the history the turn already paid for (#6540).
///
@@ -1869,18 +1906,18 @@ pub(crate) fn compaction_summary_request(
async fn create_summary(
client: &dyn ModelClient,
messages: &[Message],
config: &CompactionConfig,
system_prompt: Option<&SystemPrompt>,
tools: Option<&[Tool]>,
reasoning_effort: Option<&str>,
image_cap: Option<usize>,
notice_sink: Option<&dyn CompactionNoticeSink>,
prepared: &PreparedCompactionEnvelope,
quality_retries: &mut u32,
invocation_usage: &mut Usage,
) -> Result<String> {
// The summarization request IS the live conversation plus one final user
// message asking for the handoff summary, so the provider's prefix cache
// covers everything already sent this session.
let config = &prepared.config;
let tools = prepared.tools.as_deref();
let reasoning_effort = prepared.reasoning_effort.as_deref();
let image_cap = prepared.image_cap;
let notice_sink = prepared.notice_sink.as_deref();
// Reuse the original prefix through the selected older span. A prior
// checkpoint outside that span is folded into the next summary too.
let mut request_messages = messages.to_vec();
let stripped_images = crate::image_attach::strip_images_when_unsupported(
&mut request_messages,
@@ -1943,17 +1980,16 @@ async fn create_summary(
&cost_route.model,
client.route_limits(),
ceiling,
u64::try_from(estimate_request_input_tokens(
&request_messages,
system_prompt,
tools,
u64::try_from(calibrate_token_estimate(
estimate_request_input_tokens(&request_messages, system_prompt, tools),
prepared.input_token_ratio,
))
.unwrap_or(u64::MAX),
) {
Some(cap) if cap >= summary_floor => cap,
_ => {
anyhow::bail!(
"The complete conversation leaves too little output room in this route's context window. The conversation was preserved; switch to a model with a larger context window and retry."
"The selected history leaves too little output room in this route's context window. The conversation was preserved; switch to a model with a larger context window and retry."
);
}
};
@@ -1979,7 +2015,7 @@ async fn create_summary(
// owns it, rather than the context-window failure path.
Err(err) if !is_request_too_large_error(&err) && is_context_window_error(&err) => {
return Err(err.context(
"The provider could not summarize the complete conversation within its context window. The conversation was preserved; switch to a model with a larger context window and retry."
"The provider could not summarize the selected history within its context window. The conversation was preserved; switch to a model with a larger context window and retry."
));
}
Err(err) if is_request_too_large_error(&err) => match size_ladder {
@@ -2144,7 +2180,8 @@ async fn create_summary(
.collect::<Vec<_>>()
.join("\n");
if let Err(error) = validate_compaction_summary(&summary) {
let summary = validate_compaction_summary(&summary);
if let Err(error) = summary {
if quality_retry_used {
return Err(error.context(
"Compaction summary remained unusable after one conservative retry; \
@@ -2172,7 +2209,7 @@ no replacement checkpoint was committed",
continue;
}
return Ok(summary);
return summary;
}
}
@@ -2681,11 +2718,12 @@ mod tests {
.await
.expect_err("a partial-input summary must not replace the conversation");
assert!(error.to_string().contains("conversation was preserved"));
let requests = client.requests.lock().expect("requests");
assert_eq!(requests.len(), 1);
assert_eq!(&requests[0].messages[..original.len()], original.as_slice());
{
let requests = client.requests.lock().expect("requests");
assert_eq!(requests.len(), 1);
assert_eq!(&requests[0].messages[..original.len()], original.as_slice());
}
assert_eq!(original, before);
drop(requests);
let mut limited = ScriptedSummaryClient::new(vec![summary_content()]);
limited.limits = Some(codewhale_config::route::RouteLimits {
@@ -3419,7 +3457,7 @@ mod tests {
&retained.last().unwrap().content[0],
ContentBlock::Text { text: checkpoint, .. } if checkpoint == text
));
last_round::validate_last_round_coverage(&messages, &retained[..retained.len() - 1])
last_round::validate_retained_coverage(&messages, &retained[..retained.len() - 1], 20_000)
.unwrap();
}
@@ -3480,11 +3518,26 @@ mod tests {
assert_eq!(request.system.as_ref(), Some(&system));
assert_eq!(request.tools, prepared.tools);
assert_eq!(request.tool_choice, Some(json!("none")));
assert_eq!(
&request.messages[..original.len()],
original.as_slice(),
"summary must see the original evidence and reusable history prefix"
let tail_start = last_round::recent_tail_start(
&original,
prepared.retained_recent_tokens(Some(&system)),
);
let post_provenance = history_is_post_provenance(&original);
let summary_history = original
.iter()
.enumerate()
.filter(|(idx, message)| {
*idx < tail_start
|| is_compaction_checkpoint_message_in_era(message, post_provenance)
})
.map(|(_, message)| message.clone())
.collect::<Vec<_>>();
assert_eq!(
&request.messages[..summary_history.len()],
summary_history.as_slice(),
"summary must see the original older span and prior checkpoint"
);
assert_eq!(request.messages.len(), summary_history.len() + 1);
assert!(estimate_tokens(&result.messages) < estimate_tokens(&original) / 2);
assert_eq!(
result
@@ -3494,14 +3547,14 @@ mod tests {
.count(),
1
);
for step in [18, 19] {
let id = format!("{epoch}-{step}");
let expected = original.iter().find(|m| m.content.iter().any(|b| matches!(b, ContentBlock::ToolUse { id: found, .. } if found == &id))).unwrap();
for expected in &original[tail_start..] {
if is_compaction_checkpoint_message_in_era(expected, post_provenance) {
continue;
}
assert!(
result.messages.contains(expected),
"retained assistant text, calls and reasoning must survive unchanged"
"selected recent messages, including calls and reasoning, must survive unchanged"
);
assert!(result.messages.iter().any(|m| m.content.iter().any(|b| matches!(b, ContentBlock::ToolResult { tool_use_id, .. } if tool_use_id == &id))));
}
assert_eq!(result.messages[0], original[0]);
messages = result.messages;
@@ -3515,7 +3568,7 @@ mod tests {
msg("assistant", "session_store::roundtrip panics on reload."),
];
let gutting = vec![msg("user", "What failed?")];
let error = last_round::validate_last_round_coverage(&original, &gutting)
let error = last_round::validate_retained_coverage(&original, &gutting, 20_000)
.expect_err("dropping last-round assistant text must fail closed");
assert!(error.to_string().contains("assistant"), "{error}");
}
@@ -3675,6 +3728,10 @@ mod tests {
"N/A",
"(no summary available)",
"I cannot provide a summary.",
"</think>",
"<think>Only private reasoning.</think>",
"<THINK>Unfinished private reasoning",
"<analysis>Hidden analysis</analysis>",
] {
let error = validate_compaction_summary(summary)
.expect_err("degenerate summary must fail closed");
@@ -3686,6 +3743,17 @@ mod tests {
"目的: #4394の空要約を防止。完了: 検証と実装。制約: 履歴を変更しない。次: テスト実行。",
)
.expect("a concise multilingual handoff must not be rejected by prose length");
assert_eq!(
validate_compaction_summary(
"<think>private</think> Continue the session store migration.",
)
.unwrap(),
"Continue the session store migration."
);
validate_compaction_summary(
"Next: run `cargo test` and inspect `/new/migration.rs`; results remain unverified.",
)
.expect("planned actions must not be mistaken for fabricated evidence");
}
#[tokio::test]
@@ -3702,7 +3770,7 @@ mod tests {
];
let client = ScriptedSummaryClient::new(vec![
vec![ContentBlock::Text {
text: " \n\t ".to_string(),
text: "</think>".to_string(),
cache_control: None,
}],
vec![ContentBlock::Text {
+23 -16
View File
@@ -2,18 +2,20 @@
Schema version 2.
The protected last round means the current user instructions and the two
most recent complete tool exchanges (including their assistant output).
Short rounds remain intact. Older completed steps within a long uninterrupted
task may be summarized; otherwise a single user request could grow forever.
The protected history means the current user instructions and a recent
conversation tail selected by token budget, including assistant output.
The tail receives one fifth of the compaction threshold remaining after the
fixed system and tool prefix, capped at 20,000 estimated tokens. Provider
usage calibration applies to both that allowance and the request guard.
One complete exchange can exceed the allowance; pairing takes precedence.
Older completed steps within a long uninterrupted task may be summarized.
Tool-call batches and their results are never split, and unresolved calls
prevent a split across them. Original history is saved before any rewrite.
Language-invariant schema for session-tree journal entry types. Compaction
may summarize older turns; it must not drop the fields marked **survive**
below. The B1 strategy (Rust `codewhale-tui` compaction path) enforces this
over the live journal / API transcript. Later TS/Go strategies validate
against the same field names.
over the live journal / API transcript.
Failed compact must not replace live history. A second compact must replace
the prior summary instead of stacking it, and must not grow the cacheable
@@ -73,12 +75,13 @@ work.
Rust now:
- `last_round::build_replacement_history` keeps the bounded protected last round
(assistant + tool results) and appends one checkpoint receipt. A trailing
toolless user/assistant tail still walks back to the last tool-bearing
round; chat-only sessions keep only the latest user turn.
- `validate_last_round_coverage` refuses the rewrite if any of that round's
user texts, protected tool-call ids, tool-result ids, or assistant texts would vanish.
- `last_round::build_replacement_history` keeps the bounded selected tail,
current user instructions, older user text within its configured budget,
and one checkpoint receipt. The summary request contains the older selected
span plus previous checkpoints, so retained messages do not need to be
paraphrased and prior state is carried forward.
- `validate_retained_coverage` uses the same selection and refuses the rewrite
if its user texts, tool-call ids, tool-result ids, or assistant texts would vanish.
Every user turn in the round is checked, not just the first: the round spans
a tool-bearing turn plus the toolless tail after it, so checking one would
let the latest turn -- the one this contract exists for -- be dropped. The
@@ -103,9 +106,13 @@ Rust now:
The language-invariant fixture matrix is
`crates/tui/src/compaction/fixtures/matrix.json`. Rust loads it in
`last_round` tests. `validate_survival_contract.mjs` is the same coverage
floor for a later TypeScript strategy (`node validate_survival_contract.mjs`).
There is no Go runtime in this repository yet; a Go validator remains a
follow-up when a Go strategy exists.
`last_round` tests, with each fixture's recent token allowance. The Rust
validator is the single enforcement path.
`/context` names the compaction path and `/anchor` survival.
## Summary and budget integrity
Reasoning-only or placeholder replies cannot become checkpoints. An unusable reply gets one quality retry before the pass fails with its original history preserved. Incomplete replies and tool calls are rejected. Generated prose is explicitly unverified; retained tool receipts and live state remain the evidence for tests, commits and file state. The complete pre-compaction history is saved before a provider call. An automatic summary failure stops the turn instead of continuing from a full window or retrying the same broken summary through emergency recovery.
The parent turn learns its highest observed input-token ratio from actual request usage and applies it to subsequent request sizing, suffix growth, summary sizing and emergency recovery. Replacing history clears stale billed sizes while retaining that turn's calibration. After two compaction attempts, refilling within two model steps stops the turn with an explicit recovery message. Calibration is scoped to the current turn and cannot predict a tokenizer before a provider supplies usage. Summaries continue on the selected route; no implicit provider escalation or new spend is authorized.
+17 -17
View File
@@ -2,7 +2,7 @@
"schema_version": 2,
"cases": [
{
"id": "last-round-tools-kept",
"id": "last-round-tools-kept","recent_tokens":71,
"expect": "pass",
"last_round_start": 0,
"original": [
@@ -20,7 +20,7 @@
]
},
{
"id": "last-round-tools-dropped",
"id": "last-round-tools-dropped","recent_tokens":71,
"expect": "fail",
"last_round_start": 0,
"original": [
@@ -35,7 +35,7 @@
]
},
{
"id": "last-round-assistant-dropped",
"id": "last-round-assistant-dropped","recent_tokens":37,
"expect": "fail",
"last_round_start": 0,
"original": [
@@ -48,7 +48,7 @@
]
},
{
"id": "toolless-tail-keeps-prior-tools",
"id": "toolless-tail-keeps-prior-tools","recent_tokens":128,
"expect": "pass",
"last_round_start": 0,
"original": [
@@ -74,7 +74,7 @@
]
},
{
"id": "toolless-tail-drops-prior-tools",
"id": "toolless-tail-drops-prior-tools","recent_tokens":128,
"expect": "fail",
"last_round_start": 0,
"original": [
@@ -94,7 +94,7 @@
]
},
{
"id": "chat-only-last-round",
"id": "chat-only-last-round","recent_tokens":27,
"expect": "pass",
"last_round_start": 2,
"original": [
@@ -110,7 +110,7 @@
]
},
{
"id": "dropped-anchor",
"id": "dropped-anchor","recent_tokens":29,
"expect": "fail",
"anchors": "ship 0.9.12",
"last_round_start": 0,
@@ -125,7 +125,7 @@
]
},
{
"id": "missing-receipt",
"id": "missing-receipt","recent_tokens":29,
"expect": "fail",
"anchors": "ship 0.9.12",
"last_round_start": 0,
@@ -139,7 +139,7 @@
]
},
{
"id": "duplicate-checkpoint",
"id": "duplicate-checkpoint","recent_tokens":60,
"expect": "fail",
"last_round_start": 1,
"original": [
@@ -159,13 +159,13 @@
{"role": "user", "content": [{"type": "text", "text": "Another language model started to solve this problem: second handoff"}]}
]
},
{"id":"continuous-round-summarizes-old-exchanges","expect":"pass","last_round_start":0,"original":[{"role":"user","content":[{"type":"text","text":"Continue this task without publishing."}]},{"role":"assistant","content":[{"type":"tool_use","id":"old","name":"File","input":{"path":"old"}}]},{"role":"user","content":[{"type":"tool_result","tool_use_id":"old","content":"evidence old"}]},{"role":"user","content":[{"type":"text","text":"Another language model started to solve this problem. Old step completed; latest checks remain."}]},{"role":"assistant","content":[{"type":"tool_use","id":"new-a","name":"File","input":{"path":"new-a"}}]},{"role":"user","content":[{"type":"tool_result","tool_use_id":"new-a","content":"evidence new-a"}]},{"role":"assistant","content":[{"type":"tool_use","id":"new-b","name":"File","input":{"path":"new-b"}}]},{"role":"user","content":[{"type":"tool_result","tool_use_id":"new-b","content":"evidence new-b"}]}],"replacement":[{"role":"user","content":[{"type":"text","text":"Continue this task without publishing."}]},{"role":"assistant","content":[{"type":"tool_use","id":"new-a","name":"File","input":{"path":"new-a"}}]},{"role":"user","content":[{"type":"tool_result","tool_use_id":"new-a","content":"evidence new-a"}]},{"role":"assistant","content":[{"type":"tool_use","id":"new-b","name":"File","input":{"path":"new-b"}}]},{"role":"user","content":[{"type":"tool_result","tool_use_id":"new-b","content":"evidence new-b"}]},{"role":"user","content":[{"type":"text","text":"Another language model started to solve this problem. Old step completed; latest checks remain."}]}]},
{"id":"continuous-round-must-keep-post-checkpoint-results","expect":"fail","original":[{"role":"user","content":[{"type":"text","text":"Continue this task without publishing."}]},{"role":"assistant","content":[{"type":"tool_use","id":"old","name":"File","input":{"path":"old"}}]},{"role":"user","content":[{"type":"tool_result","tool_use_id":"old","content":"evidence old"}]},{"role":"user","content":[{"type":"text","text":"Another language model started to solve this problem. Old step completed; latest checks remain."}]},{"role":"assistant","content":[{"type":"tool_use","id":"new-a","name":"File","input":{"path":"new-a"}}]},{"role":"user","content":[{"type":"tool_result","tool_use_id":"new-a","content":"evidence new-a"}]},{"role":"assistant","content":[{"type":"tool_use","id":"new-b","name":"File","input":{"path":"new-b"}}]},{"role":"user","content":[{"type":"tool_result","tool_use_id":"new-b","content":"evidence new-b"}]}],"replacement":[{"role":"user","content":[{"type":"text","text":"Continue this task without publishing."}]},{"role":"assistant","content":[{"type":"tool_use","id":"old","name":"File","input":{"path":"old"}}]},{"role":"user","content":[{"type":"tool_result","tool_use_id":"old","content":"evidence old"}]},{"role":"user","content":[{"type":"text","text":"Another language model started to solve this problem. Old step completed; latest checks remain."}]}]},
{"id":"continuous-round-must-keep-tool-pairs","expect":"fail","original":[{"role":"user","content":[{"type":"text","text":"Continue this task without publishing."}]},{"role":"assistant","content":[{"type":"tool_use","id":"old","name":"File","input":{"path":"old"}}]},{"role":"user","content":[{"type":"tool_result","tool_use_id":"old","content":"evidence old"}]},{"role":"user","content":[{"type":"text","text":"Another language model started to solve this problem. Old step completed; latest checks remain."}]},{"role":"assistant","content":[{"type":"tool_use","id":"new-a","name":"File","input":{"path":"new-a"}}]},{"role":"user","content":[{"type":"tool_result","tool_use_id":"new-a","content":"evidence new-a"}]},{"role":"assistant","content":[{"type":"tool_use","id":"new-b","name":"File","input":{"path":"new-b"}}]},{"role":"user","content":[{"type":"tool_result","tool_use_id":"new-b","content":"evidence new-b"}]}],"replacement":[{"role":"user","content":[{"type":"text","text":"Continue this task without publishing."}]},{"role":"user","content":[{"type":"tool_result","tool_use_id":"new-a","content":"evidence new-a"}]},{"role":"assistant","content":[{"type":"tool_use","id":"new-b","name":"File","input":{"path":"new-b"}}]},{"role":"user","content":[{"type":"tool_result","tool_use_id":"new-b","content":"evidence new-b"}]},{"role":"user","content":[{"type":"text","text":"Another language model started to solve this problem. Old step completed; latest checks remain."}]}]},
{"id":"legacy-v2-checkpoint-replaced-by-handoff-note","expect":"pass","last_round_start":0,"original":[{"role":"user","content":[{"type":"text","text":"Run the failing test."}]},{"role":"assistant","content":[{"type":"tool_use","id":"live","name":"Bash","input":{"command":"cargo test"}}]},{"role":"user","content":[{"type":"tool_result","tool_use_id":"live","content":"test session_store::roundtrip ... FAILED"}]},{"role":"user","content":[{"type":"text","text":"Another language model started to solve this problem and produced a summary.\n\nfirst pass"},{"type":"text","text":"<!-- codewhale.compaction-checkpoint.v1 -->"}]}],"replacement":[{"role":"user","content":[{"type":"text","text":"Run the failing test."}]},{"role":"assistant","content":[{"type":"tool_use","id":"live","name":"Bash","input":{"command":"cargo test"}}]},{"role":"user","content":[{"type":"tool_result","tool_use_id":"live","content":"test session_store::roundtrip ... FAILED"}]},{"role":"user","content":[{"type":"text","text":"Codewhale handoff note. Earlier turns of this session were condensed to make room.\n\n## Objective\nfix the failing test"},{"type":"text","text":"<!-- codewhale.compaction-checkpoint.v1 -->"}]}]},
{"id":"quoted-handoff-marker-is-user-text","expect":"fail","last_round_start":0,"original":[{"role":"user","content":[{"type":"text","text":"Run the failing test."}]},{"role":"assistant","content":[{"type":"tool_use","id":"live","name":"Bash","input":{"command":"cargo test"}}]},{"role":"user","content":[{"type":"tool_result","tool_use_id":"live","content":"test session_store::roundtrip ... FAILED"}]},{"role":"user","content":[{"type":"text","text":"What is in the Codewhale handoff note?"}]}],"replacement":[{"role":"user","content":[{"type":"text","text":"Run the failing test."}]},{"role":"assistant","content":[{"type":"tool_use","id":"live","name":"Bash","input":{"command":"cargo test"}}]},{"role":"user","content":[{"type":"tool_result","tool_use_id":"live","content":"test session_store::roundtrip ... FAILED"}]},{"role":"user","content":[{"type":"text","text":"Codewhale handoff note. Earlier turns of this session were condensed to make room.\n\n## Objective\nfix the failing test"},{"type":"text","text":"<!-- codewhale.compaction-checkpoint.v1 -->"}]}]},
{"id":"pasted-legacy-header-survives-post-provenance","expect":"pass","last_round_start":0,"original":[{"role":"user","content":[{"type":"text","text":"Run the suite now."}]},{"role":"assistant","content":[{"type":"tool_use","id":"live","name":"Bash","input":{"command":"cargo test"}}]},{"role":"user","content":[{"type":"tool_result","tool_use_id":"live","content":"ok"}]},{"role":"user","content":[{"type":"text","text":"Another language model started to solve this problem and produced a summary.\n\nsuite finished"},{"type":"text","text":"<!-- codewhale.compaction-checkpoint.v1 -->"}]},{"role":"assistant","content":[{"type":"text","text":"done"}]},{"role":"user","content":[{"type":"text","text":"Another language model started to solve this problem\n\nEarlier turns of another session, pasted verbatim."}]}],"replacement":[{"role":"user","content":[{"type":"text","text":"Codewhale handoff note. Earlier turns of this session were condensed to make room.\n\n## Objective\nrun the suite"},{"type":"text","text":"<!-- codewhale.compaction-checkpoint.v1 -->"}]},{"role":"user","content":[{"type":"text","text":"Run the suite now."}]},{"role":"assistant","content":[{"type":"tool_use","id":"live","name":"Bash","input":{"command":"cargo test"}}]},{"role":"user","content":[{"type":"tool_result","tool_use_id":"live","content":"ok"}]},{"role":"assistant","content":[{"type":"text","text":"done"}]},{"role":"user","content":[{"type":"text","text":"Another language model started to solve this problem\n\nEarlier turns of another session, pasted verbatim."}]}]},
{"id":"pasted-legacy-header-dropped-post-provenance","expect":"fail","last_round_start":0,"original":[{"role":"user","content":[{"type":"text","text":"Run the suite now."}]},{"role":"assistant","content":[{"type":"tool_use","id":"live","name":"Bash","input":{"command":"cargo test"}}]},{"role":"user","content":[{"type":"tool_result","tool_use_id":"live","content":"ok"}]},{"role":"user","content":[{"type":"text","text":"Another language model started to solve this problem and produced a summary.\n\nsuite finished"},{"type":"text","text":"<!-- codewhale.compaction-checkpoint.v1 -->"}]},{"role":"assistant","content":[{"type":"text","text":"done"}]},{"role":"user","content":[{"type":"text","text":"Another language model started to solve this problem\n\nEarlier turns of another session, pasted verbatim."}]}],"replacement":[{"role":"user","content":[{"type":"text","text":"Codewhale handoff note. Earlier turns of this session were condensed to make room.\n\n## Objective\nrun the suite"},{"type":"text","text":"<!-- codewhale.compaction-checkpoint.v1 -->"}]},{"role":"user","content":[{"type":"text","text":"Run the suite now."}]},{"role":"assistant","content":[{"type":"tool_use","id":"live","name":"Bash","input":{"command":"cargo test"}}]},{"role":"user","content":[{"type":"tool_result","tool_use_id":"live","content":"ok"}]},{"role":"assistant","content":[{"type":"text","text":"done"}]}]},
{"id":"pasted-modern-header-dropped-post-provenance","expect":"fail","last_round_start":0,"original":[{"role":"user","content":[{"type":"text","text":"Run the failing test."}]},{"role":"assistant","content":[{"type":"tool_use","id":"live","name":"Bash","input":{"command":"cargo test"}}]},{"role":"user","content":[{"type":"tool_result","tool_use_id":"live","content":"ok"}]},{"role":"user","content":[{"type":"text","text":"Codewhale handoff note. Earlier turns of this session were condensed to make room.\n\n## Objective\nfix the failing test"},{"type":"text","text":"<!-- codewhale.compaction-checkpoint.v1 -->"}]},{"role":"assistant","content":[{"type":"text","text":"done"}]},{"role":"user","content":[{"type":"text","text":"Codewhale handoff note. Earlier turns of this session were condensed to make room.\n\n## Objective\nfix the failing test"}]}],"replacement":[{"role":"user","content":[{"type":"text","text":"Codewhale handoff note. Earlier turns of this session were condensed to make room.\n\n## Objective\nfix the failing test"},{"type":"text","text":"<!-- codewhale.compaction-checkpoint.v1 -->"}]},{"role":"user","content":[{"type":"text","text":"Run the failing test."}]},{"role":"assistant","content":[{"type":"tool_use","id":"live","name":"Bash","input":{"command":"cargo test"}}]},{"role":"user","content":[{"type":"tool_result","tool_use_id":"live","content":"ok"}]},{"role":"assistant","content":[{"type":"text","text":"done"}]}]}
{"id":"continuous-round-summarizes-old-exchanges","recent_tokens":62,"expect":"pass","last_round_start":0,"original":[{"role":"user","content":[{"type":"text","text":"Continue this task without publishing."}]},{"role":"assistant","content":[{"type":"tool_use","id":"old","name":"File","input":{"path":"old"}}]},{"role":"user","content":[{"type":"tool_result","tool_use_id":"old","content":"evidence old"}]},{"role":"user","content":[{"type":"text","text":"Another language model started to solve this problem. Old step completed; latest checks remain."}]},{"role":"assistant","content":[{"type":"tool_use","id":"new-a","name":"File","input":{"path":"new-a"}}]},{"role":"user","content":[{"type":"tool_result","tool_use_id":"new-a","content":"evidence new-a"}]},{"role":"assistant","content":[{"type":"tool_use","id":"new-b","name":"File","input":{"path":"new-b"}}]},{"role":"user","content":[{"type":"tool_result","tool_use_id":"new-b","content":"evidence new-b"}]}],"replacement":[{"role":"user","content":[{"type":"text","text":"Continue this task without publishing."}]},{"role":"assistant","content":[{"type":"tool_use","id":"new-a","name":"File","input":{"path":"new-a"}}]},{"role":"user","content":[{"type":"tool_result","tool_use_id":"new-a","content":"evidence new-a"}]},{"role":"assistant","content":[{"type":"tool_use","id":"new-b","name":"File","input":{"path":"new-b"}}]},{"role":"user","content":[{"type":"tool_result","tool_use_id":"new-b","content":"evidence new-b"}]},{"role":"user","content":[{"type":"text","text":"Another language model started to solve this problem. Old step completed; latest checks remain."}]}]},
{"id":"continuous-round-must-keep-post-checkpoint-results","recent_tokens":62,"expect":"fail","original":[{"role":"user","content":[{"type":"text","text":"Continue this task without publishing."}]},{"role":"assistant","content":[{"type":"tool_use","id":"old","name":"File","input":{"path":"old"}}]},{"role":"user","content":[{"type":"tool_result","tool_use_id":"old","content":"evidence old"}]},{"role":"user","content":[{"type":"text","text":"Another language model started to solve this problem. Old step completed; latest checks remain."}]},{"role":"assistant","content":[{"type":"tool_use","id":"new-a","name":"File","input":{"path":"new-a"}}]},{"role":"user","content":[{"type":"tool_result","tool_use_id":"new-a","content":"evidence new-a"}]},{"role":"assistant","content":[{"type":"tool_use","id":"new-b","name":"File","input":{"path":"new-b"}}]},{"role":"user","content":[{"type":"tool_result","tool_use_id":"new-b","content":"evidence new-b"}]}],"replacement":[{"role":"user","content":[{"type":"text","text":"Continue this task without publishing."}]},{"role":"assistant","content":[{"type":"tool_use","id":"old","name":"File","input":{"path":"old"}}]},{"role":"user","content":[{"type":"tool_result","tool_use_id":"old","content":"evidence old"}]},{"role":"user","content":[{"type":"text","text":"Another language model started to solve this problem. Old step completed; latest checks remain."}]}]},
{"id":"continuous-round-must-keep-tool-pairs","recent_tokens":62,"expect":"fail","original":[{"role":"user","content":[{"type":"text","text":"Continue this task without publishing."}]},{"role":"assistant","content":[{"type":"tool_use","id":"old","name":"File","input":{"path":"old"}}]},{"role":"user","content":[{"type":"tool_result","tool_use_id":"old","content":"evidence old"}]},{"role":"user","content":[{"type":"text","text":"Another language model started to solve this problem. Old step completed; latest checks remain."}]},{"role":"assistant","content":[{"type":"tool_use","id":"new-a","name":"File","input":{"path":"new-a"}}]},{"role":"user","content":[{"type":"tool_result","tool_use_id":"new-a","content":"evidence new-a"}]},{"role":"assistant","content":[{"type":"tool_use","id":"new-b","name":"File","input":{"path":"new-b"}}]},{"role":"user","content":[{"type":"tool_result","tool_use_id":"new-b","content":"evidence new-b"}]}],"replacement":[{"role":"user","content":[{"type":"text","text":"Continue this task without publishing."}]},{"role":"user","content":[{"type":"tool_result","tool_use_id":"new-a","content":"evidence new-a"}]},{"role":"assistant","content":[{"type":"tool_use","id":"new-b","name":"File","input":{"path":"new-b"}}]},{"role":"user","content":[{"type":"tool_result","tool_use_id":"new-b","content":"evidence new-b"}]},{"role":"user","content":[{"type":"text","text":"Another language model started to solve this problem. Old step completed; latest checks remain."}]}]},
{"id":"legacy-v2-checkpoint-replaced-by-handoff-note","recent_tokens":57,"expect":"pass","last_round_start":0,"original":[{"role":"user","content":[{"type":"text","text":"Run the failing test."}]},{"role":"assistant","content":[{"type":"tool_use","id":"live","name":"Bash","input":{"command":"cargo test"}}]},{"role":"user","content":[{"type":"tool_result","tool_use_id":"live","content":"test session_store::roundtrip ... FAILED"}]},{"role":"user","content":[{"type":"text","text":"Another language model started to solve this problem and produced a summary.\n\nfirst pass"},{"type":"text","text":"<!-- codewhale.compaction-checkpoint.v1 -->"}]}],"replacement":[{"role":"user","content":[{"type":"text","text":"Run the failing test."}]},{"role":"assistant","content":[{"type":"tool_use","id":"live","name":"Bash","input":{"command":"cargo test"}}]},{"role":"user","content":[{"type":"tool_result","tool_use_id":"live","content":"test session_store::roundtrip ... FAILED"}]},{"role":"user","content":[{"type":"text","text":"Codewhale handoff note. Earlier turns of this session were condensed to make room.\n\n## Objective\nfix the failing test"},{"type":"text","text":"<!-- codewhale.compaction-checkpoint.v1 -->"}]}]},
{"id":"quoted-handoff-marker-is-user-text","recent_tokens":78,"expect":"fail","last_round_start":0,"original":[{"role":"user","content":[{"type":"text","text":"Run the failing test."}]},{"role":"assistant","content":[{"type":"tool_use","id":"live","name":"Bash","input":{"command":"cargo test"}}]},{"role":"user","content":[{"type":"tool_result","tool_use_id":"live","content":"test session_store::roundtrip ... FAILED"}]},{"role":"user","content":[{"type":"text","text":"What is in the Codewhale handoff note?"}]}],"replacement":[{"role":"user","content":[{"type":"text","text":"Run the failing test."}]},{"role":"assistant","content":[{"type":"tool_use","id":"live","name":"Bash","input":{"command":"cargo test"}}]},{"role":"user","content":[{"type":"tool_result","tool_use_id":"live","content":"test session_store::roundtrip ... FAILED"}]},{"role":"user","content":[{"type":"text","text":"Codewhale handoff note. Earlier turns of this session were condensed to make room.\n\n## Objective\nfix the failing test"},{"type":"text","text":"<!-- codewhale.compaction-checkpoint.v1 -->"}]}]},
{"id":"pasted-legacy-header-survives-post-provenance","recent_tokens":59,"expect":"pass","last_round_start":0,"original":[{"role":"user","content":[{"type":"text","text":"Run the suite now."}]},{"role":"assistant","content":[{"type":"tool_use","id":"live","name":"Bash","input":{"command":"cargo test"}}]},{"role":"user","content":[{"type":"tool_result","tool_use_id":"live","content":"ok"}]},{"role":"user","content":[{"type":"text","text":"Another language model started to solve this problem and produced a summary.\n\nsuite finished"},{"type":"text","text":"<!-- codewhale.compaction-checkpoint.v1 -->"}]},{"role":"assistant","content":[{"type":"text","text":"done"}]},{"role":"user","content":[{"type":"text","text":"Another language model started to solve this problem\n\nEarlier turns of another session, pasted verbatim."}]}],"replacement":[{"role":"user","content":[{"type":"text","text":"Codewhale handoff note. Earlier turns of this session were condensed to make room.\n\n## Objective\nrun the suite"},{"type":"text","text":"<!-- codewhale.compaction-checkpoint.v1 -->"}]},{"role":"user","content":[{"type":"text","text":"Run the suite now."}]},{"role":"assistant","content":[{"type":"tool_use","id":"live","name":"Bash","input":{"command":"cargo test"}}]},{"role":"user","content":[{"type":"tool_result","tool_use_id":"live","content":"ok"}]},{"role":"assistant","content":[{"type":"text","text":"done"}]},{"role":"user","content":[{"type":"text","text":"Another language model started to solve this problem\n\nEarlier turns of another session, pasted verbatim."}]}]},
{"id":"pasted-legacy-header-dropped-post-provenance","recent_tokens":59,"expect":"fail","last_round_start":0,"original":[{"role":"user","content":[{"type":"text","text":"Run the suite now."}]},{"role":"assistant","content":[{"type":"tool_use","id":"live","name":"Bash","input":{"command":"cargo test"}}]},{"role":"user","content":[{"type":"tool_result","tool_use_id":"live","content":"ok"}]},{"role":"user","content":[{"type":"text","text":"Another language model started to solve this problem and produced a summary.\n\nsuite finished"},{"type":"text","text":"<!-- codewhale.compaction-checkpoint.v1 -->"}]},{"role":"assistant","content":[{"type":"text","text":"done"}]},{"role":"user","content":[{"type":"text","text":"Another language model started to solve this problem\n\nEarlier turns of another session, pasted verbatim."}]}],"replacement":[{"role":"user","content":[{"type":"text","text":"Codewhale handoff note. Earlier turns of this session were condensed to make room.\n\n## Objective\nrun the suite"},{"type":"text","text":"<!-- codewhale.compaction-checkpoint.v1 -->"}]},{"role":"user","content":[{"type":"text","text":"Run the suite now."}]},{"role":"assistant","content":[{"type":"tool_use","id":"live","name":"Bash","input":{"command":"cargo test"}}]},{"role":"user","content":[{"type":"tool_result","tool_use_id":"live","content":"ok"}]},{"role":"assistant","content":[{"type":"text","text":"done"}]}]},
{"id":"pasted-modern-header-dropped-post-provenance","recent_tokens":101,"expect":"fail","last_round_start":0,"original":[{"role":"user","content":[{"type":"text","text":"Run the failing test."}]},{"role":"assistant","content":[{"type":"tool_use","id":"live","name":"Bash","input":{"command":"cargo test"}}]},{"role":"user","content":[{"type":"tool_result","tool_use_id":"live","content":"ok"}]},{"role":"user","content":[{"type":"text","text":"Codewhale handoff note. Earlier turns of this session were condensed to make room.\n\n## Objective\nfix the failing test"},{"type":"text","text":"<!-- codewhale.compaction-checkpoint.v1 -->"}]},{"role":"assistant","content":[{"type":"text","text":"done"}]},{"role":"user","content":[{"type":"text","text":"Codewhale handoff note. Earlier turns of this session were condensed to make room.\n\n## Objective\nfix the failing test"}]}],"replacement":[{"role":"user","content":[{"type":"text","text":"Codewhale handoff note. Earlier turns of this session were condensed to make room.\n\n## Objective\nfix the failing test"},{"type":"text","text":"<!-- codewhale.compaction-checkpoint.v1 -->"}]},{"role":"user","content":[{"type":"text","text":"Run the failing test."}]},{"role":"assistant","content":[{"type":"tool_use","id":"live","name":"Bash","input":{"command":"cargo test"}}]},{"role":"user","content":[{"type":"tool_result","tool_use_id":"live","content":"ok"}]},{"role":"assistant","content":[{"type":"text","text":"done"}]}]}
]
}
+88 -56
View File
@@ -1,8 +1,8 @@
//! Last-round coverage floor for compaction replacement history.
//! Recent-history coverage floor for compaction replacement history.
//!
//! Compaction may summarize older turns, but the latest user round (user
//! text plus following assistant/tool results) must survive verbatim,
//! bounded, or the pass is refused. See [`SURVIVAL_CONTRACT.md`].
//! Compaction summarizes older turns while retaining a token-budgeted tail
//! and the current user instructions. Tool exchanges stay paired and retained
//! output is bounded. See [`SURVIVAL_CONTRACT.md`].
use anyhow::Result;
use std::collections::HashSet;
@@ -226,37 +226,50 @@ fn last_round_slice(messages: &[Message]) -> &[Message] {
&messages[start..end]
}
/// Retain the current user instructions and the two most recent tool
/// exchanges. A user round can contain thousands of steps: retaining that
/// entire round forever makes a continuous task impossible to compact.
/// Older completed exchanges are covered by the summary and durable history.
/// A split is legal only when all preceding tool calls have their results.
fn protected_last_round(messages: &[Message]) -> Vec<&Message> {
pub(super) fn recent_tail_start(messages: &[Message], recent_tokens: usize) -> usize {
let post_provenance = history_is_post_provenance(messages);
let round = last_round_slice(messages);
let mut tokens = 0usize;
let mut start = messages.len();
for (idx, message) in messages.iter().enumerate().rev() {
if is_compaction_checkpoint_message_in_era(message, post_provenance) {
continue;
}
tokens = tokens.saturating_add(
super::estimate_tokens(std::slice::from_ref(message)).saturating_add(12),
);
start = idx;
if tokens >= recent_tokens {
break;
}
}
let mut pending = HashSet::new();
let mut boundaries = Vec::new();
for (idx, message) in round.iter().enumerate() {
let calls = tool_use_ids(message);
if !calls.is_empty() && pending.is_empty() {
let mut boundaries = vec![0];
for (idx, message) in messages.iter().enumerate() {
if pending.is_empty() {
boundaries.push(idx);
}
pending.extend(calls);
pending.extend(tool_use_ids(message));
for id in tool_result_ids(message) {
pending.remove(&id);
}
}
let start = if boundaries.len() > 2 {
boundaries[boundaries.len() - 2]
} else {
0
};
round
boundaries
.into_iter()
.rfind(|&idx| idx <= start)
.unwrap_or(0)
}
fn protected_messages(messages: &[Message], recent_tokens: usize) -> Vec<&Message> {
let post_provenance = history_is_post_provenance(messages);
let start = recent_tail_start(messages, recent_tokens);
let round_start = last_round_start_in_era(messages, post_provenance);
messages
.iter()
.enumerate()
.filter_map(|(idx, message)| {
(!is_compaction_checkpoint_message_in_era(message, post_provenance)
&& (idx >= start || is_plain_user_text(post_provenance, message)))
&& (idx >= start
|| (idx >= round_start && is_plain_user_text(post_provenance, message))))
.then_some(message)
})
.collect()
@@ -265,15 +278,17 @@ fn protected_last_round(messages: &[Message]) -> Vec<&Message> {
pub(super) fn replacement_messages(
messages: &[Message],
retained_user_message_tokens: usize,
recent_tokens: usize,
) -> Vec<Message> {
let post_provenance = history_is_post_provenance(messages);
let (start, _) = last_round_range(messages);
let start = start.min(recent_tail_start(messages, recent_tokens));
let mut retained = retained_user_messages_in_era(
post_provenance,
&messages[..start],
retained_user_message_tokens,
);
let round = protected_last_round(messages)
let round = protected_messages(messages, recent_tokens)
.into_iter()
.cloned()
.collect::<Vec<_>>();
@@ -417,12 +432,13 @@ fn survives(text: &str, replacement: &[Message], of: impl Fn(&Message) -> Option
})
}
pub(crate) fn validate_last_round_coverage(
pub(crate) fn validate_retained_coverage(
original: &[Message],
replacement: &[Message],
recent_tokens: usize,
) -> Result<()> {
let post_provenance = history_is_post_provenance(original);
let last_round = protected_last_round(original);
let last_round = protected_messages(original, recent_tokens);
if last_round.is_empty() {
return Ok(());
}
@@ -518,8 +534,9 @@ pub(crate) fn validate_survival_contract(
original: &[Message],
replacement: &[Message],
anchors: Option<&str>,
recent_tokens: usize,
) -> Result<()> {
validate_last_round_coverage(original, replacement)?;
validate_retained_coverage(original, replacement, recent_tokens)?;
// The receipt count reads the replacement in its own era: a replacement
// carrying the engine-stamped receipt is modern, so retained turns that
// paste a bare header do not count as duplicated summaries, while a
@@ -578,7 +595,7 @@ pub(super) fn measure_coverage(
}
/// Build the post-compaction history: recent plain user messages kept
/// verbatim within `retained_user_message_tokens`, the bounded last round, and
/// verbatim within `retained_user_message_tokens`, the bounded recent tail, and
/// the checkpoint. The budget is `[compaction] retained_user_message_tokens`
/// (#5956); it was a hard-coded 20 000 before that key existed.
pub(super) fn build_replacement_history(
@@ -586,12 +603,13 @@ pub(super) fn build_replacement_history(
checkpoint_text: &str,
anchors: Option<&str>,
retained_user_message_tokens: usize,
recent_tokens: usize,
) -> Result<Vec<Message>> {
let mut retained = replacement_messages(messages, retained_user_message_tokens);
let mut retained = replacement_messages(messages, retained_user_message_tokens, recent_tokens);
retained.push(compaction_checkpoint_message(&SystemPrompt::Text(
checkpoint_text.to_string(),
)));
validate_survival_contract(messages, &retained, anchors)?;
validate_survival_contract(messages, &retained, anchors, recent_tokens)?;
Ok(retained)
}
@@ -612,7 +630,7 @@ mod tests {
{"role":"assistant","content":[{"type":"tool_use","id":"wire","execution_id":"local","name":"read","input":{}}]},
{"role":"user","content":[{"type":"tool_result","tool_use_id":"wire","execution_id":"local","content":"kept"}]}
])).unwrap();
assert!(validate_last_round_coverage(&original, &original).is_ok());
assert!(validate_retained_coverage(&original, &original, 20_000).is_ok());
for identity in [None, Some("different")] {
let mut replacement = original.clone();
for block in replacement
@@ -627,13 +645,13 @@ mod tests {
_ => {}
}
}
assert!(validate_last_round_coverage(&original, &replacement).is_err());
assert!(validate_retained_coverage(&original, &replacement, 20_000).is_err());
}
let mut replacement = original.clone();
if let ContentBlock::ToolResult { tool_use_id, .. } = &mut replacement[2].content[0] {
*tool_use_id = "wrong-wire".to_string();
}
let error = validate_last_round_coverage(&original, &replacement).unwrap_err();
let error = validate_retained_coverage(&original, &replacement, 20_000).unwrap_err();
assert_eq!(
error.to_string(),
"Making room stopped: last-round tool result execution local (provider call wire) was dropped; history was not replaced."
@@ -642,7 +660,7 @@ mod tests {
if let ContentBlock::ToolUse { id, .. } = &mut replacement[1].content[0] {
*id = "wrong-wire".to_string();
}
let error = validate_last_round_coverage(&original, &replacement).unwrap_err();
let error = validate_retained_coverage(&original, &replacement, 20_000).unwrap_err();
assert_eq!(
error.to_string(),
"Making room stopped: last-round tool call execution local (provider call wire) was dropped; history was not replaced."
@@ -790,7 +808,7 @@ mod tests {
tool_use("third", "Read", json!({"path": "third"})),
tool_result("third", "third output"),
];
let kept = replacement_messages(&original, 20_000);
let kept = replacement_messages(&original, 20_000, 20_000);
let snapshots: Vec<_> = kept
.iter()
.filter(|message| extension_prompt_contributions_display(message).is_some())
@@ -801,7 +819,7 @@ mod tests {
"a person's quote is ordinary user text"
);
assert_eq!(
replacement_messages(&kept, 20_000)
replacement_messages(&kept, 20_000, 20_000)
.iter()
.filter(|message| extension_prompt_contributions_display(message).is_some())
.count(),
@@ -839,7 +857,7 @@ mod tests {
tool_use("third", "Read", json!({"path": "third"})),
tool_result("third", "third output"),
];
let kept = replacement_messages(&original, 20_000);
let kept = replacement_messages(&original, 20_000, 20_000);
let snapshots: Vec<_> = kept
.iter()
.filter(|message| constitution_display(message).is_some())
@@ -850,7 +868,7 @@ mod tests {
"a person's quote is ordinary user text"
);
assert_eq!(
replacement_messages(&kept, 20_000)
replacement_messages(&kept, 20_000, 20_000)
.iter()
.filter(|message| constitution_display(message).is_some())
.count(),
@@ -875,7 +893,9 @@ mod tests {
tool_use("third", "Bash", json!({"command": "cargo test"})),
tool_result("third", &long_output),
];
let kept = replacement_messages(&original, 20_000);
let recent_tokens = crate::compaction::estimate_tokens(&original[3..])
+ original[3..].len() * 12;
let kept = replacement_messages(&original, 20_000, recent_tokens);
let kept_ids: Vec<&str> = kept
.iter()
.flat_map(tool_result_ids)
@@ -903,11 +923,11 @@ mod tests {
let header = crate::compaction::SUMMARY_HEADER;
assert!(
header.contains("last steps of the current round"),
header.contains("current user instructions"),
"{header}"
);
assert!(
header.contains("including earlier steps of this round"),
header.contains("tail selected by token budget"),
"{header}"
);
assert!(
@@ -933,10 +953,10 @@ mod tests {
msg("user", "Run the failing test."),
checkpoint("and kept going"),
];
let error = validate_last_round_coverage(&original, &gutting)
let error = validate_retained_coverage(&original, &gutting, 20_000)
.expect_err("dropping the last tool result must fail the coverage floor");
assert!(error.to_string().contains("tool result live"), "{error}");
assert!(validate_last_round_coverage(&original, &original).is_ok());
assert!(validate_retained_coverage(&original, &original, 20_000).is_ok());
}
#[test]
@@ -945,7 +965,7 @@ mod tests {
msg("user", "What failed?"),
msg("assistant", "session_store::roundtrip panics on reload."),
];
let error = validate_last_round_coverage(&original, &[msg("user", "What failed?")])
let error = validate_retained_coverage(&original, &[msg("user", "What failed?")], 20_000)
.expect_err("dropping last-round assistant text must fail closed");
assert!(error.to_string().contains("assistant"), "{error}");
}
@@ -973,10 +993,10 @@ mod tests {
tool_result("live", "ok"),
checkpoint("then shipped"),
];
let error = validate_last_round_coverage(&original, &drops_latest)
let error = validate_retained_coverage(&original, &drops_latest, 20_000)
.expect_err("dropping the latest user turn must fail the coverage floor");
assert!(error.to_string().contains("user message"), "{error}");
assert!(validate_last_round_coverage(&original, &original).is_ok());
assert!(validate_retained_coverage(&original, &original, 20_000).is_ok());
}
/// A surviving `tool_result` whose `tool_use` was summarized away is an
@@ -995,7 +1015,7 @@ mod tests {
tool_result("live", "FAILED"),
checkpoint("and it failed"),
];
let error = validate_last_round_coverage(&original, &orphaned)
let error = validate_retained_coverage(&original, &orphaned, 20_000)
.expect_err("dropping the tool call must fail the coverage floor");
assert!(error.to_string().contains("tool call live"), "{error}");
}
@@ -1012,7 +1032,7 @@ mod tests {
msg("user", "What failed?"),
msg("assistant", "Earlier we discussed several test failures."),
];
let error = validate_last_round_coverage(&original, &summarized)
let error = validate_retained_coverage(&original, &summarized, 20_000)
.expect_err("substituting a summary for the round's output must fail closed");
assert!(error.to_string().contains("assistant"), "{error}");
}
@@ -1021,8 +1041,9 @@ mod tests {
fn survival_contract_rejects_dropped_anchors_and_receipts() {
let original = vec![msg("user", "Keep the pin."), msg("assistant", "Anchored.")];
let without_receipt = vec![msg("user", "Keep the pin."), msg("assistant", "Anchored.")];
let error = validate_survival_contract(&original, &without_receipt, Some("ship 0.9.12"))
.expect_err("missing checkpoint receipt must fail closed");
let error =
validate_survival_contract(&original, &without_receipt, Some("ship 0.9.12"), 20_000)
.expect_err("missing checkpoint receipt must fail closed");
assert!(error.to_string().contains("receipt"), "{error}");
let without_anchor = vec![
@@ -1030,8 +1051,9 @@ mod tests {
msg("assistant", "Anchored."),
checkpoint("progress without the pin"),
];
let error = validate_survival_contract(&original, &without_anchor, Some("ship 0.9.12"))
.expect_err("dropped /anchor text must fail closed");
let error =
validate_survival_contract(&original, &without_anchor, Some("ship 0.9.12"), 20_000)
.expect_err("dropped /anchor text must fail closed");
assert!(error.to_string().contains("anchor"), "{error}");
}
@@ -1080,6 +1102,7 @@ mod tests {
&first_summary,
None,
PRODUCTION_MIN_RETAINED_TOKENS,
20_000,
)
.expect("first compaction");
crate::runtime_handoff::replace_agent_topology_checkpoint(&mut first, &[]);
@@ -1105,6 +1128,7 @@ mod tests {
&second_summary,
None,
PRODUCTION_MIN_RETAINED_TOKENS,
20_000,
)
.expect("second compaction");
assert!(
@@ -1131,7 +1155,7 @@ mod tests {
.filter(|message| user_text_of(message).as_deref() != Some(long_question.as_str()))
.cloned()
.collect::<Vec<_>>();
assert!(validate_last_round_coverage(&first, &without_question).is_err());
assert!(validate_retained_coverage(&first, &without_question, 20_000).is_err());
}
#[test]
@@ -1141,7 +1165,7 @@ mod tests {
let original = vec![msg("user", &copied_text), msg("assistant", "Acknowledged")];
let replacement = vec![runtime, msg("assistant", "Acknowledged")];
assert!(
validate_last_round_coverage(&original, &replacement).is_err(),
validate_retained_coverage(&original, &replacement, 20_000).is_err(),
"runtime-owned text must not stand in for the user's actual prompt"
);
}
@@ -1161,6 +1185,7 @@ mod tests {
&format!("{COMPACTION_SUMMARY_MARKER}: work continues"),
None,
1,
20_000,
)
.expect("compaction must retain the active Operate contract");
assert_eq!(replaced.first(), Some(&contract));
@@ -1188,6 +1213,7 @@ mod tests {
&format!("{COMPACTION_SUMMARY_MARKER}: work continues"),
None,
1,
20_000,
)
.expect("current contract must survive compaction");
assert_eq!(replaced.first(), Some(&current));
@@ -1224,6 +1250,7 @@ mod tests {
&format!("{COMPACTION_SUMMARY_MARKER}: work continues"),
None,
1,
20_000,
)
.expect("current contract must survive compaction");
assert_eq!(replaced.first(), Some(&current));
@@ -1251,6 +1278,7 @@ mod tests {
&format!("{COMPACTION_SUMMARY_MARKER}: work continues"),
None,
1,
20_000,
)
.expect("saved contract must survive compaction");
assert_eq!(replaced.first(), Some(&shipped));
@@ -1282,6 +1310,7 @@ mod tests {
&next,
None,
crate::compaction::COMPACT_RETAINED_USER_MESSAGE_MAX_TOKENS,
20_000,
)
.expect("toolless tails must not drop the last tool result");
assert!(replaced.iter().any(|message| {
@@ -1350,12 +1379,12 @@ mod tests {
msg("assistant", "done"),
msg("user", &pasted),
];
validate_survival_contract(&original, &replacement, None)
validate_survival_contract(&original, &replacement, None, 20_000)
.expect("a retained pasted-header turn must not break or lose the round");
let mut dropped = replacement.clone();
dropped.pop();
let error = validate_survival_contract(&original, &dropped, None)
let error = validate_survival_contract(&original, &dropped, None, 20_000)
.expect_err("dropping the pasted turn must fail the coverage floor");
assert!(
error.to_string().contains("user message was dropped"),
@@ -1406,6 +1435,7 @@ mod tests {
replacement: Vec<Message>,
#[serde(default)]
last_round_start: Option<usize>,
recent_tokens: usize,
}
#[test]
@@ -1430,6 +1460,7 @@ mod tests {
&case.original,
&case.replacement,
case.anchors.as_deref(),
case.recent_tokens,
);
match case.expect.as_str() {
"pass" => {
@@ -1461,6 +1492,7 @@ mod tests {
&next,
Some("ship 0.9.12"),
crate::compaction::COMPACT_RETAINED_USER_MESSAGE_MAX_TOKENS,
20_000,
)
.expect("second compact must keep last round and one receipt");
let checkpoints = replaced
+2 -1
View File
@@ -404,7 +404,7 @@ fn replacement_history_honours_the_configured_retention_budget() {
let checkpoint = format!("{COMPACTION_SUMMARY_MARKER} and produced this handoff.");
let count_verbatim = |budget: usize| {
last_round::build_replacement_history(&messages, &checkpoint, None, budget)
last_round::build_replacement_history(&messages, &checkpoint, None, budget, 128)
.expect("replacement history")
.len()
};
@@ -582,6 +582,7 @@ fn legacy_v2_checkpoint_with_provenance_is_recognised_and_replaced() {
&build_compaction_summary_block_text("second handoff", ""),
None,
COMPACT_RETAINED_USER_MESSAGE_MAX_TOKENS,
20_000,
)
.expect("replacement");
let checkpoints = replaced
@@ -1,262 +0,0 @@
#!/usr/bin/env node
// Language-invariant coverage floor for the compaction survival contract.
// Later TypeScript strategies should keep this check; Rust remains the B1
// enforcement path. Run: node validate_survival_contract.mjs
import { readFileSync } from "node:fs";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
// Mirrors compaction.rs: a new checkpoint is the handoff-note header block
// plus the provenance block; only the two legacy markers match by substring,
// because older checkpoints may lack provenance. Recognition is era-scoped:
// in a history that already carries a stamped carrier only the stamped form
// is a checkpoint, so a user turn that pastes a bare legacy header stays
// content; a history without one keeps the loose legacy form.
const MARKER = "Codewhale handoff note";
const LEGACY_V2_MARKER = "Another language model started to solve this problem";
const LEGACY_MARKERS = [LEGACY_V2_MARKER, "Conversation Summary (Auto-Generated)"];
const PROVENANCE = "<!-- codewhale.compaction-checkpoint.v1 -->";
const root = dirname(fileURLToPath(import.meta.url));
const matrix = JSON.parse(
readFileSync(join(root, "fixtures/matrix.json"), "utf8"),
);
function userTextOf(message) {
if (message.role !== "user") return null;
const text = (message.content ?? [])
.filter((block) => block.type === "text")
.map((block) => block.text)
.join("\n")
.trim();
return text || null;
}
function isWireCheckpoint(message) {
const blocks = message.content ?? [];
if (message.role !== "user" || blocks.length !== 2) return false;
const [header, provenance] = blocks;
return (
header.type === "text" &&
provenance.type === "text" &&
provenance.text === PROVENANCE &&
(header.text.startsWith(MARKER) || header.text.startsWith(LEGACY_V2_MARKER))
);
}
function historyIsPostProvenance(messages) {
return messages.some(isWireCheckpoint);
}
function isCheckpoint(message, postProvenance) {
if (postProvenance) return isWireCheckpoint(message);
if (isWireCheckpoint(message)) return true;
const text = userTextOf(message);
return Boolean(text && LEGACY_MARKERS.some((marker) => text.includes(marker)));
}
function isPlainUserText(postProvenance, message) {
return !isCheckpoint(message, postProvenance) && Boolean(userTextOf(message));
}
function lastPlainUserIndex(messages, end, postProvenance) {
for (let idx = end - 1; idx >= 0; idx -= 1) {
if (isPlainUserText(postProvenance, messages[idx])) return idx;
}
return null;
}
function sliceHasToolResult(messages, start) {
return messages.slice(start).some((message) =>
(message.content ?? []).some((block) => block.type === "tool_result"),
);
}
export function lastRoundStart(messages) {
const postProvenance = historyIsPostProvenance(messages);
const lastUser = lastPlainUserIndex(messages, messages.length, postProvenance);
if (lastUser === null) return 0;
if (sliceHasToolResult(messages, lastUser)) return lastUser;
let candidate = lastUser;
for (;;) {
const prev = lastPlainUserIndex(messages, candidate, postProvenance);
if (prev === null) return lastUser;
if (sliceHasToolResult(messages, prev)) return prev;
candidate = prev;
}
}
function toolResultIds(message) {
return (message.content ?? [])
.filter((block) => block.type === "tool_result")
.map((block) => block.tool_use_id);
}
function toolUseIds(message) {
return (message.content ?? [])
.filter((block) => block.type === "tool_use")
.map((block) => block.id);
}
function isAssistantLike(message) {
return message.role === "assistant" || message.role === "assistant_interrupted";
}
function assistantTextOf(message) {
if (!isAssistantLike(message)) return null;
const text = (message.content ?? [])
.filter((block) => block.type === "text")
.map((block) => block.text)
.join("\n")
.trim();
return text || null;
}
// A retained copy may be truncated, so a prefix either way counts as survival
// -- but nothing weaker does.
function survives(text, replacement, of) {
return replacement.some((message) => {
const kept = of(message);
return (
kept &&
(kept === text || text.startsWith(kept) || kept.startsWith(text))
);
});
}
export function validateSurvivalContract(original, replacement, anchors) {
const postProvenance = historyIsPostProvenance(original);
const start = lastRoundStart(original);
const round = original.slice(start);
const pending = new Set();
const boundaries = [];
for (const [idx, message] of round.entries()) {
const calls = toolUseIds(message);
if (calls.length && pending.size === 0) boundaries.push(idx);
for (const id of calls) pending.add(id);
for (const id of toolResultIds(message)) pending.delete(id);
}
const tail = boundaries.length > 2 ? boundaries.at(-2) : 0;
const lastRound = round.filter(
(message, idx) =>
!isCheckpoint(message, postProvenance) &&
(idx >= tail || isPlainUserText(postProvenance, message)),
);
// Every user turn in the round, not the first one `find` reaches: the round
// spans a tool-bearing turn plus the toolless tail after it, so checking one
// let a rewrite drop the latest turn. The witness reads the replacement
// through the same era-scoped plain-user predicate: a stamped carrier is a
// user message whose text starts with the summary header, and quoting it
// verbatim would let a rewrite hide a dropped pasted turn behind the
// carrier's own header.
const plainUserTextOf = (message) => {
if (isCheckpoint(message, postProvenance)) return null;
return userTextOf(message);
};
for (const text of lastRound.map(plainUserTextOf).filter(Boolean)) {
if (!survives(text, replacement, plainUserTextOf)) {
return "a last-round user message was dropped";
}
}
for (const id of lastRound.flatMap(toolResultIds)) {
const kept = replacement.some((message) =>
(message.content ?? []).some(
(block) => block.type === "tool_result" && block.tool_use_id === id,
),
);
if (!kept) {
return `last-round tool result ${id} was dropped`;
}
}
// The call, not just its result: a tool_result whose tool_use was summarized
// away is an orphan providers reject.
for (const id of lastRound.flatMap(toolUseIds)) {
const kept = replacement.some((message) =>
(message.content ?? []).some(
(block) => block.type === "tool_use" && block.id === id,
),
);
if (!kept) {
return `last-round tool call ${id} was dropped`;
}
}
// Match the text: "some assistant message survived" is satisfied by the
// summary the rewrite itself just wrote.
for (const text of lastRound.map(assistantTextOf).filter(Boolean)) {
if (!survives(text, replacement, assistantTextOf)) {
return "last-round assistant output was dropped";
}
}
if (
lastRound.some(isAssistantLike) &&
!replacement.some(isAssistantLike)
) {
return "last-round assistant output was dropped";
}
// The receipt count reads the replacement in its own era, except that a
// post-provenance original raises the bar to a stamped carrier: the engine
// stamps every replacement it builds, and the stamp is the only form
// request rewriting relocates.
const receiptRecognition = historyIsPostProvenance(original)
? true
: historyIsPostProvenance(replacement);
const checkpoints = replacement.filter(
(message) => isCheckpoint(message, receiptRecognition),
).length;
if (checkpoints === 0) return "checkpoint receipt was dropped";
if (checkpoints > 1) return "prior summaries were duplicated";
if (anchors && !replacement.some((message) =>
(message.content ?? []).some((block) => {
if (block.type === "text") return (block.text ?? "").includes(anchors);
if (block.type === "tool_result") {
return (block.content ?? "").includes(anchors);
}
return false;
}),
)) {
return "pinned /anchor text was dropped";
}
return null;
}
function main() {
if (matrix.schema_version !== 2) {
throw new Error(`unexpected schema_version ${matrix.schema_version}`);
}
let failed = 0;
for (const fixture of matrix.cases) {
if (typeof fixture.last_round_start === "number") {
const start = lastRoundStart(fixture.original);
if (start !== fixture.last_round_start) {
failed += 1;
console.error(
`${fixture.id}: last_round_start ${start} != ${fixture.last_round_start}`,
);
}
}
const error = validateSurvivalContract(
fixture.original,
fixture.replacement,
fixture.anchors,
);
const passed = error === null;
if (fixture.expect === "pass" && !passed) {
failed += 1;
console.error(`${fixture.id}: expected pass, got ${error}`);
} else if (fixture.expect === "fail" && passed) {
failed += 1;
console.error(`${fixture.id}: expected fail closed`);
}
}
if (failed > 0) {
console.error(`${failed} fixture(s) failed`);
process.exit(1);
}
console.log(`ok ${matrix.cases.length} survival-contract fixtures`);
}
const entry = process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1];
if (entry || process.argv[1]?.endsWith("validate_survival_contract.mjs")) {
main();
}
+33 -10
View File
@@ -598,6 +598,7 @@ impl Engine {
>= auto_compaction_config.token_threshold
{
let mut prepared = self.prepare_compaction_envelope(auto_compaction_config);
prepared.input_token_ratio = turn.input_token_ratio;
prepared.tools = active_tools.map(<[Tool]>::to_vec);
Some(prepared)
} else {
@@ -663,6 +664,14 @@ impl Engine {
if let Some(prepared) = prepared
&& compaction_go
{
if let Some(message) = turn.compaction_thrash_message() {
let _ = self
.send_event(Event::error(ErrorEnvelope::context_overflow(
message.clone(),
)))
.await;
return AutoCompactionStep::EndTurn(TurnOutcomeStatus::Failed, Some(message));
}
let compaction_id = format!("compact_{}", &uuid::Uuid::new_v4().to_string()[..8]);
turn.stop_diagnostics.automatic_compaction_attempts = turn
.stop_diagnostics
@@ -755,6 +764,8 @@ impl Engine {
}
self.session.replace_messages(result.messages);
turn.clear_parent_input_tokens();
self.session.latest_parent_input_tokens = None;
turn.last_compaction_step = Some(turn.step);
if let Some(pm) = self.session.prefix_stability.as_mut() {
pm.note_history_reset("compaction");
}
@@ -804,7 +815,6 @@ impl Engine {
Err(err) => {
*auto_compaction_suppressed = true;
self.learn_summary_image_cap(&prepared, &err);
// Log error but continue with original messages (never corrupt)
let message = crate::compaction::report_compaction_failure(
"Making room failed",
&compaction_id,
@@ -813,7 +823,9 @@ impl Engine {
);
self.emit_compaction_failed(compaction_id.clone(), true, message.clone())
.await;
let _ = self.send_event(Event::status(message)).await;
let _ = self.send_event(Event::status(message.clone())).await;
self.finish_compaction(&compaction_id);
return AutoCompactionStep::EndTurn(TurnOutcomeStatus::Failed, Some(message));
}
}
self.finish_compaction(&compaction_id);
@@ -843,6 +855,10 @@ impl Engine {
) else {
return false;
};
if let Some(message) = turn.compaction_thrash_message() {
turn.context_recovery_rejection = Some(anyhow::anyhow!(message));
return false;
}
// Nothing to summarize or prune: a pass cannot help, so do not make
// the user wait on a model call before the failure the caller will
// report anyway.
@@ -873,10 +889,12 @@ impl Engine {
// Measured with the estimator `after_tokens` and the preflight guard
// use, so `recovered` compares like with like and the receipt reads
// ~before → ~after on one scale.
let before_tokens = crate::compaction::estimate_input_tokens_for_pressure(
&self.session.messages,
self.session.system_prompt.as_ref(),
);
let before_tokens =
turn.calibrated_input_tokens(crate::compaction::estimate_request_input_tokens(
&self.session.messages,
self.session.system_prompt.as_ref(),
tools,
));
let before_count = self.session.messages.len();
let mut forced_config = self.config.compaction.clone();
@@ -886,6 +904,7 @@ impl Engine {
.min(target_budget.saturating_sub(1))
.max(1);
let mut prepared = self.prepare_compaction_envelope(forced_config);
prepared.input_token_ratio = turn.input_token_ratio;
prepared.tools = tools.map(<[Tool]>::to_vec);
let started = Instant::now();
@@ -992,10 +1011,12 @@ impl Engine {
// Validate the complete candidate before the only history swap. Bare
// front-trimming after a failed summary silently lost user state and
// could leave orphan tool results in an apparently recovered session.
let after_tokens = crate::compaction::estimate_input_tokens_for_pressure(
&compacted_messages,
self.session.system_prompt.as_ref(),
);
let after_tokens =
turn.calibrated_input_tokens(crate::compaction::estimate_request_input_tokens(
&compacted_messages,
self.session.system_prompt.as_ref(),
tools,
));
let after_count = compacted_messages.len();
let recovered = after_tokens <= target_budget && after_tokens < before_tokens;
@@ -1011,6 +1032,8 @@ impl Engine {
}
self.session.replace_messages(compacted_messages);
turn.clear_parent_input_tokens();
self.session.latest_parent_input_tokens = None;
turn.last_compaction_step = Some(turn.step);
if let Some(pm) = self.session.prefix_stability.as_mut() {
pm.note_history_reset("compaction");
}
@@ -567,7 +567,14 @@ impl Engine {
job.response_settled(!response_stream_failed);
}
turn.add_parent_usage(&usage);
turn.note_parent_prompt_len(self.session.messages.len());
turn.note_parent_prompt_len(
self.session.messages.len(),
crate::compaction::estimate_request_input_tokens(
&stream_request.messages,
stream_request.system.as_ref(),
stream_request.tools.as_deref(),
),
);
self.session.latest_parent_input_tokens = turn.latest_parent_input_tokens;
if usage_reported {
let _ = self
@@ -596,7 +596,9 @@ impl Engine {
request.system.as_ref(),
request.tools.as_deref(),
);
let request_input = request_input.max(estimated_input);
let request_input = turn
.calibrated_input_tokens(request_input)
.max(estimated_input);
let output_cap = crate::route_budget::request_output_cap_for_input(
self.api_provider,
&self.session.model,
@@ -714,6 +716,14 @@ impl Engine {
estimated_input: usize,
input_budget: usize,
) -> PhaseResult<PreparedModelStep> {
if let Some(message) = turn.compaction_thrash_message() {
let _ = self
.send_event(Event::error(ErrorEnvelope::context_overflow(
message.clone(),
)))
.await;
return PhaseResult::Return((TurnOutcomeStatus::Failed, Some(message)));
}
if progress.context_recovery_attempts >= MAX_CONTEXT_RECOVERY_ATTEMPTS {
let message = context_overflow_exhausted_message(
self.config.terminal_chrome_enabled,
+47 -10
View File
@@ -108,6 +108,8 @@ pub struct TurnContext {
/// request are not in the bill; GrokBuild's pre-sampling gate adds a
/// byte-estimate of that suffix so auto-compact can fire mid-turn.
pub(crate) messages_len_at_last_parent_prompt: Option<usize>,
pub(crate) input_token_ratio: (usize, usize),
pub(crate) last_compaction_step: Option<u32>,
/// One-shot latch: an automatic-compaction refusal has already been
/// surfaced this turn. Pressure is re-checked every step, and repeating
@@ -200,6 +202,8 @@ impl TurnContext {
routed_usage_dropped_records: 0,
latest_parent_input_tokens: None,
messages_len_at_last_parent_prompt: None,
input_token_ratio: (1, 1),
last_compaction_step: None,
compaction_refusal_notified: false,
pending_route: None,
context_recovery_rejection: None,
@@ -354,12 +358,33 @@ impl TurnContext {
/// Record how long the transcript was when the latest parent prompt was
/// billed. Call immediately after `add_parent_usage`, before this
/// response's assistant/tool messages are appended.
pub(crate) fn note_parent_prompt_len(&mut self, message_count: usize) {
pub(crate) fn note_parent_prompt_len(&mut self, message_count: usize, estimated_input: usize) {
self.messages_len_at_last_parent_prompt = Some(message_count);
if let Some(reported) = self.latest_parent_input_tokens
&& estimated_input > 0
&& u128::from(reported) * self.input_token_ratio.1 as u128
> self.input_token_ratio.0 as u128 * estimated_input as u128
{
self.input_token_ratio = (reported as usize, estimated_input);
}
}
pub(crate) fn calibrated_input_tokens(&self, estimate: usize) -> usize {
crate::compaction::calibrate_token_estimate(estimate, self.input_token_ratio)
}
pub(crate) fn compaction_thrash_message(&self) -> Option<String> {
let attempts = self
.stop_diagnostics
.automatic_compaction_attempts
.saturating_add(self.stop_diagnostics.emergency_compaction_attempts);
(attempts >= 2
&& self.last_compaction_step.is_some_and(|step| self.step.saturating_sub(step) <= 2))
.then(|| "Context filled again within two model steps after repeated compaction. The turn was stopped to preserve the task and avoid another summary loop. The conversation is saved; choose a larger context route or reduce the task's retained input before retrying.".to_string())
}
/// Live context for the auto-compact gate: last billed prompt plus a
/// /4 estimate of messages appended since that prompt (tool results,
/// calibrated estimate of messages appended since that prompt (tool results,
/// the assistant reply that will be replayed on the next request).
///
/// `max(billed, estimate(full list))` hides mid-turn growth when the
@@ -383,22 +408,21 @@ impl TurnContext {
let growth = if suffix.is_empty() {
0
} else {
u64::try_from(crate::compaction::estimate_input_tokens_for_pressure(
suffix, None,
u64::try_from(self.calibrated_input_tokens(
crate::compaction::estimate_input_tokens_for_pressure(suffix, None),
))
.unwrap_or(u64::MAX)
};
let estimated = u64::try_from(crate::compaction::estimate_input_tokens_for_pressure(
messages,
system_prompt,
let estimated = u64::try_from(self.calibrated_input_tokens(
crate::compaction::estimate_input_tokens_for_pressure(messages, system_prompt),
))
.unwrap_or(u64::MAX);
let live = estimated.max(billed.unwrap_or(0).saturating_add(growth));
(live > 0).then_some(live)
}
/// Drop the turn-local billed receipt after history is rewritten so the
/// next step cannot compact again on the pre-compaction prompt.
/// Drop the turn-local billed receipt after history is rewritten, retaining
/// the learned token ratio for the replacement history and its growth.
pub(crate) fn clear_parent_input_tokens(&mut self) {
self.latest_parent_input_tokens = None;
self.messages_len_at_last_parent_prompt = None;
@@ -542,7 +566,7 @@ mod usage_tests {
cache_control: None,
}],
}];
turn.note_parent_prompt_len(prompt.len());
turn.note_parent_prompt_len(prompt.len(), 70_000);
let mut with_tool = prompt;
with_tool.push(codewhale_models::Message {
@@ -583,6 +607,19 @@ mod usage_tests {
&config,
Some(live),
));
turn.note_parent_prompt_len(1, 35_000);
let calibrated = turn
.live_input_tokens_for_compaction(&with_tool, None, None)
.expect("calibrated live tokens");
assert!(calibrated > live);
turn.clear_parent_input_tokens();
assert_eq!(turn.calibrated_input_tokens(20_000), 40_000);
turn.last_compaction_step = Some(3);
turn.step = 4;
turn.stop_diagnostics.automatic_compaction_attempts = 2;
assert!(turn.compaction_thrash_message().is_some());
turn.step = 6;
assert!(turn.compaction_thrash_message().is_none());
}
}
+16 -11
View File
@@ -5469,21 +5469,26 @@ pub(crate) fn extract_title(raw: &str) -> &str {
/// Strip common inline thinking/reasoning XML sections from saved assistant
/// text before it is shown in session previews.
pub(crate) fn strip_thinking_tags(text: &str) -> String {
if !text.contains("<think") && !text.contains("<thinking") && !text.contains("<reasoning") {
return text.to_string();
}
let tags = ["think", "thinking", "reasoning"];
let tags = ["think", "thinking", "reasoning", "analysis"];
let mut result = text.to_string();
for tag in tags {
let open = format!("<{tag}>");
let close = format!("</{tag}>");
while let Some(start) = result.find(&open) {
let Some(end) = result[start..].find(&close) else {
break;
};
let end_abs = start + end + close.len();
result.replace_range(start..end_abs, "");
loop {
let lower = result.to_ascii_lowercase();
match (lower.find(&open), lower.find(&close)) {
(Some(start), Some(end)) if start < end => {
result.replace_range(start..end + close.len(), "");
}
(_, Some(end)) => {
result.replace_range(..end + close.len(), "");
}
(Some(start), None) => {
result.truncate(start);
break;
}
(None, None) => break,
}
}
}
result