Skip to content
Merged
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
132 changes: 131 additions & 1 deletion agent/context_compressor.py
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,13 @@ 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. (Exception: if tool results or your own "
"tool calls appear after this summary, you are mid-way through an "
"in-flight exchange — continue that exchange normally.) "
"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 +267,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 @@ -6919,3 +6957,95 @@ 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).

Delegates to ``_strip_context_summary_handoff_message`` — the canonical
"does anything survive once the handoff is removed" logic (it also
handles multimodal list content and returns ``None`` for a merged-shaped
row whose preserved prior tail is EMPTY, which a bare
``classify_summary_content(...) == "merged"`` check would wrongly treat
as live). Callers must pre-filter with ``is_compaction_summary_message``:
for non-summary rows the strip helper returns the message unchanged,
which would read as "carries live content" here.
"""
if not isinstance(message, dict):
return False
return (
ContextCompressor._strip_context_summary_handoff_message(message)
is not None
)


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)
107 changes: 107 additions & 0 deletions agent/conversation_loop.py
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,66 @@

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. The caller has already
established that a reference-only handoff would drive the next model
call (#80622); this helper only decides whether a restorable ask exists.
"""
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

if not reference_handoff_would_drive_next_model_call(messages):
return False
if _restore_user_after_reference_handoff(messages, user_message):
# The restored ask is an actionable non-synthetic user row appended
# after the handoff — by construction the handoff no longer drives.
return False
return True


# Fallback final_response for a turn ended by the sole-handoff skip (#80622).
# Deliberately NOT a replay of the last assistant text: finalize_turn's
# non-assistant-tail chokepoint (#43849) appends final_response as a fresh
# assistant row, so recovering the previous turn's prose here would duplicate
# it in the durable transcript AND re-deliver it to the user as if it were
# this turn's answer. A short status is honest and idempotent.
_HANDOFF_SKIP_FINAL_RESPONSE = (
"Context was compacted. The previous response is complete — "
"awaiting your next message."
)


# 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 @@ -2142,9 +2202,30 @@ def run_conversation(
conversation_history = conversation_history_after_compression(
agent, messages, conversation_history
)
# This preflight iteration never reaches the provider whether
# we skip the turn (handoff guard below) or re-run the loop —
# refund the consumed call/budget in BOTH cases, mirroring the
# ollama_runtime_context_too_small early-exit above. Without
# the refund on the break path, every skipped turn leaked one
# iteration-budget unit for the agent's lifetime and
# finalize_turn logged an api_call_count including a call that
# was never made.
api_call_count -= 1
agent._api_call_count = api_call_count
agent.iteration_budget.refund()
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 = _HANDOFF_SKIP_FINAL_RESPONSE
_turn_exit_reason = "compaction_handoff_not_actionable"
break
continue
elif (
agent.compression_enabled
Expand Down Expand Up @@ -5715,12 +5796,26 @@ def _perform_api_call(next_api_kwargs):
# to fit the context window.
retry_count += 1
_retry.restart_with_compressed_messages = False
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 = _HANDOFF_SKIP_FINAL_RESPONSE
_turn_exit_reason = "compaction_handoff_not_actionable"
break
# In-loop compression rebuilt `messages` with fresh compaction
# copies, so the pre-compression current-turn index is stale.
# Re-anchor exactly like the prologue does: a stale index that
# lands on a historical user message would make the live-compose
# fallback inject this turn's prefetch into that message on the
# wire only, diverging the next turn's replayed prefix there.
# Ordered AFTER the handoff guard: the guard may have re-appended
# this turn's real user ask (restore path), and the anchor must
# land on that restored row, not on -1 / a pre-restore index.
current_turn_user_idx = reanchor_current_turn_user_idx(
messages, user_message
)
Expand Down Expand Up @@ -6566,6 +6661,18 @@ 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 = _HANDOFF_SKIP_FINAL_RESPONSE
_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
17 changes: 12 additions & 5 deletions cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -8410,11 +8410,15 @@ def retry_last(self):

# Walk backwards to the last *real* user message. Timeline bookkeeping
# rows (display_kind set) are role=user but are not user turns — match
# CLI resume counting and list_recent_user_messages.
# CLI resume counting and list_recent_user_messages. Compaction
# handoffs are excluded too (durable role=user, sometimes without
# display_kind on legacy sessions; #80622).
from agent.context_compressor import is_user_originated_turn

last_user_idx = None
for i in range(len(self.conversation_history) - 1, -1, -1):
msg = self.conversation_history[i]
if msg.get("role") == "user" and not msg.get("display_kind"):
if is_user_originated_turn(msg):
last_user_idx = i
break

Expand Down Expand Up @@ -8460,12 +8464,15 @@ def undo_last(self, n: int = 1, prefill: bool = True):
n = 1

# Walk backwards collecting the indices of the last N *real* user
# messages (exclude display_kind timeline rows — same predicate as
# list_recent_user_messages and resume turn counting).
# messages (exclude display_kind timeline rows and compaction
# handoffs — same predicate as list_recent_user_messages, resume
# turn counting, and /retry; #80622).
from agent.context_compressor import is_user_originated_turn

user_indices = []
for i in range(len(self.conversation_history) - 1, -1, -1):
msg = self.conversation_history[i]
if msg.get("role") == "user" and not msg.get("display_kind"):
if is_user_originated_turn(msg):
user_indices.append(i)
if len(user_indices) >= n:
break
Expand Down
8 changes: 7 additions & 1 deletion gateway/slash_commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -2572,9 +2572,15 @@ async def _handle_retry_command(self, event: MessageEvent) -> str:
# and re-sent opaque bookkeeping text (same class as the TUI ordinal).
last_user_msg = None
last_user_idx = None
# is_user_originated_turn: excludes display_kind bookkeeping AND
# compaction handoffs (durable role=user, sometimes without
# display_kind on legacy sessions; #80622) — /retry must never
# re-send a reference-only summary as if the user asked it.
from agent.context_compressor import is_user_originated_turn

for i in range(len(history) - 1, -1, -1):
msg = history[i]
if msg.get("role") == "user" and not msg.get("display_kind"):
if is_user_originated_turn(msg):
last_user_msg = msg.get("content", "")
last_user_idx = i
break
Expand Down
6 changes: 5 additions & 1 deletion hermes_cli/cli_agent_setup_mixin.py
Original file line number Diff line number Diff line change
Expand Up @@ -623,11 +623,15 @@ def _preload_resumed_session(self) -> bool:
self._resume_display_history = [
m for m in display_history if m.get("role") != "session_meta"
]
from agent.context_compressor import is_user_originated_turn

# Count only user-originated turns (#80622): legacy compaction
# handoffs are durable role=user rows without display_kind.
msg_count = len(
[
m
for m in self._resume_display_history
if m.get("role") == "user" and not m.get("display_kind")
if is_user_originated_turn(m)
]
)
title_part = ""
Expand Down
Loading
Loading