diff --git a/agent/context_compressor.py b/agent/context_compressor.py index 7475c5f5d4..20f35fed5f 100644 --- a/agent/context_compressor.py +++ b/agent/context_compressor.py @@ -1418,6 +1418,19 @@ The user has requested that this compaction PRIORITISE preserving all informatio # Merge the summary into the first tail message instead # of inserting a standalone message that breaks alternation. _merge_summary_into_tail = True + + # When the summary lands as a standalone role="user" message, + # weak models read the verbatim "## Active Task" quote of a past + # user request as fresh input (#11475, #14521). Append the explicit + # end marker — the same one used in the merge-into-tail path — so + # the model has a clear "summary above, not new input" signal. + if not _merge_summary_into_tail and summary_role == "user": + summary = ( + summary + + "\n\n--- END OF CONTEXT SUMMARY — " + "respond to the message below, not the summary above ---" + ) + if not _merge_summary_into_tail: compressed.append({"role": summary_role, "content": summary}) diff --git a/tests/agent/test_context_compressor.py b/tests/agent/test_context_compressor.py index fd88cc7a96..75a7594a0d 100644 --- a/tests/agent/test_context_compressor.py +++ b/tests/agent/test_context_compressor.py @@ -664,6 +664,44 @@ class TestCompressWithClient: "call_123" ] + def test_user_role_summary_carries_end_marker(self): + """When the summary lands as standalone role='user' (e.g. head ends + with assistant/tool), the message body must include the explicit + '--- END OF CONTEXT SUMMARY ---' marker. Without it, weak models + read the verbatim past user request quoted in '## Active Task' as + fresh input (#11475, #14521). + """ + mock_response = MagicMock() + mock_response.choices = [MagicMock()] + mock_response.choices[0].message.content = "summary text" + + with patch("agent.context_compressor.get_model_context_length", return_value=100000): + c = ContextCompressor(model="test", quiet_mode=True, protect_first_n=2, protect_last_n=2) + + # head_last=assistant, tail_first=assistant (same shape as the + # existing consecutive-user test) → role resolves to "user". + msgs = [ + {"role": "user", "content": "msg 0"}, + {"role": "assistant", "content": "msg 1"}, + {"role": "user", "content": "msg 2"}, + {"role": "assistant", "content": "msg 3"}, + {"role": "user", "content": "msg 4"}, + {"role": "assistant", "content": "msg 5"}, + {"role": "user", "content": "msg 6"}, + {"role": "assistant", "content": "msg 7"}, + ] + with patch("agent.context_compressor.call_llm", return_value=mock_response): + result = c.compress(msgs) + + summary_msg = next( + m for m in result if (m.get("content") or "").startswith(SUMMARY_PREFIX) + ) + assert summary_msg["role"] == "user" + assert "END OF CONTEXT SUMMARY" in summary_msg["content"] + assert summary_msg["content"].rstrip().endswith( + "respond to the message below, not the summary above ---" + ) + def test_summary_role_avoids_consecutive_user_messages(self): """Summary role should alternate with the last head message to avoid consecutive same-role messages.""" mock_client = MagicMock()