fix(agent): recognize the retry loop's other synthetic nudges during compaction

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.
This commit is contained in:
pierrenode
2026-08-08 15:02:15 +03:00
committed by kshitij
parent 35cbad5854
commit 45cd93fb5b
3 changed files with 161 additions and 21 deletions
+21
View File
@@ -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
+49 -21
View File
@@ -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
@@ -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
):