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:
neo 2026-07-05 03:21:15 +08:00 committed by Teknium
parent 4e6e5181c6
commit b4c2c4f922
2 changed files with 52 additions and 2 deletions

View file

@ -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] = []

View file

@ -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.