mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-20 15:33:54 +00:00
fix: drop empty user turns from MoA advisory view (strict-provider 400)
MoA's _reference_messages() unconditionally appended every user-role
message to the advisory view sent to reference models, even when the
message content was an empty string or a non-string/multimodal payload
that the text-extraction step flattens to "".
Strict providers (Kimi/Moonshot, and others that enforce non-empty user
content) reject such a message with:
400 Invalid request: the message at position N with role 'user'
must not be empty
Lenient providers (DeepSeek) accept it, so an identical rendered view
passes on one reference and 400s on another within the same fan-out —
the user sees "kimi doesn't support MoA" when the real cause is an empty
user turn leaking into the advisory transcript.
Skip empty user turns, mirroring the existing behavior for empty
assistant turns (which are already dropped when they carry no parts).
The end-on-user invariant is preserved: the synthetic advisory-request
user turn is still appended when the view would otherwise end on an
assistant turn.
Adds a regression test asserting the advisory view contains no empty
user turn and still ends on a user turn.
This commit is contained in:
parent
4e6e5181c6
commit
b4c2c4f922
2 changed files with 52 additions and 2 deletions
|
|
@ -501,8 +501,20 @@ def _reference_messages(messages: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
|||
# a placeholder so the reference knows a non-text turn
|
||||
# happened.
|
||||
text = "[user sent non-text content (e.g. an image attachment)]"
|
||||
if text.strip():
|
||||
last_user_content = text
|
||||
if not text.strip():
|
||||
# Genuinely empty user turn (content="" / None). It carries
|
||||
# nothing advisory, and strict providers (Kimi/Moonshot, ZAI,
|
||||
# and others that enforce non-empty user content) reject it
|
||||
# with 400 "message ... with role 'user' must not be empty" —
|
||||
# the same way the assistant branch below drops turns with no
|
||||
# parts. Lenient providers (DeepSeek) accept the empty turn,
|
||||
# which is why a MoA fan-out would fail on one reference and
|
||||
# pass on another for the identical rendered view. The
|
||||
# advisory view is already not strictly alternating (adjacent
|
||||
# assistant turns occur in every tool loop), so dropping a
|
||||
# contentless turn is safe.
|
||||
continue
|
||||
last_user_content = text
|
||||
rendered.append({"role": "user", "content": text})
|
||||
elif role == "assistant":
|
||||
parts: list[str] = []
|
||||
|
|
|
|||
|
|
@ -392,6 +392,44 @@ def test_reference_messages_fresh_user_turn_ends_on_that_user():
|
|||
assert view[-1] == {"role": "user", "content": "q2 current"}
|
||||
|
||||
|
||||
def test_reference_messages_drops_empty_user_turns():
|
||||
"""Empty user turns must not leak into the advisory view.
|
||||
|
||||
A user message whose content is "" or a non-string/multimodal payload
|
||||
(flattened to "" by the text-extraction step) carries nothing advisory.
|
||||
Strict providers (Kimi/Moonshot and others that enforce non-empty user
|
||||
content) reject such a message with
|
||||
400 "message ... with role 'user' must not be empty", while lenient
|
||||
providers (DeepSeek) accept it — so a fan-out over the identical rendered
|
||||
view fails on one reference and passes on another. The renderer must emit
|
||||
NO empty user turn, mirroring how empty assistant turns are dropped.
|
||||
"""
|
||||
from agent.moa_loop import _reference_messages
|
||||
|
||||
messages = [
|
||||
{"role": "system", "content": "sys"},
|
||||
{"role": "user", "content": "real question"},
|
||||
{"role": "assistant", "content": "", "tool_calls": [
|
||||
{"function": {"name": "read_file", "arguments": '{"path":"c.yaml"}'}}
|
||||
]},
|
||||
{"role": "tool", "content": "some result"},
|
||||
{"role": "user", "content": ""}, # empty string user turn
|
||||
{"role": "user", "content": [{"type": "text", "text": "multimodal"}]}, # non-string -> ""
|
||||
]
|
||||
|
||||
view = _reference_messages(messages)
|
||||
|
||||
# No user turn in the view may be empty/whitespace-only.
|
||||
empty_users = [
|
||||
m for m in view
|
||||
if m.get("role") == "user" and not str(m.get("content", "")).strip()
|
||||
]
|
||||
assert empty_users == [], f"empty user turn leaked into advisory view: {empty_users}"
|
||||
# The real user prompt survives and the view still ends on a user turn.
|
||||
assert view[0] == {"role": "user", "content": "real question"}
|
||||
assert view[-1]["role"] == "user"
|
||||
|
||||
|
||||
def test_run_reference_prepends_advisory_system_prompt(monkeypatch):
|
||||
"""Each reference call gets the advisory-role system prompt first.
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue