diff --git a/agent/moa_loop.py b/agent/moa_loop.py index 267f33dd7e5a..d8461e921dc2 100644 --- a/agent/moa_loop.py +++ b/agent/moa_loop.py @@ -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] = [] diff --git a/tests/run_agent/test_moa_loop_mode.py b/tests/run_agent/test_moa_loop_mode.py index 094278541762..659f64da7a94 100644 --- a/tests/run_agent/test_moa_loop_mode.py +++ b/tests/run_agent/test_moa_loop_mode.py @@ -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.