From aed114a69bdab975e892bc3537c49cffc401a520 Mon Sep 17 00:00:00 2001 From: PRATHAMESH75 Date: Tue, 4 Aug 2026 21:24:34 +0530 Subject: [PATCH] fix(agent): treat max-iteration nudge as synthetic during compaction MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit handle_max_iterations() appends its runtime summary request as a plain role="user" row, which SessionDB persists verbatim. On later compaction the synthetic-turn filters only recognized compaction summaries, continuation rows, and todo snapshots, so the nudge could be selected as the latest actionable user turn — becoming the task snapshot / auto-focus input and getting summarized as "User asked: ...", demoting the real human task. Metadata flags do not survive SessionDB projection (the reason the existing markers are content-based), so recognition must key off stable content. Extract the nudge into a shared MAX_ITERATIONS_SUMMARY_REQUEST constant and teach _is_synthetic_compression_user_turn() to recognize it, mirroring the continuation/todo markers. Every _is_actionable_user_turn call site already pairs the synthetic guard, so the single recognizer change covers anchor selection, auto-focus, and real-user-turn detection. Fixes #78580 --- agent/chat_completion_helpers.py | 11 +++--- agent/context_compressor.py | 10 ++++++ ...context_compressor_zero_user_provenance.py | 35 +++++++++++++++++++ 3 files changed, 51 insertions(+), 5 deletions(-) diff --git a/agent/chat_completion_helpers.py b/agent/chat_completion_helpers.py index 18f5a2c259c19..158b1dce472e1 100644 --- a/agent/chat_completion_helpers.py +++ b/agent/chat_completion_helpers.py @@ -2317,11 +2317,12 @@ def handle_max_iterations(agent, messages: list, api_call_count: int) -> str: defer_logical_completion=True, ) - summary_request = ( - "You've reached the maximum number of tool-calling iterations allowed. " - "Please provide a final response summarizing what you've found and accomplished so far, " - "without calling any more tools." - ) + # Shared constant so compaction recognizers can identify this runtime nudge + # by its stable content after SessionDB projection strips metadata flags + # (see MAX_ITERATIONS_SUMMARY_REQUEST / _is_synthetic_compression_user_turn). + from agent.context_compressor import MAX_ITERATIONS_SUMMARY_REQUEST + + summary_request = MAX_ITERATIONS_SUMMARY_REQUEST messages.append({"role": "user", "content": summary_request}) try: diff --git a/agent/context_compressor.py b/agent/context_compressor.py index e9471aaa43355..c6b18d148197c 100644 --- a/agent/context_compressor.py +++ b/agent/context_compressor.py @@ -168,6 +168,15 @@ _LEGACY_COMPRESSION_CONTINUATION_USER_CONTENT = ( "This marker exists because the compacted transcript contained " "no preserved user turn." ) +# Runtime nudge appended by ``handle_max_iterations`` as a ``role="user"`` row. +# SessionDB projection strips underscore-prefixed metadata, so a synthetic flag +# would not survive persistence; the stable content string is the authoritative +# marker for compaction recognizers (mirrors the continuation/todo markers). +MAX_ITERATIONS_SUMMARY_REQUEST = ( + "You've reached the maximum number of tool-calling iterations allowed. " + "Please provide a final response summarizing what you've found and accomplished so far, " + "without calling any more tools." +) def _fresh_compaction_message_copy(msg: Dict[str, Any]) -> Dict[str, Any]: @@ -4368,6 +4377,7 @@ This compaction should PRIORITISE preserving all information related to the focu return text in { COMPRESSION_CONTINUATION_USER_CONTENT, _LEGACY_COMPRESSION_CONTINUATION_USER_CONTENT, + MAX_ITERATIONS_SUMMARY_REQUEST, } or text.startswith( TODO_INJECTION_HEADER + "\n" ) diff --git a/tests/agent/test_context_compressor_zero_user_provenance.py b/tests/agent/test_context_compressor_zero_user_provenance.py index 16be1258830a6..1952af6bed1da 100644 --- a/tests/agent/test_context_compressor_zero_user_provenance.py +++ b/tests/agent/test_context_compressor_zero_user_provenance.py @@ -11,6 +11,7 @@ from agent.context_compressor import ( COMPRESSED_SUMMARY_HAS_USER_TURN_KEY, COMPRESSED_SUMMARY_METADATA_KEY, HISTORICAL_TASK_HEADING, + MAX_ITERATIONS_SUMMARY_REQUEST, SUMMARY_PREFIX, ContextCompressor, _NO_USER_TASK_SENTINEL, @@ -202,6 +203,40 @@ def test_zero_user_provenance_survives_iterative_compaction(compressor): assert second_handoffs[0][COMPRESSED_SUMMARY_HAS_USER_TURN_KEY] is False +def test_max_iterations_nudge_is_synthetic_not_actionable(): + """#78580: the max-iteration runtime nudge is runtime scaffolding, not a + human turn. It is appended as ``role="user"`` and persisted verbatim in + state.db (metadata flags do not survive projection), so recognition must be + content-based — exactly like the continuation/todo markers.""" + # The projected form: a bare role/content row with no internal metadata. + nudge = {"role": "user", "content": MAX_ITERATIONS_SUMMARY_REQUEST} + + assert ContextCompressor._is_synthetic_compression_user_turn(nudge) is True + # A real human turn with the same shape stays actionable. + human = {"role": "user", "content": "Ship the release notes for v2."} + assert ContextCompressor._is_synthetic_compression_user_turn(human) is False + assert ContextCompressor._transcript_has_real_user_turn([nudge]) is False + assert ContextCompressor._transcript_has_real_user_turn([human, nudge]) is True + + +def test_real_task_wins_over_trailing_max_iterations_nudge(compressor): + """The tail anchor must resolve to the human task, not the nudge that the + runtime appended after it when iterations were exhausted.""" + human = {"role": "user", "content": "Refactor the auth module and add tests."} + messages = [ + human, + {"role": "assistant", "content": "Working on it.", "tool_calls": [ + {"id": "c1", "function": {"name": "terminal", "arguments": "{}"}} + ]}, + {"role": "tool", "tool_call_id": "c1", "content": "ok"}, + {"role": "user", "content": MAX_ITERATIONS_SUMMARY_REQUEST}, + ] + + idx = compressor._find_last_user_message_idx(messages, head_end=0) + assert idx == 0, "nudge was selected as the anchor instead of the human task" + assert messages[idx]["content"] == human["content"] + + def test_compress_context_todo_snapshot_stays_synthetic_across_two_boundaries( tmp_path, monkeypatch ):