From 45cd93fb5b5366446ced799596578c1bc7ea8d2e Mon Sep 17 00:00:00 2001 From: pierrenode <298902573+pierrenode@users.noreply.github.com> Date: Sat, 8 Aug 2026 15:02:15 +0300 Subject: [PATCH] fix(agent): recognize the retry loop's other synthetic nudges during compaction MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit aed114a69 taught _is_synthetic_compression_user_turn to recognize the max-iteration nudge as ephemeral runtime scaffolding rather than a human turn, since its role="user" metadata flag doesn't survive SessionDB projection and a crash/interrupt mid-turn can persist it durably — becoming the compaction anchor / auto-focus topic in place of the real task. conversation_loop.py's retry loop appends several more role="user" rows with the exact same "ephemeral, metadata-tag-only" shape, none of them recognized by the classifier: - The three _get_continuation_prompt variants (length-continuation nudge, tagged _length_continuation_nudge) — two fixed strings plus a third that interpolates the dropped-tool-call list. - _CODEX_INCOMPLETE_NUDGE (codex/responses reasoning-only retry). - The codex ack-continuation nudge (acknowledgment-only reply re-prompt). - The dropped-tool-call nudge (tagged _dropped_toolcall_nudge) — persisted across up to 3 consecutive retries before the finalization pop-loop strips it; an interrupt/crash before that pop can persist it same as the max-iteration case. Promote the previously-inline nudge strings to named module-level constants in conversation_loop.py (single source of truth for both construction and recognition), then extend the classifier to recognize all of them — exact match for the five fixed-content nudges, a stable-prefix check for the dropped-tool-call continuation variant (its tool list is interpolated so it can't be exact-matched, same treatment TODO_INJECTION_HEADER already gets). Imported lazily inside the classifier to avoid a module-load-order cycle — conversation_loop.py already imports FROM context_compressor.py at call time for the same reason. --- agent/context_compressor.py | 21 +++++ agent/conversation_loop.py | 70 +++++++++----- ...context_compressor_zero_user_provenance.py | 91 +++++++++++++++++++ 3 files changed, 161 insertions(+), 21 deletions(-) diff --git a/agent/context_compressor.py b/agent/context_compressor.py index 3bc40b7a84..134d27abd6 100644 --- a/agent/context_compressor.py +++ b/agent/context_compressor.py @@ -4592,12 +4592,33 @@ This compaction should PRIORITISE preserving all information related to the focu if cls._is_context_summary_content(content): return True text = _content_text_for_contains(content).strip() + # Sibling recovery nudges from agent.conversation_loop's retry loop: + # same "ephemeral scaffolding, not a real human turn" class as the + # markers above (see _CODEX_INCOMPLETE_NUDGE's own docstring there), + # imported lazily to avoid a module-load-order cycle (conversation_loop + # already imports FROM this module at call time for the same reason). + from agent.conversation_loop import ( + _CODEX_ACK_CONTINUATION_NUDGE, + _CODEX_INCOMPLETE_NUDGE, + _DROPPED_TOOLCALL_NUDGE_CONTENT, + _LENGTH_CONTINUATION_DROPPED_TOOLS_PREFIX, + _LENGTH_CONTINUATION_NETWORK_STUB, + _LENGTH_CONTINUATION_OUTPUT_LIMIT, + ) + return text in { COMPRESSION_CONTINUATION_USER_CONTENT, _LEGACY_COMPRESSION_CONTINUATION_USER_CONTENT, MAX_ITERATIONS_SUMMARY_REQUEST, + _CODEX_INCOMPLETE_NUDGE, + _CODEX_ACK_CONTINUATION_NUDGE, + _DROPPED_TOOLCALL_NUDGE_CONTENT, + _LENGTH_CONTINUATION_NETWORK_STUB, + _LENGTH_CONTINUATION_OUTPUT_LIMIT, } or text.startswith( TODO_INJECTION_HEADER + "\n" + ) or text.startswith( + _LENGTH_CONTINUATION_DROPPED_TOOLS_PREFIX ) @staticmethod diff --git a/agent/conversation_loop.py b/agent/conversation_loop.py index 4d2f3eea4e..4e00a17ff7 100644 --- a/agent/conversation_loop.py +++ b/agent/conversation_loop.py @@ -759,11 +759,34 @@ def _stored_prompt_matches_runtime(agent, prompt: str) -> bool: return True +# The three _get_continuation_prompt variants below, in named-constant form +# so agent.context_compressor's _is_synthetic_compression_user_turn can +# recognize them by content after a crash/interrupt persists one mid-list — +# these rows carry no durable role beyond driving the retry, and SessionDB +# projection strips the _length_continuation_nudge metadata tag that marks +# them in live memory (see agent/context_compressor.py). +_LENGTH_CONTINUATION_NETWORK_STUB = ( + "[System: The previous response was cut off by a " + "network error mid-stream. Continue exactly where " + "you left off. Do not restart or repeat prior text. " + "Finish the answer directly.]" +) +_LENGTH_CONTINUATION_OUTPUT_LIMIT = ( + "[System: Your previous response was truncated by the output " + "length limit. Continue exactly where you left off. Do not " + "restart or repeat prior text. Finish the answer directly.]" +) +# The dropped-tools variant interpolates the tool name list right after this +# prefix, so it can't be exact-matched — this stable prefix is what +# _is_synthetic_compression_user_turn checks with str.startswith instead. +_LENGTH_CONTINUATION_DROPPED_TOOLS_PREFIX = "[System: Your previous tool call (" + + def _get_continuation_prompt(is_partial_stub: bool, dropped_tools: Optional[List[str]] = None) -> str: if is_partial_stub and dropped_tools: tool_list = ", ".join(dropped_tools[:3]) return ( - "[System: Your previous tool call " + f"{_LENGTH_CONTINUATION_DROPPED_TOOLS_PREFIX}" f"({tool_list}) was too large and " "the stream timed out before it " "could be delivered. Do NOT retry " @@ -776,18 +799,9 @@ def _get_continuation_prompt(is_partial_stub: bool, dropped_tools: Optional[List "tokens to avoid stream timeouts.]" ) elif is_partial_stub: - return ( - "[System: The previous response was cut off by a " - "network error mid-stream. Continue exactly where " - "you left off. Do not restart or repeat prior text. " - "Finish the answer directly.]" - ) + return _LENGTH_CONTINUATION_NETWORK_STUB else: - return ( - "[System: Your previous response was truncated by the output " - "length limit. Continue exactly where you left off. Do not " - "restart or repeat prior text. Finish the answer directly.]" - ) + return _LENGTH_CONTINUATION_OUTPUT_LIMIT # Continuation nudge for Codex/Responses turns that came back with only @@ -805,6 +819,27 @@ _CODEX_INCOMPLETE_NUDGE = ( ) +# Re-prompt sent after a Codex/Responses turn ends with an acknowledgment-only +# reply (no tool calls, no final answer) — named so +# agent.context_compressor's _is_synthetic_compression_user_turn can +# recognize it by content the same way it recognizes _CODEX_INCOMPLETE_NUDGE. +_CODEX_ACK_CONTINUATION_NUDGE = ( + "[System: Continue now. Execute the required tool calls and only " + "send your final answer after completing the task.]" +) + +# Re-prompt sent when a provider returns finish_reason="tool_calls" with an +# empty tool_calls array (dropped-tool-call recovery, see the retry loop +# below). Named for the same reason as _CODEX_ACK_CONTINUATION_NUDGE — this +# pair is only stripped from the durable transcript once the turn reaches +# finalization; an interrupt/crash mid-retry can still persist it. +_DROPPED_TOOLCALL_NUDGE_CONTENT = ( + "Your previous turn indicated a tool call but none was " + "included. Do not narrate a plan or restate intent — issue " + "the actual tool call now to continue the task." +) + + # Shared recovery hint appended to every content-policy refusal message. Both # the HTTP-200 refusal path (``finish_reason=content_filter``) and the # exception path (a provider moderation error classified as @@ -7272,10 +7307,7 @@ def run_conversation( continue_msg = { "role": "user", - "content": ( - "[System: Continue now. Execute the required tool calls and only " - "send your final answer after completing the task.]" - ), + "content": _CODEX_ACK_CONTINUATION_NUDGE, } messages.append(continue_msg) agent._session_messages = messages @@ -7343,11 +7375,7 @@ def run_conversation( messages.append(final_msg) messages.append({ "role": "user", - "content": ( - "Your previous turn indicated a tool call but none was " - "included. Do not narrate a plan or restate intent — issue " - "the actual tool call now to continue the task." - ), + "content": _DROPPED_TOOLCALL_NUDGE_CONTENT, "_dropped_toolcall_nudge": True, }) agent._session_messages = messages diff --git a/tests/agent/test_context_compressor_zero_user_provenance.py b/tests/agent/test_context_compressor_zero_user_provenance.py index 1952af6bed..1954353fd2 100644 --- a/tests/agent/test_context_compressor_zero_user_provenance.py +++ b/tests/agent/test_context_compressor_zero_user_provenance.py @@ -237,6 +237,97 @@ def test_real_task_wins_over_trailing_max_iterations_nudge(compressor): assert messages[idx]["content"] == human["content"] +@pytest.mark.parametrize( + "content", + [ + pytest.param( + "[System: The previous response was cut off by a " + "network error mid-stream. Continue exactly where " + "you left off. Do not restart or repeat prior text. " + "Finish the answer directly.]", + id="length_continuation_network_stub", + ), + pytest.param( + "[System: Your previous response was truncated by the output " + "length limit. Continue exactly where you left off. Do not " + "restart or repeat prior text. Finish the answer directly.]", + id="length_continuation_output_limit", + ), + pytest.param( + "[System: Your previous tool call (write_file) was too large and " + "the stream timed out before it could be delivered. Do NOT retry " + "the same tool call with the same large content. Instead, break the " + "content into multiple smaller tool calls (e.g. use multiple patch " + "calls or write smaller files). Each tool call's arguments must be " + "under ~8K tokens to avoid stream timeouts.]", + id="length_continuation_dropped_tools", + ), + pytest.param( + "[System: Your previous response contained only internal reasoning and " + "never produced a visible answer or tool call. Do not keep thinking. " + "Produce your final answer as plain text now (or make the tool call " + "you were planning).]", + id="codex_incomplete_nudge", + ), + pytest.param( + "[System: Continue now. Execute the required tool calls and only " + "send your final answer after completing the task.]", + id="codex_ack_continuation_nudge", + ), + pytest.param( + "Your previous turn indicated a tool call but none was " + "included. Do not narrate a plan or restate intent — issue " + "the actual tool call now to continue the task.", + id="dropped_toolcall_nudge", + ), + ], +) +def test_conversation_loop_retry_nudges_are_synthetic(content): + """These are runtime recovery nudges appended by conversation_loop's retry + loop (length-continuation, codex incomplete/ack-continuation, + dropped-tool-call) — same "ephemeral scaffolding, not a human turn" class + as MAX_ITERATIONS_SUMMARY_REQUEST above. A turn interrupted/crashed mid- + retry can persist one of these as a plain role="user" row (their + _length_continuation_nudge/_dropped_toolcall_nudge metadata tags do not + survive SessionDB projection), so recognition must be content-based.""" + nudge = {"role": "user", "content": content} + assert ContextCompressor._is_synthetic_compression_user_turn(nudge) is True + + human = {"role": "user", "content": "Ship the release notes for v2."} + assert ContextCompressor._is_synthetic_compression_user_turn(human) is False + + +def test_real_task_wins_over_trailing_dropped_tools_continuation_nudge(compressor): + """The dropped-tools continuation nudge interpolates the tool name list, + so it can only be recognized by a stable prefix (unlike the other nudges, + which are exact-matched) — this proves that prefix path actually wires + into anchor selection, not just the classifier in isolation.""" + 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": "write_file", "arguments": "{}"}} + ]}, + {"role": "tool", "tool_call_id": "c1", "content": "ok"}, + { + "role": "user", + "content": ( + "[System: Your previous tool call (write_file, patch_file) was " + "too large and the stream timed out before it could be " + "delivered. Do NOT retry the same tool call with the same " + "large content. Instead, break the content into multiple " + "smaller tool calls (e.g. use multiple patch calls or write " + "smaller files). Each tool call's arguments must be under " + "~8K tokens to avoid stream timeouts.]" + ), + }, + ] + + 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 ):