Preserving the user turn after context compaction
Cluster 55677 · agent. Data as of
Nothing open on GitHub. Fix merged in #58331 on 4 Jul 2026. Check it covers your case.
The short answer
- Known?
- Yes. 3 related items grouped here, 1 issue already closed.
- Fix event
- A fix was merged to main in PR #58331. Check it covers your case. No release data is tracked.
- Tracker
- The maintainers' tracker marks this
resolved, deciding PR #58331. That is separate from the fix event. - Still open
- Nothing open on GitHub.
The members concern context compaction/compression and a user message being absent afterward. One is a bug report citing a 'No user query found in messages' Jinja template error and session corruption, and two are pull requests with the same title about preserving the user turn, one described as salvaging an earlier change (#58276). The titles do not establish whether the fixes address the reported error.
Open issues0
Open PRs0
Fixes on record
-
#58331 fix(compression): preserve user turn after compaction (salvage #58276)
files:
agent/conversation_compression.py,scripts/release.py,tests/agent/test_compression_concurrent_fork.pyShow the diff
-
agent/conversation_compression.py@@ -391,6 +391,47 @@ def conversation_history_after_compression(agent: Any, messages: list) -> Option return None +def _ensure_compressed_has_user_turn(original_messages: list, compressed: list) -> None: + """Preserve a real user turn when a compressor returns assistant/tool-only context. + + On repeated compaction the protected head decays to the system prompt only, + the middle summary can land as ``role="assistant"``, and a tool-heavy tail + can be all assistant/tool — so the compacted transcript can legitimately + contain zero user messages. Strict chat templates (LM Studio / llama.cpp + Jinja) then fail with "No user query found in messages" (#55677). + + The restored turn is appended at the END: the guard only runs when + ``compressed`` currently ends with an assistant/tool message (any existing + user turn — including a todo-snapshot append — short-circuits the + ``any()`` check), so appending a user message never creates consecutive + same-role messages. ``_fresh_compaction_message_copy`` copies the message + and strips the ``_db_persisted`` marker so the rotation/in-place flush + still persists the restored row to the new session (#57491). + + If the pre-compression transcript itself carried no user turn at all + (near-impossible — every real conversation opens with a user request — + but kept as a defensive backstop), a minimal continuation marker is + appended instead so strict templates still see a user message. + """ + if any(isinstance(msg, dict) and msg.get("role") == "user" for msg in compressed): + return + from agent.context_compressor import _fresh_compaction_message_copy + + for msg in reversed(original_messages): + if not isinstance(msg, dict) or msg.get("role") != "user": + continue + compressed.append(_fresh_compaction_message_copy(msg)) + return + compressed.append({ + "role": "user", + "content": ( + "Continue from the compressed conversation context above. " + "This marker exists because the compacted transcript contained " + "no preserved user turn." + ), + }) + + def compress_context( agent: Any, messages: list, @@ -647,6 +688,7 @@ def _release_lock() -> None: todo_snapshot = agent._todo_store.format_for_injection() if todo_snapshot: compressed.append({"role": "user", "content": todo_snapshot}) + _ensure_compressed_has_user_turn(messages, compressed) agent._invalidate_system_prompt() new_system_prompt = agent._build_system_prompt(system_message) -
scripts/release.py@@ -45,7 +45,7 @@ # Auto-extracted from noreply emails + manual overrides AUTHOR_MAP = { - "[redacted:email]": "msh01", # PR #58250 salvage (telegram: wall-clock init timeout via daemon-thread deadline + abandon the shielded initialize task on timeout so the retry ladder advances instead of hanging on attempt 1/8 under s6 supervision; #58236) + "[redacted:email]": "msh01", # PR #58250 salvage (telegram: wall-clock init timeout via daemon-thread deadline + abandon the shielded initialize task on timeout so the retry ladder advances instead of hanging on attempt 1/8 under s6 supervision; #58236). Also covers PR #58276 salvage (compression: preserve a real user turn after compaction; #55677). "[email protected]": "huanshan5195", # PR #57601 salvage (custom-provider: emit reasoning_effort at the live CustomProfile path so GLM-5.2/ARK/vLLM/Ollama endpoints receive it; + "max" reasoning level) "[redacted:email]": "infinitycrew39", # PR #56431 salvage (honor live vLLM context limits on local endpoints) "[redacted:email]": "CocaKova", # PR #57692 salvage (cron: run jobs under the profile secret scope so get_secret does not fail-close with UnscopedSecretError under profile isolation) -
tests/agent/test_compression_concurrent_fork.py@@ -202,6 +202,40 @@ def test_skipped_compression_returns_messages_unchanged(tmp_path: Path) -> None: agent.context_compressor.compress.assert_not_called() +def test_compression_restores_user_turn_when_compressor_drops_all_users(tmp_path: Path) -> None: + """Provider chat templates need at least one user message after compaction. + + A plugin or future compressor can legally return a compacted context made + only of assistant/tool summary rows. Before the guard in + ``compress_context``, that transcript went straight into the next API call; + LM Studio / llama.cpp Jinja templates then failed with "No user query found + in messages." Preserve the last real user turn from the pre-compression + transcript instead of inventing a new active request. + """ + db = SessionDB(db_path=tmp_path / "state.db") + parent_sid = "NO_USER_AFTER_COMPRESS" + db.create_session(parent_sid, source="cli") + + agent = _build_agent_with_db(db, parent_sid) + agent.context_compressor.compress.side_effect = lambda *_a, **_kw: [ + { + "role": "assistant", + "content": "[CONTEXT COMPACTION] earlier work was summarized", + } + ] + messages = [ + {"role": "user", "content": "first request"}, + {"role": "assistant", "content": "first answer"}, + {"role": "user", "content": "please continue from here"}, + {"role": "assistant", "content": "working"}, + ] + + compressed, _sp = agent._compress_context(messages, "sys", approx_tokens=[redacted:secret-assignment] + + user_messages = [msg for msg in compressed if msg.get("role") == "user"] + assert user_messages == [{"role": "user", "content": "please continue from here"}] + + def test_lock_refresh_keeps_owner_live_past_initial_ttl(tmp_path: Path, monkeypatch) -> None: """The owning compression call must keep its lease alive while it runs.""" real_try_acquire = SessionDB.try_acquire_compression_lock
-
1 PR closed without merging
From the close comments: merged via another change 1
-
#58276 fix(compression): preserve user turn after compaction · merged via another change →
6e176e4,#58331,#57491,#55677
Closed issues · 1
- Closed#55677[Bug] Context compaction fails with 'No user query found in messages' Jinja template error, corrupts session
How this cluster was grouped
-
Link evidence (
structural links throughout): every member is connected by structural evidence: shared changed files, or a closing/superseded reference. -
Pairwise link density
0.67: the share of item pairs that carry a link, counting a structural link in full and a relevance-only link at 0.36.1.00means every pair is linked.
Pick up a Lab task
Check whether a fix covers the open issues here, with your own agent. Bring back evidence for maintainers.
Open the LabAt a glance
- Subsystem
- agent
- Merged PRs
- #58331
- Closed issues
- 1
- Attempts
- 1 PR closed without merging
- Fix link checked
- 4 Jul 2026