fix(agent): treat max-iteration nudge as synthetic during compaction

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
This commit is contained in:
PRATHAMESH75 2026-08-04 21:24:34 +05:30 committed by kshitij
parent 1005a057f0
commit aed114a69b
3 changed files with 51 additions and 5 deletions

View File

@ -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:

View File

@ -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"
)

View File

@ -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
):