fix(streaming): clean cancelled user context chains

This commit is contained in:
Hermes Agent authored and nesquena-hermes committed 2026-06-17 04:49:55 +00:00
1 parent fa21a4bb81
commit ce3179d64f
2 files changed
+488 -25

No files matched your search

+129 -25
View File
@@ -3846,9 +3846,14 @@ def _dedupe_replayed_context_messages(previous_context, result_messages, msg_tex
): ):
boundary_idx = len(previous_context) - 1 boundary_idx = len(previous_context) - 1
boundary_row = result_messages[boundary_idx] boundary_row = result_messages[boundary_idx]
is_stale_merge = ( is_stale_merge = bool(
previous_user_tail previous_user_tail
and _detect_stale_user_merge(boundary_row, msg_text, previous_user_tail) and _detect_stale_user_merge(
boundary_row,
msg_text,
previous_user_tail,
previous_context=previous_context,
)
) )
if is_stale_merge or _looks_like_current_user_turn(boundary_row, msg_text): if is_stale_merge or _looks_like_current_user_turn(boundary_row, msg_text):
if is_stale_merge: if is_stale_merge:
@@ -3873,6 +3878,7 @@ def _dedupe_replayed_context_messages(previous_context, result_messages, msg_tex
candidates, candidates,
msg_text, msg_text,
previous_user_tail, previous_user_tail,
previous_context=previous_context,
) )
candidates = _strip_replayed_prefix(previous_context, candidates) candidates = _strip_replayed_prefix(previous_context, candidates)
if candidates: if candidates:
@@ -4030,40 +4036,131 @@ def _last_user_row(messages):
return None return None
def _detect_stale_user_merge(message, msg_text, previous_user_tail): def _stale_prefix_matches_prior_user_context(stale_prefix, stale_segments, previous_context):
"""Return True when a stale prefix is explainable by prior user context.
First-generation repair usually produces segments matching consecutive
prior user rows. Once a session is already contaminated, later repair can
replay a stable stale prefix from an older polluted row even after newer
clean user turns have moved the context tail forward. Handle both shapes
while still requiring all evidence to come from prior user-role rows.
"""
prior_rows = [
_stale_user_tail_candidate(msg)
for msg in previous_context or []
]
prior_rows = [row for row in prior_rows if row]
if not prior_rows:
return False
if stale_segments:
segment_count = len(stale_segments)
for start in range(0, len(prior_rows) - segment_count + 1):
if prior_rows[start:start + segment_count] == stale_segments:
return True
# Already-polluted sessions may replay paragraphs from older polluted
# rows after newer clean turns have advanced the context tail. In that
# shape the stale paragraphs are still all prior user content, but they
# are substrings within older merged rows rather than standalone rows.
row_index = 0
row_offset = 0
matched_all_segments = True
for segment in stale_segments:
matched_segment = False
while row_index < len(prior_rows):
row = prior_rows[row_index]
pos = row.find(segment, row_offset)
if pos >= 0:
row_offset = pos + len(segment)
matched_segment = True
break
row_index += 1
row_offset = 0
if not matched_segment:
matched_all_segments = False
break
if matched_all_segments:
return True
prefix_norm = _normalize_user_text(stale_prefix)
if not prefix_norm:
return False
for row in prior_rows:
if row == prefix_norm or row.startswith(f'{prefix_norm} '):
return True
return False
def _detect_stale_user_merge(message, msg_text, previous_user_tail, previous_context=None):
"""Return True if `message` is the current user turn with a stale prefix merged in. """Return True if `message` is the current user turn with a stale prefix merged in.
The agent's defensive repair path can concatenate the prior context-tail The agent's defensive repair path can concatenate prior user context with
user row with the submitted current turn as ``<stale>\\n\\n<current>``. the submitted current turn as ``<stale>\\n\\n<current>``. The stale portion
The literal ``\\n\\n`` boundary must survive into the comparison; a can be either the immediate prior user tail or a replayed prefix from an
single-newline or space-only join is not the repair shape and must not older already-polluted user row. The literal ``\\n\\n`` boundary must survive
match. Workspace sentinels may be present on either or both halves and into the comparison; a single-newline or space-only join is not the repair
are stripped before comparison. shape and must not match. Workspace sentinels may be present on either or
both halves and are stripped before comparison.
""" """
if not isinstance(message, dict) or message.get('role') != 'user': if not isinstance(message, dict) or message.get('role') != 'user':
return False return False
current_norm = _normalize_user_text(msg_text) current_norm = _normalize_user_text(msg_text)
if not current_norm: if not current_norm:
return False return False
tail_norm = _normalize_user_text(previous_user_tail) if previous_user_tail else ""
if not tail_norm or len(tail_norm) < 2: merged = _raw_message_text(message.get('content', '')).replace("\r\n", "\n")
if "\n\n" not in merged:
return False return False
merged = _raw_message_text(message.get('content', ''))
if not merged: # The user's current turn can itself contain paragraph breaks. Find a repair
# boundary whose suffix normalizes to the *entire* submitted turn, then treat
# only the prefix as stale context. A plain split-and-last-segment check would
# miss ``<stale>\n\n<current paragraph A>\n\n<current paragraph B>``.
stale_segments = []
stale_prefix = ''
search_end = len(merged)
while search_end > 0:
boundary_idx = merged.rfind("\n\n", 0, search_end)
if boundary_idx < 0:
break
suffix = merged[boundary_idx + 2:]
if _normalize_user_text(suffix) == current_norm:
prefix = merged[:boundary_idx]
candidate_segments = [
_normalize_user_text(segment)
for segment in prefix.split("\n\n")
]
if candidate_segments and all(candidate_segments):
stale_segments = candidate_segments
stale_prefix = prefix
break
# The suffix matched the submitted turn, but this boundary leaves
# blank prefix segments; try the next candidate boundary to the left.
search_end = boundary_idx
if not stale_segments:
return False return False
# Require the literal \n\n repair boundary to be present after sentinel
# stripping. Check both the raw text and the workspace-stripped text so if previous_context is not None and _stale_prefix_matches_prior_user_context(
# that workspace-prefixed rows on either or both halves still match. stale_prefix,
for candidate in (merged, _strip_workspace_prefixes_for_compare(merged)): stale_segments,
if '\n\n' not in candidate: previous_context,
continue ):
head, _, rest = candidate.partition('\n\n') return True
if _normalize_user_text(head) == tail_norm and _normalize_user_text(rest) == current_norm:
return True return bool(
return False previous_context is None
and len(stale_segments) == 1
and _normalize_user_text(previous_user_tail) == stale_segments[0]
)
def _strip_stale_user_merge_from_messages(messages, msg_text, previous_user_tail): def _strip_stale_user_merge_from_messages(
messages,
msg_text,
previous_user_tail,
previous_context=None,
):
"""Return messages with stale-prefixed current user turns replaced by clean ones. """Return messages with stale-prefixed current user turns replaced by clean ones.
Both context-merge (model-facing) and display-merge (visible transcript) Both context-merge (model-facing) and display-merge (visible transcript)
@@ -4075,7 +4172,12 @@ def _strip_stale_user_merge_from_messages(messages, msg_text, previous_user_tail
return messages return messages
out = [] out = []
for msg in messages: for msg in messages:
if _detect_stale_user_merge(msg, msg_text, previous_user_tail): if _detect_stale_user_merge(
msg,
msg_text,
previous_user_tail,
previous_context=previous_context,
):
cleaned = copy.deepcopy(msg) if isinstance(msg, dict) else {'role': 'user', 'content': msg_text} cleaned = copy.deepcopy(msg) if isinstance(msg, dict) else {'role': 'user', 'content': msg_text}
cleaned['content'] = msg_text cleaned['content'] = msg_text
out.append(cleaned) out.append(cleaned)
@@ -4403,6 +4505,7 @@ def _merge_display_messages_after_agent_result(previous_display, previous_contex
candidates, candidates,
msg_text, msg_text,
previous_user_tail, previous_user_tail,
previous_context=previous_context,
) )
candidates = _strip_replayed_prefix(previous_display, candidates) candidates = _strip_replayed_prefix(previous_display, candidates)
candidates = _strip_replayed_prefix(previous_context, candidates) candidates = _strip_replayed_prefix(previous_context, candidates)
@@ -4419,6 +4522,7 @@ def _merge_display_messages_after_agent_result(previous_display, previous_contex
turn_candidates, turn_candidates,
msg_text, msg_text,
previous_user_tail, previous_user_tail,
previous_context=previous_context,
) )
candidates = marker_candidates + turn_candidates candidates = marker_candidates + turn_candidates
@@ -16,6 +16,10 @@ import pytest
PRIOR_TAIL = "please use the larger context model" PRIOR_TAIL = "please use the larger context model"
CURRENT_TURN = "can you summarize the release blockers?" CURRENT_TURN = "can you summarize the release blockers?"
POLLUTED = f"{PRIOR_TAIL}\n\n{CURRENT_TURN}" POLLUTED = f"{PRIOR_TAIL}\n\n{CURRENT_TURN}"
CHAIN_TAIL_A = "could you re-check the deployment checklist"
CHAIN_TAIL_B = "also, ignore the earlier correction request"
CHAIN_CURRENT = "start from the latest verified state only"
CHAIN_POLLUTED = f"{CHAIN_TAIL_A}\n\n{CHAIN_TAIL_B}\n\n{CHAIN_CURRENT}"
def _text(value): def _text(value):
@@ -55,6 +59,213 @@ def test_detect_stale_user_merge_does_not_match_when_tail_differs():
assert _detect_stale_user_merge(other_msg, CURRENT_TURN, PRIOR_TAIL) is False assert _detect_stale_user_merge(other_msg, CURRENT_TURN, PRIOR_TAIL) is False
def test_detect_stale_user_merge_prefers_context_over_mismatched_tail_fallback():
"""When previous_context is supplied, a stale row must be supported by that context."""
from api.streaming import _detect_stale_user_merge
polluted_msg = {"role": "user", "content": POLLUTED}
previous_context = [{"role": "user", "content": "different prior context"}]
assert _detect_stale_user_merge(
polluted_msg,
CURRENT_TURN,
PRIOR_TAIL,
previous_context=previous_context,
) is False
def test_detect_stale_user_merge_matches_multihop_chain_with_context_history():
"""Multi-hop stale merges match when historical user context is contiguous."""
from api.streaming import _detect_stale_user_merge
previous_context = [
{"role": "user", "content": CHAIN_TAIL_A},
{"role": "assistant", "content": "acknowledged"},
{"role": "user", "content": CHAIN_TAIL_B},
]
polluted_msg = {"role": "user", "content": CHAIN_POLLUTED}
assert _detect_stale_user_merge(
polluted_msg,
CHAIN_CURRENT,
CHAIN_TAIL_B,
previous_context=previous_context,
) is True
def test_detect_stale_user_merge_rejects_multihop_chain_without_matching_history():
"""A matching current row is not enough when stale segments do not align."""
from api.streaming import _detect_stale_user_merge
previous_context = [
{"role": "user", "content": "different first tail"},
{"role": "assistant", "content": "skip"},
{"role": "user", "content": CHAIN_TAIL_B},
]
polluted_msg = {"role": "user", "content": CHAIN_POLLUTED}
assert _detect_stale_user_merge(
polluted_msg,
CHAIN_CURRENT,
CHAIN_TAIL_B,
previous_context=previous_context,
) is False
def test_detect_stale_user_merge_preserves_legitimate_multisection_current_turn():
"""A user-authored multi-section current prompt is not the stale repair shape."""
from api.streaming import _detect_stale_user_merge
previous_context = [
{"role": "user", "content": CHAIN_TAIL_A},
{"role": "assistant", "content": "acknowledged"},
{"role": "user", "content": CHAIN_TAIL_B},
]
current_prompt = f"{CHAIN_TAIL_A}\n\n{CHAIN_TAIL_B}\n\n{CHAIN_CURRENT}"
current_msg = {"role": "user", "content": current_prompt}
assert _detect_stale_user_merge(
current_msg,
current_prompt,
CHAIN_TAIL_B,
previous_context=previous_context,
) is False
def test_detect_stale_user_merge_handles_single_hop_current_turn_with_paragraphs():
"""One-hop stale repair still matches when the submitted turn has paragraphs."""
from api.streaming import _detect_stale_user_merge
current_prompt = "summarize this first\n\nthen list the risks"
polluted_msg = {"role": "user", "content": f"{PRIOR_TAIL}\n\n{current_prompt}"}
assert _detect_stale_user_merge(
polluted_msg,
current_prompt,
PRIOR_TAIL,
) is True
def test_detect_stale_user_merge_handles_multihop_current_turn_with_paragraphs():
"""Multi-hop stale repair uses the full current-turn suffix, not the last paragraph."""
from api.streaming import _detect_stale_user_merge
previous_context = [
{"role": "user", "content": CHAIN_TAIL_A},
{"role": "assistant", "content": "acknowledged"},
{"role": "user", "content": CHAIN_TAIL_B},
]
current_prompt = "summarize this first\n\nthen list the risks"
polluted_msg = {
"role": "user",
"content": f"{CHAIN_TAIL_A}\n\n{CHAIN_TAIL_B}\n\n{current_prompt}",
}
assert _detect_stale_user_merge(
polluted_msg,
current_prompt,
CHAIN_TAIL_B,
previous_context=previous_context,
) is True
def test_detect_stale_user_merge_handles_replayed_prefix_from_old_polluted_row():
"""Already-contaminated sessions can replay an old prefix after newer clean turns."""
from api.streaming import _detect_stale_user_merge
stable_prefix = f"{CHAIN_TAIL_A}\n\n{CHAIN_TAIL_B}"
previous_context = [
{"role": "user", "content": f"{stable_prefix}\n\nold follow-up"},
{"role": "assistant", "content": "answered the old follow-up"},
{"role": "user", "content": "newer clean question after the polluted row"},
{"role": "assistant", "content": "answered the newer clean question"},
]
current_turn = "latest question that should stand alone"
polluted_msg = {"role": "user", "content": f"{stable_prefix}\n\n{current_turn}"}
assert _detect_stale_user_merge(
polluted_msg,
current_turn,
"newer clean question after the polluted row",
previous_context=previous_context,
) is True
def test_detect_stale_user_merge_handles_segments_replayed_from_multiple_old_rows():
"""Stale paragraphs can be assembled from earlier polluted rows, not only one row."""
from api.streaming import _detect_stale_user_merge
old_correction = "remove the reassurance sentence"
attachment_request = "send that with the attached image"
thread_check = "confirm it was sent in the right thread"
salary_question = "what is the average salary there"
current_turn = "should we mention the prior product by name?"
previous_context = [
{"role": "user", "content": f"{old_correction}\n\n{attachment_request}\n\nold current"},
{"role": "assistant", "content": "answered the old current"},
{"role": "user", "content": f"{thread_check}\n\n{salary_question}"},
{"role": "assistant", "content": "answered salary"},
{"role": "user", "content": "newer clean question"},
]
polluted_msg = {
"role": "user",
"content": (
f"{old_correction}\n\n{attachment_request}\n\n{thread_check}\n\n"
f"{salary_question}\n\n{current_turn}"
),
}
assert _detect_stale_user_merge(
polluted_msg,
current_turn,
"newer clean question",
previous_context=previous_context,
) is True
def test_detect_stale_user_merge_rejects_replayed_segments_in_wrong_order():
"""Prior user substrings must explain stale paragraphs in chronological order."""
from api.streaming import _detect_stale_user_merge
first = "first stale paragraph"
second = "second stale paragraph"
current_turn = "latest clean question"
previous_context = [
{"role": "user", "content": second},
{"role": "assistant", "content": "answered second"},
{"role": "user", "content": first},
]
polluted_msg = {"role": "user", "content": f"{first}\n\n{second}\n\n{current_turn}"}
assert _detect_stale_user_merge(
polluted_msg,
current_turn,
first,
previous_context=previous_context,
) is False
def test_detect_stale_user_merge_handles_prior_row_starting_with_stale_prefix():
"""A stable stale prefix may be a leading subset of one older polluted row."""
from api.streaming import _detect_stale_user_merge
stable_prefix = f"{CHAIN_TAIL_A}\n\n{CHAIN_TAIL_B}"
current_turn = "latest question that should stand alone"
previous_context = [
{"role": "user", "content": f"{stable_prefix}\n\nolder different follow-up"},
{"role": "assistant", "content": "answered older follow-up"},
{"role": "user", "content": "newer clean question"},
]
polluted_msg = {"role": "user", "content": f"{stable_prefix}\n\n{current_turn}"}
assert _detect_stale_user_merge(
polluted_msg,
current_turn,
"newer clean question",
previous_context=previous_context,
) is True
def test_detect_stale_user_merge_ignores_non_user_roles(): def test_detect_stale_user_merge_ignores_non_user_roles():
"""Only user rows are candidates for the repair-merge pattern.""" """Only user rows are candidates for the repair-merge pattern."""
from api.streaming import _detect_stale_user_merge from api.streaming import _detect_stale_user_merge
@@ -138,6 +349,31 @@ def test_strip_stale_user_merge_handles_list_content_row():
assert cleaned[1] == messages[1] assert cleaned[1] == messages[1]
def test_strip_stale_user_merge_from_messages_replaces_multihop_polluted_row():
"""Cleaner should replace a multi-hop polluted row with the clean current text."""
from api.streaming import _strip_stale_user_merge_from_messages
previous_context = [
{"role": "user", "content": CHAIN_TAIL_A},
{"role": "assistant", "content": "done"},
{"role": "user", "content": CHAIN_TAIL_B},
]
messages = [
{"role": "user", "content": CHAIN_POLLUTED},
{"role": "assistant", "content": "pushing now"},
]
cleaned = _strip_stale_user_merge_from_messages(
messages,
CHAIN_CURRENT,
CHAIN_TAIL_B,
previous_context=previous_context,
)
assert cleaned[0]["content"] == CHAIN_CURRENT
assert cleaned[1] == messages[1]
def test_strip_stale_user_merge_does_not_touch_clean_rows(): def test_strip_stale_user_merge_does_not_touch_clean_rows():
"""Clean rows, assistant rows, and tool rows must pass through untouched.""" """Clean rows, assistant rows, and tool rows must pass through untouched."""
from api.streaming import _strip_stale_user_merge_from_messages from api.streaming import _strip_stale_user_merge_from_messages
@@ -255,6 +491,41 @@ def test_dedupe_replayed_context_handles_repair_replaced_tail_user_row():
) )
def test_dedupe_replayed_context_handles_multihop_repair_replaced_tail_row():
"""When repair replaces last tail row with a multi-hop merge, history is preserved."""
from api.streaming import _dedupe_replayed_context_messages
previous_context = [
{"role": "user", "content": CHAIN_TAIL_A},
{"role": "assistant", "content": "done"},
{"role": "user", "content": CHAIN_TAIL_B},
]
result_messages = [
previous_context[0],
previous_context[1],
{"role": "user", "content": CHAIN_POLLUTED},
{"role": "assistant", "content": "pushing now"},
]
cleaned = _dedupe_replayed_context_messages(
previous_context,
result_messages,
CHAIN_CURRENT,
)
assert [m.get("content") for m in cleaned] == [
CHAIN_TAIL_A,
"done",
CHAIN_TAIL_B,
CHAIN_CURRENT,
"pushing now",
]
assert not any(
isinstance(m, dict) and CHAIN_POLLUTED in _text(m.get("content", ""))
for m in cleaned
)
def test_merge_display_drops_polluted_current_when_eager_checkpoint_clean(): def test_merge_display_drops_polluted_current_when_eager_checkpoint_clean():
"""Display merge must not append a polluted row next to a clean eager checkpoint. """Display merge must not append a polluted row next to a clean eager checkpoint.
@@ -305,6 +576,94 @@ def test_merge_display_drops_polluted_current_when_eager_checkpoint_clean():
), "Assistant response must still be appended" ), "Assistant response must still be appended"
def test_merge_display_drops_multihop_polluted_current_when_eager_checkpoint_clean():
"""Multi-hop stale shape is normalized before eager checkpoint dedupe logic."""
from api.streaming import _merge_display_messages_after_agent_result
previous_display = [
{"role": "user", "content": CHAIN_TAIL_A},
{"role": "assistant", "content": "done"},
{"role": "user", "content": CHAIN_CURRENT}, # eager checkpoint
]
previous_context = [
{"role": "user", "content": CHAIN_TAIL_A},
{"role": "assistant", "content": "done"},
{"role": "user", "content": CHAIN_TAIL_B},
]
result_messages = [
*previous_context,
{"role": "user", "content": CHAIN_POLLUTED},
{"role": "assistant", "content": "pushing now"},
]
merged = _merge_display_messages_after_agent_result(
previous_display,
previous_context,
result_messages,
CHAIN_CURRENT,
)
user_texts = [
_text(m.get("content", "")).strip()
for m in merged
if isinstance(m, dict) and m.get("role") == "user"
]
assert user_texts.count(CHAIN_CURRENT) == 1, (
f"Should keep exactly one clean current user row; got user rows: {user_texts}"
)
assert not any(CHAIN_POLLUTED in t for t in user_texts), (
f"Multi-hop polluted row must be removed from display; got: {user_texts}"
)
def test_merge_display_drops_replayed_old_prefix_after_newer_clean_turn():
"""Visible transcript drops stale prefixes replayed from older polluted rows."""
from api.streaming import _merge_display_messages_after_agent_result
stable_prefix = f"{CHAIN_TAIL_A}\n\n{CHAIN_TAIL_B}"
newer_clean = "newer clean question after the polluted row"
current_turn = "latest question that should stand alone"
polluted_current = f"{stable_prefix}\n\n{current_turn}"
previous_display = [
{"role": "user", "content": f"{stable_prefix}\n\nold follow-up"},
{"role": "assistant", "content": "answered old follow-up"},
{"role": "user", "content": newer_clean},
{"role": "assistant", "content": "answered newer clean question"},
{"role": "user", "content": current_turn}, # eager checkpoint
]
previous_context = previous_display[:-1]
result_messages = [
*previous_context,
{"role": "user", "content": polluted_current},
{"role": "assistant", "content": "latest answer"},
]
merged = _merge_display_messages_after_agent_result(
previous_display,
previous_context,
result_messages,
current_turn,
)
user_texts = [
_text(m.get("content", "")).strip()
for m in merged
if isinstance(m, dict) and m.get("role") == "user"
]
assert user_texts.count(current_turn) == 1, (
f"Should keep one clean current user row; got user rows: {user_texts}"
)
assert polluted_current not in user_texts, (
f"Replayed stale prefix must not be displayed as a user row; got: {user_texts}"
)
assert any(
isinstance(m, dict)
and m.get("role") == "assistant"
and "latest answer" in _text(m.get("content", ""))
for m in merged
), "Assistant response must still be appended"
def test_merge_display_does_not_overstrip_when_current_already_clean(): def test_merge_display_does_not_overstrip_when_current_already_clean():
"""A legitimately new current turn that mentions the prior phrase stays intact.""" """A legitimately new current turn that mentions the prior phrase stays intact."""
from api.streaming import _merge_display_messages_after_agent_result from api.streaming import _merge_display_messages_after_agent_result