fix: reconcile state db delta after context
This commit is contained in:
parent
15cde132f3
commit
50c69713cc
3 changed files with 328 additions and 0 deletions
|
|
@ -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)
|
||||
|
||||
|
||||
|
|
|
|||
143
pr-artifacts/context-replay-failure-cases.md
Normal file
143
pr-artifacts/context-replay-failure-cases.md
Normal 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.
|
||||
```
|
||||
|
|
@ -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"},
|
||||
|
|
|
|||
Loading…
Reference in a new issue