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:
parent
1005a057f0
commit
aed114a69b
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
):
|
||||
|
|
|
|||
Loading…
Reference in New Issue