From 24add1db743dec5166048d406d8b8a58555b7697 Mon Sep 17 00:00:00 2001 From: HexLab98 Date: Sun, 5 Jul 2026 16:58:55 +0700 Subject: [PATCH] fix(compressor): keep a user turn when compression would drop the last one MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Compression could produce a transcript with ZERO user-role messages, which OpenAI-compatible backends (vLLM/Qwen) reject with a non-retryable `400 No user query found in messages`. This crashes `hermes kanban` workers unrecoverably: every resume replays the same poisoned history and fails on the very first request after a successful compaction. The existing #52160 guard pins the handoff summary to role="user" only when `last_head_role == "system"` — i.e. when the system prompt sits inside `messages` (the gateway `/compress` path). The main auto-compression path prepends the system prompt at request-build time, so the list handed to `compress()` starts with a user/assistant turn, `last_head_role` defaults to "user", and the summary is emitted as role="assistant". A kanban worker seeded with a single short `"work kanban task "` prompt followed by nothing but assistant/tool turns therefore ends up user-less once that early turn is summarised. Generalise the guard: when no user-role message survives in the protected head or the preserved tail, force the summary to carry role="user" so the request always has at least one user turn. When a user does survive (e.g. in the tail), the guard does not fire, so alternation is preserved. Fixes #58753. --- agent/context_compressor.py | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/agent/context_compressor.py b/agent/context_compressor.py index 9f2b8d18b29..a51a65d11bb 100644 --- a/agent/context_compressor.py +++ b/agent/context_compressor.py @@ -2910,6 +2910,33 @@ This compaction should PRIORITISE preserving all information related to the focu # is not role=user, so we must pin the summary to "user" and # prevent the flip logic below from reverting it (#52160). _force_user_leading = last_head_role == "system" + # Zero-user-turn guard (#58753). The #52160 guard above only fires + # when the system prompt sits *inside* ``messages`` (the gateway + # ``/compress`` path). The main auto-compression path passes the + # transcript WITHOUT the system prompt (it is prepended at + # request-build time), so ``last_head_role`` defaults to "user" and + # the summary is emitted as role="assistant". On a session whose only + # genuine user turn falls into the compressed middle — e.g. a + # ``hermes kanban`` worker seeded with a single short + # ``"work kanban task "`` prompt followed by nothing but + # assistant/tool turns — that leaves the compressed transcript with + # ZERO user-role messages. OpenAI-compatible backends (vLLM/Qwen) + # reject such a request with a non-retryable + # ``400 No user query found in messages``, crashing the worker with no + # possible recovery (every resume replays the same poisoned history). + # If no user-role message survives in either the protected head or the + # preserved tail, the summary MUST carry role="user" so the request + # always has at least one user turn. + if not _force_user_leading: + _user_survives = any( + messages[i].get("role") == "user" + for i in range(0, compress_start) + ) or any( + messages[i].get("role") == "user" + for i in range(compress_end, n_messages) + ) + if not _user_survives: + _force_user_leading = True # Pick a role that avoids consecutive same-role with both neighbors. # Priority: avoid colliding with head (already committed), then tail. if last_head_role in {"assistant", "tool"} or _force_user_leading: