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:
@@ -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
@@ -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
|
||||
):
|
||||
|
||||
Reference in New Issue
Block a user