fix: reconcile state db delta after context

This commit is contained in:
Lumen Yang 2026-05-21 06:47:44 +00:00 committed by nesquena-hermes
parent 15cde132f3
commit 50c69713cc
3 changed files with 328 additions and 0 deletions

View file

@ -3235,6 +3235,58 @@ def _has_visible_duplicate(visible_key: tuple, visible_keys: set[tuple]) -> bool
return _matching_visible_duplicate(visible_key, visible_keys) is not None
def state_db_delta_after_context(sidecar_context: list, state_messages: list) -> list:
"""Return only state.db rows that are newer than model-facing context.
`context_messages` is the authoritative model-facing prefix. state.db may
contain a mirrored copy of that prefix with fresh timestamps, especially for
LCM/continuation sessions. Appending the whole state transcript to a clean
sidecar context replays old context into the next runtime prompt.
"""
sidecar_context = list(sidecar_context or [])
state_messages = list(state_messages or [])
if not sidecar_context or not state_messages:
return state_messages
sidecar_keys = [_session_message_content_key(m) for m in sidecar_context]
state_keys = [_session_message_content_key(m) for m in state_messages]
max_offset = min(len(sidecar_keys), len(state_keys))
best_len = 0
for offset in range(max_offset):
length = 0
while (
offset + length < len(sidecar_keys)
and length < len(state_keys)
and sidecar_keys[offset + length] == state_keys[length]
):
length += 1
if length > best_len:
best_len = length
# Require at least two mirrored rows. A single repeated short user message
# is not enough evidence that state.db starts with a mirrored context
# segment, but small recovered contexts often contain only a compact summary
# and one follow-up row; those should still use the delta path.
if best_len < 2:
return state_messages
sidecar_key_set = set(sidecar_keys)
last_represented_state_index = best_len - 1
for idx, key in enumerate(state_keys[best_len:], start=best_len):
if key in sidecar_key_set:
last_represented_state_index = idx
delta = []
for msg, key in zip(
state_messages[last_represented_state_index + 1:],
state_keys[last_represented_state_index + 1:],
):
if key in sidecar_key_set:
continue
delta.append(msg)
return delta
def merge_session_messages_append_only(sidecar_messages: list, state_messages: list) -> list:
"""Merge sidecar/context and state.db messages without deleting local rows."""
sidecar_messages = list(sidecar_messages or [])
@ -3340,6 +3392,8 @@ def reconciled_state_db_messages_for_session(
local_messages = getattr(session, 'messages', None) or []
if state_messages is None:
state_messages = get_state_db_session_messages(getattr(session, 'session_id', None))
if prefer_context and local_messages:
state_messages = state_db_delta_after_context(local_messages, state_messages)
return merge_session_messages_append_only(local_messages, state_messages)

View file

@ -0,0 +1,143 @@
# Context replay / progress-ring failure cases
This artifact records concrete failure cases observed while debugging WebUI + LCM context replay. It is intended to support an upstream PR with reproducible rationale.
## A. Compression continuation replays active tail into display/context
Observed shape after compression/continuation:
```text
previous_context = [summary, A, B, C]
result_messages = previous_context + [A, B, C, D]
old saved context/display = [summary, A, B, C, A, B, C, D]
expected = [summary, A, B, C, D]
```
Code-level cause: writeback assumed `result_messages[len(previous_context):]` was all new delta. After LCM/session rollover, the agent may replay the active tail after the compacted prefix, so this assumption is false.
Regression target: strip candidate prefixes that are already suffixes of existing context/display.
## B. Near-duplicate large Session Arc Summary cards
Observed in WebUI sidecar display transcript for sessions in the `20260520_200424_a43cef` / `20260520_201320_a95eac` lineage:
```text
[Session Arc Summary (d1, node 39)] ... 62k chars
[Session Arc Summary (d1, node 39)] ... 62k chars
[Session Arc Summary (d1, node 39)] ... 74k chars
```
These summaries shared thousands of identical prefix characters but differed in tails/expand hints. Exact identity checks missed them. One duplicated ~80k char summary explains a ~20k-token jump.
Regression target: treat large `[Session Arc Summary ...]` messages with the same long prefix as replayed summary artifacts.
## C. Non-adjacent replay blocks separated by markers/summaries
Observed display transcript contained repeated blocks that were not immediately adjacent, e.g. best block lengths around 171 messages in historical `messages`:
```text
A B C ... [compression marker / summary / unrelated rows] ... A B C
```
Adjacent-only dedupe falsely reported clean. This matters because LCM/continuation can insert compression cards, cron banners, or summary messages between original block and replayed tail.
Regression target: detect and strip replayed non-adjacent blocks when appending model context candidates.
## D. Non-streaming `/api/chat` writeback missed dedupe
Observed session: `20260521_060755_294aed`.
User asked a short question with no meaningful new tool usage:
```text
这是一个内部服务对么?简答
```
Before cleanup:
```text
context_messages: 136
best replay: 67 messages repeated from index 0 at index 67
last_prompt_tokens: 136668 (~53.4% of 256k)
```
Expected shape was:
```text
previous_context(67) + new_user + new_assistant = 69 messages
```
Actual cause: streaming writeback used `_dedupe_replayed_context_messages`, but synchronous `/api/chat` wrote `_restore_reasoning_metadata(previous_context, result_messages)` directly to `s.context_messages`.
Regression target: both streaming and non-streaming writeback paths must use the same replay-dedupe guard.
## E. Runtime/progress-ring jump: clean persisted context, polluted turn-start reconciliation
Observed session: `20260521_060755_294aed` after cleanup and deployment.
Persisted sidecar after pause/cancel:
```text
context_messages: 69
context chars: 199,972
rough content tokens: ~49,993
last_prompt_tokens: 86,723 (~33.9%)
```
But starting/continuing a streaming turn made the progress ring jump to ~55%. Simulating the turn-start code path showed:
```text
ctx_before_agent: 154 messages
chars: 448,438
rough content tokens: ~112,109
```
After applying existing final-writeback dedupe to that runtime prompt:
```text
after_current_dedupe: 85 messages
chars: 248,466
rough content tokens: ~62,116
```
So the ring was not randomly wrong: it reflected a polluted runtime prompt estimate. The persisted sidecar stayed clean because final writeback/cancel did not save the runtime replay.
Code-level cause: streaming turn start does:
```python
_previous_context_messages = _new_turn_context_from_messages(
reconciled_state_db_messages_for_session(
s,
prefer_context=True,
state_messages=_external_state_messages,
),
msg_text,
)
```
When `prefer_context=True`, sidecar `context_messages` are clean, but `state.db` still contains mirrored/replayed transcript rows. `reconciled_state_db_messages_for_session` append-only merges `context_messages + whole state transcript`, so the agent/runtime prompt temporarily receives old transcript rows again.
Regression target: when `prefer_context=True` and sidecar `context_messages` exists, reconciliation must return:
```text
clean sidecar context + truly newer state.db delta
```
not:
```text
clean sidecar context + full state.db transcript
```
## PR thesis
The bug family is not a model behavior issue. It is a WebUI persistence/reconciliation invariant violation:
> Model-facing context is append-only, but append candidates may contain replayed context due to LCM/session continuation/state-db mirroring. Every boundary that merges result/state messages into model context must strip replayed prefixes/blocks and must distinguish clean sidecar context from full display/state transcripts.
Key invariant for upstream:
```text
If context_messages exists, it is the authoritative model-facing prefix.
State/db/display histories may be fuller/noisier and should only contribute messages that are demonstrably newer than that prefix.
```

View file

@ -371,6 +371,137 @@ def test_non_adjacent_replayed_context_block_is_not_appended_again():
]
def test_prefer_context_reconcile_strips_state_db_mirrored_prefix():
sidecar_context = [
{"role": "assistant", "content": "cron banner"},
{"role": "user", "content": "[Session Arc Summary]"},
{"role": "assistant", "content": "old answer"},
{"role": "user", "content": "latest saved question", "timestamp": 200.0},
{"role": "assistant", "content": "latest saved answer", "timestamp": 201.0},
]
state_messages = [
{"role": "assistant", "content": "cron banner", "timestamp": 100.0},
{"role": "user", "content": "[Session Arc Summary]", "timestamp": 101.0},
{"role": "assistant", "content": "old answer", "timestamp": 102.0},
{"role": "user", "content": "latest saved question", "timestamp": 200.0},
{"role": "assistant", "content": "latest saved answer", "timestamp": 201.0},
{"role": "user", "content": "continue", "timestamp": 300.0},
]
session = SimpleNamespace(
session_id="prefix-replay",
context_messages=sidecar_context,
messages=[],
)
reconciled = reconciled_state_db_messages_for_session(
session, prefer_context=True, state_messages=state_messages
)
assert reconciled == sidecar_context + [
{"role": "user", "content": "continue", "timestamp": 300.0},
]
def test_prefer_context_reconcile_keeps_state_delta_when_no_mirrored_prefix():
sidecar_context = [
{"role": "user", "content": "summary"},
{"role": "assistant", "content": "saved answer"},
]
state_messages = [
{"role": "user", "content": "fresh question", "timestamp": 300.0},
{"role": "assistant", "content": "fresh answer", "timestamp": 301.0},
]
session = SimpleNamespace(
session_id="fresh-delta",
context_messages=sidecar_context,
messages=[],
)
reconciled = reconciled_state_db_messages_for_session(
session, prefer_context=True, state_messages=state_messages
)
assert reconciled == sidecar_context + state_messages
def test_prefer_context_reconcile_strips_mirrored_rows_without_sidecar_timestamps():
sidecar_context = [
{"role": "assistant", "content": "cron banner"},
{"role": "user", "content": "summary"},
{"role": "assistant", "content": "older answer"},
{"role": "user", "content": "already saved"},
]
state_messages = [
{"role": "assistant", "content": "cron banner", "timestamp": 100.0},
{"role": "user", "content": "summary", "timestamp": 101.0},
{"role": "assistant", "content": "older answer", "timestamp": 102.0},
{"role": "user", "content": "already saved", "timestamp": 500.0},
{"role": "user", "content": "new after sidecar", "timestamp": 600.0},
]
session = SimpleNamespace(
session_id="untimestamped-context",
context_messages=sidecar_context,
messages=[],
)
reconciled = reconciled_state_db_messages_for_session(
session, prefer_context=True, state_messages=state_messages
)
assert reconciled == sidecar_context + [
{"role": "user", "content": "new after sidecar", "timestamp": 600.0},
]
def test_prefer_context_reconcile_starts_after_last_state_row_seen_in_context():
sidecar_context = [
{"role": "assistant", "content": "cron banner"},
{"role": "user", "content": "summary"},
{"role": "assistant", "content": "old answer"},
{"role": "assistant", "content": "later represented row"},
]
state_messages = [
{"role": "assistant", "content": "cron banner", "timestamp": 100.0},
{"role": "user", "content": "summary", "timestamp": 101.0},
{"role": "assistant", "content": "old answer", "timestamp": 102.0},
{"role": "tool", "content": "state-only stale tool", "timestamp": 103.0},
{"role": "assistant", "content": "later represented row", "timestamp": 104.0},
{"role": "user", "content": "new after represented boundary", "timestamp": 200.0},
]
session = SimpleNamespace(
session_id="represented-boundary",
context_messages=sidecar_context,
messages=[],
)
reconciled = reconciled_state_db_messages_for_session(
session, prefer_context=True, state_messages=state_messages
)
assert reconciled == sidecar_context + [
{"role": "user", "content": "new after represented boundary", "timestamp": 200.0},
]
def test_non_streaming_chat_writeback_dedupes_full_context_replay():
previous_context = [
{"role": "assistant", "content": "cron banner"},
{"role": "user", "content": "[Session Arc Summary (d1, node 39)]\n" + "old context\n" * 400},
{"role": "assistant", "content": "previous answer"},
]
result_messages = previous_context + previous_context + [
{"role": "user", "content": "simple follow-up"},
{"role": "assistant", "content": "short answer"},
]
next_context = _dedupe_replayed_context_messages(previous_context, result_messages)
assert next_context == previous_context + [
{"role": "user", "content": "simple follow-up"},
{"role": "assistant", "content": "short answer"},
]
def test_session_context_falls_back_to_display_messages_for_legacy_sessions(tmp_path):
messages = [
{"role": "user", "content": "legacy prompt"},