fix(compressor): keep a user turn when compression would drop the last one

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 <id>"` 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.
This commit is contained in:
HexLab98 2026-07-05 16:58:55 +07:00 committed by kshitijk4poor
parent 605727e3b4
commit 24add1db74

View file

@ -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 <id>"`` 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: