fix(agent): decay protected summaries after restart

protect_first_n decay state (compression_count / _previous_summary) is
in-memory only, so a gateway restart re-protected the persisted handoff
summary and head fossils, growing the head unboundedly across
restart+compaction cycles (#57814).

_effective_protect_first_n now probes a bounded resumed-head window for
a persisted handoff summary (by metadata or content prefix) and decays
protection when one is found, before compress_start is computed. The
first post-restart compaction self-heals: stacked summary fossils are
folded into the next summary prompt instead of preserved verbatim,
rehydration is rolled back on abort, deterministic fallback carries a
bounded redacted previous-summary snapshot, and forced user-leading
merged summaries keep the live tail request after the summary end
marker so they stay rehydratable.

Squashed reapply of the 12-commit series from PR #57835 onto current
main (branch was 1210 commits behind; single add/add conflict with the
task-snapshot grounding helpers resolved by keeping both).

Fixes #57814.
This commit is contained in:
harjoth 2026-07-22 05:43:51 -07:00 • committed by Teknium
parent cbc1054e23
commit 08ea88f4f7
3 changed files with 617 additions and 54 deletions

View file

@ -3898,6 +3898,35 @@ class TestDoubleCompactionSummaryRole:
f"compatibility, got role={non_system[0]['role']!r}"
)
def test_restart_handoff_without_system_still_starts_with_user(self):
"""When decayed head protection leaves no head, the visible transcript
must still begin with a user role for Anthropic/Bedrock compatibility.
"""
mock_response = MagicMock()
mock_response.choices = [MagicMock()]
mock_response.choices[0].message.content = "summary of resumed turns"
with patch("agent.context_compressor.get_model_context_length", return_value=100000):
c = ContextCompressor(
model="test", quiet_mode=True, protect_first_n=3, protect_last_n=2,
)
msgs = [
{"role": "user", "content": f"{SUMMARY_PREFIX}\nold persisted summary"},
{"role": "assistant", "content": "handoff acknowledged"},
{"role": "user", "content": "new work after restart"},
{"role": "assistant", "content": "new answer after restart"},
{"role": "user", "content": "more new work after restart"},
{"role": "assistant", "content": "more new answer after restart"},
{"role": "user", "content": "tail request"},
{"role": "assistant", "content": "tail answer"},
]
with patch("agent.context_compressor.call_llm", return_value=mock_response):
result = c.compress(msgs)
assert result[0]["role"] == "user"
assert "summary of resumed turns" in (result[0].get("content") or "")
def test_double_compaction_user_tail_merges_into_tail(self):
"""When the summary is forced to role=user (system-only head) and
the first tail message is also user, the summary must merge into