Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
126 changes: 125 additions & 1 deletion agent/context_compressor.py
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,11 @@ def _is_summary_access_or_quota_error(exc: Exception) -> bool:
"Respond ONLY to the latest user message that appears AFTER this "
"summary — that message is the single source of truth for what to do "
"right now. "
"If no user message appears AFTER this summary, do nothing: do not "
"resume, wrap up, or continue work from "
f"'{HISTORICAL_TASK_HEADING}' or any other section, do not call tools, "
"and wait for a new user message. This handoff must never become the "
"active turn by itself. "
"Topic overlap with the summary does NOT mean you should resume its "
"task: even on similar topics, the latest user message WINS. Treat ONLY "
"the latest message as the active task and discard stale items from "
Expand Down Expand Up @@ -260,7 +265,38 @@ def _strip_persistence_markers(messages: List[Dict[str, Any]]) -> None:
# written by that build generation; prepend only. tests/agent/
# test_summary_prefix_semantics.py byte-pins every entry to enforce this.
_HISTORICAL_SUMMARY_PREFIXES = (
# Pre-#69619: identical to the current prefix except the stale-item
# Pre-#80622: identical to the current prefix except it lacked the
# explicit "if no user message appears AFTER this summary, do nothing"
# clause. Standalone reference handoffs persisted by that build could
# occupy the active user slot after a completed assistant stop and
# resume stale Historical Task Snapshot work.
"[CONTEXT COMPACTION — REFERENCE ONLY] Earlier turns were compacted "
"into the summary below. This is a handoff from a previous context "
"window — treat it as background reference, NOT as active instructions. "
"Do NOT answer questions or fulfill requests mentioned in this summary; "
"they were already addressed. "
"Respond ONLY to the latest user message that appears AFTER this "
"summary — that message is the single source of truth for what to do "
"right now. "
"Topic overlap with the summary does NOT mean you should resume its "
"task: even on similar topics, the latest user message WINS. Treat ONLY "
"the latest message as the active task and discard stale items from "
"'## Historical Task Snapshot' entirely — do not 'wrap up' or "
"'finish' work described there unless the latest message explicitly "
"asks for it. "
"Reverse signals in the latest message (e.g. 'stop', 'undo', 'roll "
"back', 'just verify', 'don't do that anymore', 'never mind', a new "
"topic) must immediately end any in-flight work described in the "
"summary; do not re-surface it in later turns. "
"IMPORTANT: Your persistent memory (MEMORY.md, USER.md) in the system "
"prompt is ALWAYS authoritative and active — never ignore or deprioritize "
"memory content due to this compaction note. "
"None of the above restricts HOW you work: your tools remain fully "
"active — keep calling them normally for the active task (edit files, "
"run commands, search) instead of merely narrating what you would do. "
"The current session state (files, config, etc.) may reflect work "
"described here — avoid repeating it:",
# Pre-#69619: identical to the then-current prefix except the stale-item
# discard clause named all four historical headings (the three
# section headers removed by #69619 were still in the template).
# Summaries persisted by builds immediately before #69619 carry this
Expand Down Expand Up @@ -6881,3 +6917,91 @@ def is_compaction_summary_message(message: Any) -> bool:
else:
content = message
return ContextCompressor._is_context_summary_content(content)


def _handoff_carries_live_user_content(message: Any) -> bool:
"""Return True when a summary-bearing row still carries a live user ask.

Merge-into-tail carriers preserve prior turn content before the summary.
Force-user-leading merges prepend the handoff + end marker to the real
ask, leaving a non-empty remainder after ``_SUMMARY_END_MARKER``. Either
shape must remain actionable (#80622 must not treat them as sole-handoff).
"""
if not isinstance(message, dict):
return False
content = message.get("content")
kind = ContextCompressor.classify_summary_content(content)
if kind == "merged":
return True
text = _content_text_for_contains(content)
marker_idx = text.find(_SUMMARY_END_MARKER)
if marker_idx < 0:
return False
return bool(text[marker_idx + len(_SUMMARY_END_MARKER) :].strip())


def reference_handoff_would_drive_next_model_call(
messages: Optional[List[Dict[str, Any]]],
) -> bool:
"""Return True when the next model call would be driven only by a handoff.

A reference-only compaction handoff must never become the active user turn
by itself after an assistant response has already completed (#80622). Mid
tool-loop compression remains allowed: tool results / assistant tool_calls
after the handoff mean the loop is continuing an in-flight exchange, not
starting a fresh turn from the synthetic summary.
"""
if not messages:
return False

last_driving_handoff = -1
for index, message in enumerate(messages):
if not is_compaction_summary_message(message):
continue
if _handoff_carries_live_user_content(message):
# Embedded live ask — this row is not a sole-handoff driver.
continue
last_driving_handoff = index

if last_driving_handoff < 0:
return False

for message in messages[last_driving_handoff + 1 :]:
if not isinstance(message, dict):
continue
role = message.get("role")
if role == "tool":
return False
if role == "assistant" and message.get("tool_calls"):
return False
if (
ContextCompressor._is_actionable_user_turn(message)
and not ContextCompressor._is_synthetic_compression_user_turn(message)
):
return False
if is_compaction_summary_message(message) and _handoff_carries_live_user_content(
message
):
return False
return True


def is_user_originated_turn(message: Any) -> bool:
"""Return True for human-authored user turns (not compaction scaffolding).

Gateway/session dispatchers (retry, undo, active-turn selection) must use
this instead of ``role == "user" and not display_kind`` — standalone
handoffs with ``_compressed_summary_has_user_turn`` were previously left
without ``display_kind=hidden`` and could be mistaken for real asks (#80622).
Summary-bearing rows are never user-originated, even when they embed a
live ask after the end marker (callers that need that text should unwrap).
"""
if not isinstance(message, dict) or message.get("role") != "user":
return False
if message.get("display_kind"):
return False
if is_compaction_summary_message(message):
return False
if ContextCompressor._is_synthetic_compression_user_turn(message):
return False
return ContextCompressor._is_actionable_user_turn(message)
102 changes: 102 additions & 0 deletions agent/conversation_loop.py
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,70 @@

logger = logging.getLogger(__name__)


def _restore_user_after_reference_handoff(
messages: List[Dict[str, Any]], user_message: Any
) -> bool:
"""Re-append this turn's real user ask when compaction left only a handoff.

Returns True when a restore append happened. Used before deciding whether
a post-compaction ``continue`` would let the reference-only summary drive
the next model call (#80622).
"""
from agent.context_compressor import reference_handoff_would_drive_next_model_call

if not reference_handoff_would_drive_next_model_call(messages):
return False
if user_message is None:
return False
if isinstance(user_message, str):
if not user_message.strip():
return False
content: Any = user_message
elif isinstance(user_message, list):
if not user_message:
return False
content = user_message
else:
return False
if (
messages
and isinstance(messages[-1], dict)
and messages[-1].get("role") == "user"
and messages[-1].get("content") == content
):
return False
messages.append({"role": "user", "content": content})
return True


def _should_skip_model_call_for_reference_handoff(
messages: List[Dict[str, Any]], user_message: Any
) -> bool:
"""Guard post-compaction continues against sole-handoff active turns (#80622)."""
from agent.context_compressor import reference_handoff_would_drive_next_model_call

_restore_user_after_reference_handoff(messages, user_message)
return reference_handoff_would_drive_next_model_call(messages)


def _final_response_from_messages(messages: List[Dict[str, Any]]) -> str:
"""Best-effort recovery of the last real assistant text after a skipped call."""
from agent.context_compressor import is_compaction_summary_message

for message in reversed(messages or []):
if not isinstance(message, dict) or message.get("role") != "assistant":
continue
if message.get("tool_calls"):
continue
if is_compaction_summary_message(message):
continue
content = message.get("content")
if isinstance(content, str) and content.strip():
return content
return ""


# Stable prefix of the local interrupt status string emitted when a turn is
# cancelled while waiting on the provider. Surfaces (ACP, TUI) match on this
# to treat it as cancellation metadata rather than assistant prose.
Expand Down Expand Up @@ -2068,6 +2132,19 @@ def run_conversation(
conversation_history = conversation_history_after_compression(
agent, messages, conversation_history
)
if _should_skip_model_call_for_reference_handoff(
messages, user_message
):
# Reference-only handoff must not become the active turn
# after a completed assistant response (#80622).
logger.info(
"Skipping post-compaction model call: reference-only "
"handoff would be the sole active user turn (#80622)"
)
if not final_response:
final_response = _final_response_from_messages(messages)
_turn_exit_reason = "compaction_handoff_not_actionable"
break
api_call_count -= 1
agent._api_call_count = api_call_count
agent.iteration_budget.refund()
Expand Down Expand Up @@ -5651,6 +5728,17 @@ def _perform_api_call(next_api_kwargs):
messages, user_message
)
agent._persist_user_message_idx = current_turn_user_idx
if _should_skip_model_call_for_reference_handoff(
messages, user_message
):
logger.info(
"Skipping compressed-restart model call: reference-only "
"handoff would be the sole active user turn (#80622)"
)
if not final_response:
final_response = _final_response_from_messages(messages)
_turn_exit_reason = "compaction_handoff_not_actionable"
break
continue

if _retry.restart_with_rebuilt_messages:
Expand Down Expand Up @@ -6492,6 +6580,20 @@ def _perform_api_call(next_api_kwargs):
conversation_history = conversation_history_after_compression(
agent, messages, conversation_history
)
if _should_skip_model_call_for_reference_handoff(
messages, user_message
):
logger.info(
"Skipping post-tool compaction model call: "
"reference-only handoff would be the sole "
"active user turn (#80622)"
)
if not final_response:
final_response = _final_response_from_messages(
messages
)
_turn_exit_reason = "compaction_handoff_not_actionable"
break
elif agent.compression_enabled:
# Over threshold but compression is blocked (summary-LLM
# cooldown or anti-thrashing). Surface a deduped warning so
Expand Down
18 changes: 12 additions & 6 deletions agent/turn_context.py
Original file line number Diff line number Diff line change
Expand Up @@ -179,20 +179,26 @@ def reanchor_current_turn_user_idx(messages: List[Any], user_message: Any) -> in
meaningless. Prefer the LAST user message whose content exactly matches
this turn's text — the surviving copy in the common case — so the
injection stamp and the #48677 persist override can't land on a
todo-snapshot or historical row. Fall back to the last user message when
no exact match survives (merge-summary-into-tail rewrites the content but
the trackers still need a live anchor). Returns -1 when the list has no
user message at all.
todo-snapshot or historical row. Fall back to the last *user-originated*
turn when no exact match survives (merge-summary-into-tail rewrites the
content but the trackers still need a live anchor). Compaction handoffs
must never become the fallback anchor (#80622) — they are reference-only
scaffolding, not the active ask. Returns -1 when the list has no
user-originated message at all.
"""
from agent.context_compressor import is_user_originated_turn

fallback = -1
for i in range(len(messages) - 1, -1, -1):
msg = messages[i]
if not (isinstance(msg, dict) and msg.get("role") == "user"):
continue
if fallback < 0:
fallback = i
if msg.get("content") == user_message:
return i
# Prefer a real human turn over a synthetic handoff / continuation
# marker when the exact content was rewritten by merge-into-tail.
if fallback < 0 and is_user_originated_turn(msg):
fallback = i
return fallback


Expand Down
20 changes: 18 additions & 2 deletions run_agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -2242,10 +2242,26 @@ def _flush_messages_to_session_db_unlocked(
"codex_message_items": msg.get("codex_message_items"),
"timestamp": _row_timestamp,
"api_content": _row_api_content,
# Standalone reference handoffs are always hidden, even
# when the summarized transcript contained a user turn —
# otherwise they occupy the active user slot in
# retry/undo/session dispatch (#80622). Merge-into-tail
# carriers keep prior visibility rules so preserved tail
# content stays readable.
"display_kind": (
"hidden"
if msg.get(COMPRESSED_SUMMARY_METADATA_KEY)
and not msg.get("_compressed_summary_has_user_turn")
if (
msg.get(COMPRESSED_SUMMARY_METADATA_KEY)
and (
ContextCompressor.classify_summary_content(
msg.get("content")
)
== "standalone"
or not msg.get(
"_compressed_summary_has_user_turn"
)
)
)
else msg.get("display_kind")
),
"display_metadata": msg.get("display_metadata"),
Expand Down
16 changes: 12 additions & 4 deletions tests/agent/test_micro_compaction.py
Original file line number Diff line number Diff line change
Expand Up @@ -451,8 +451,9 @@ def explode(self): # pragma: no cover - must never be called
def test_first_pass_costs_marker_overhead_then_pays_it_back(self):
"""The first pass can grow the transcript; later passes recover it.

Inserting the summary marker costs a fixed ~400 tokens of scaffolding
(the compaction preamble, the historical heading and the end marker).
Inserting the summary marker costs a fixed block of scaffolding
(``SUMMARY_PREFIX``, the historical heading and the end marker —
currently ~450 tokens and grows when the preamble is lengthened).
On pass one that overhead is paid against a single absorbed exchange,
so the net can be positive. From pass two on the marker is replaced
rather than added, so the scaffolding is already paid for and each
Expand All @@ -476,13 +477,20 @@ def test_first_pass_costs_marker_overhead_then_pays_it_back(self):
assert after_many < after_first, "later passes must recover it"

def test_cumulative_savings_accumulate_across_passes(self):
"""Session-total savings go positive once marker overhead is paid back.

The first pass inserts ``SUMMARY_PREFIX`` scaffolding (~450 tokens);
with the current preamble that alone leaves the cumulative counter
negative after only a few absorptions. Enough later passes must
still recover it — that is the amortization contract.
"""
cc = _compressor()
messages = _conversation(exchanges=10)

for _ in range(4):
for _ in range(6):
messages = cc._micro_compact(messages)

assert cc._micro_compact_passes == 4
assert cc._micro_compact_passes == 6
assert cc._micro_compact_tokens_saved_total > 0

def test_defrag_triggers_once_the_rolling_summary_grows(self):
Expand Down
Loading
Loading