Problem
A history compaction wrote a checkpoint whose LLM summary was a 138-token free-form fragment that (a) ignored the mandated structured format, (b) ended mid-sentence, and (c) omitted all load-bearing context (which files were changed, current worktree state, test status). The continuing model then confabulated implementation details (it described a TypeScript CLI change as non-existent Rust files), which looked like foreign content leaking into the session.
Incident evidence (session fbdb3fd3, checkpoint hcheckpoint-981ceab88738d4819971747aeb292203):
- Compaction folded 742 RuntimeEvents / ~235,503 estimated tokens into a 138-token summary (~1700:1).
- The summary text:
确认服务端语义后,决定:…现在看 desktop 的 retry 循环结尾: — free-form, ends with a colon mid-sentence, and follows none of the SUMMARIZATION_SYSTEM_PROMPT sections (## Goal / ## Progress / ## Key Decisions / ## Next Steps / ## Critical Context).
- The run ledger shows 3 failedOpen attempts with
failOpenReason: provider_error immediately before the write that succeeded — the accepted response was produced right after repeated provider failures and is consistent with a degraded/truncated provider completion.
Root cause
history-compact-summarizer.ts + ai-sdk-compaction.ts accept any non-empty string as a checkpoint summary:
- The only rejection gate is
empty_summary (!summary?.trim()). There is no structural validation (required sections), no minimum-size sanity check relative to the folded span, and no truncated-output detection beyond finishReason === 'length' — which does not catch providers that return partial text with a stop finish reason (observed with the session's own model serving as summarizer).
- The summarizer reuses the session model (
resolveModel()). A weak instruction-follower can silently produce a free-form note instead of the structured checkpoint, and the pipeline records it as a valid checkpoint with decision: 'replaced', replacing 235k tokens of history.
No cross-session data is involved in this incident: buildHistoryCompactCheckpoint rejects multi-session event sets, and the checkpoint's coverage references this session's own event projection. The "foreign" content was reference material deliberately fetched into the session (a codex-rs source study requested by the user) that the thin summary failed to contextualize, leaving the continuation model to hallucinate around it.
Proposal
- Structural validation of summaries: require the mandated section headers (
## Goal, ## Progress, ## Next Steps at minimum) and reject/flag summaries that end mid-sentence (e.g. trailing colon/open code fence). Failure → HistoryCompactSummarizerError('malformed_summary') → fail-open (keep history, retry next turn) instead of persisting the fragment.
- Size sanity bound: reject summaries smaller than a floor relative to the folded span (or an absolute floor such as ~200 tokens when folding >10k tokens), so a 235k→138 collapse cannot be accepted silently.
- Truncation robustness: treat missing/unknown finish reasons on suspiciously short output as
output_length (retryable), and record the summarizer's finishReason/usage in compaction diagnostics.
- Deterministic facts appendix: attach model-independent anchors to the checkpoint — files touched (from tool-call args), task ledger snapshot, git worktree/branch — so the continuation has ground truth even when the prose summary is thin.
- Consider a dedicated (stronger or cheaper-but-reliable) summarizer model rather than always reusing the session model, or at least a retry with elevated instructions when validation fails.
Acceptance
- A degraded provider response (short, section-less, mid-sentence) can never replace folded history; the turn fails open and retries.
- Compaction diagnostics record summarizer finishReason, output tokens, and validation outcome.
- Regression test: a summarizer stub returning the incident's 138-token fragment fails validation and the checkpoint is not written.
Generated-by: Maka
Problem
A history compaction wrote a checkpoint whose LLM summary was a 138-token free-form fragment that (a) ignored the mandated structured format, (b) ended mid-sentence, and (c) omitted all load-bearing context (which files were changed, current worktree state, test status). The continuing model then confabulated implementation details (it described a TypeScript CLI change as non-existent Rust files), which looked like foreign content leaking into the session.
Incident evidence (session fbdb3fd3, checkpoint hcheckpoint-981ceab88738d4819971747aeb292203):
确认服务端语义后,决定:…现在看 desktop 的 retry 循环结尾:— free-form, ends with a colon mid-sentence, and follows none of theSUMMARIZATION_SYSTEM_PROMPTsections (## Goal / ## Progress / ## Key Decisions / ## Next Steps / ## Critical Context).failOpenReason: provider_errorimmediately before the write that succeeded — the accepted response was produced right after repeated provider failures and is consistent with a degraded/truncated provider completion.Root cause
history-compact-summarizer.ts+ai-sdk-compaction.tsaccept any non-empty string as a checkpoint summary:empty_summary(!summary?.trim()). There is no structural validation (required sections), no minimum-size sanity check relative to the folded span, and no truncated-output detection beyondfinishReason === 'length'— which does not catch providers that return partial text with astopfinish reason (observed with the session's own model serving as summarizer).resolveModel()). A weak instruction-follower can silently produce a free-form note instead of the structured checkpoint, and the pipeline records it as a valid checkpoint withdecision: 'replaced', replacing 235k tokens of history.No cross-session data is involved in this incident:
buildHistoryCompactCheckpointrejects multi-session event sets, and the checkpoint's coverage references this session's own event projection. The "foreign" content was reference material deliberately fetched into the session (a codex-rs source study requested by the user) that the thin summary failed to contextualize, leaving the continuation model to hallucinate around it.Proposal
## Goal,## Progress,## Next Stepsat minimum) and reject/flag summaries that end mid-sentence (e.g. trailing colon/open code fence). Failure →HistoryCompactSummarizerError('malformed_summary')→ fail-open (keep history, retry next turn) instead of persisting the fragment.output_length(retryable), and record the summarizer's finishReason/usage in compaction diagnostics.Acceptance
Generated-by: Maka