Files
hermes-agent/agent/moa_alternation.py
teknium1 40778c74e2 fix: MoA aggregator merges adjacent same-role messages only for destinations that reject them
The aggregator request deliberately ends `user(task), user(guidance)` on
iteration 1 of every turn (#113175) so the whole prefix stays byte-stable
for the provider prompt cache. Strict-alternation chat templates
(llama.cpp / vLLM Jinja templates, Mistral, some OpenRouter routes) 400 on
that adjacency ("Conversation roles must alternate ..."), and the turn
failed. Merging proactively for everyone was declined because it brings
back the byte divergence #113175/#113784 removed and only moves the 400.

Reactive, destination-scoped recovery instead:

- error_classifier: new `FailoverReason.role_alternation` for the vendor
  alternation wordings (checked before the request-validation table since
  the body also carries `invalid_request_error`); same abort+fallback hints
  as format_error so non-MoA consumers behave exactly as before.
- moa_alternation (new sibling): `merge_same_role_messages` (reuses the
  loop's `_merge_user_content`), `destination_key` (base_url|provider,
  model), `is_role_alternation_rejection`.
- moa_loop._call_prepared_aggregator: on that 400, retry ONCE with the
  adjacent user turns merged, remember the destination on the facade for
  the session so later iterations pre-merge, never touch destinations that
  accepted the split shape. The trace records the messages actually sent.
- docs: caching section explains the reactive merge.

Live loopback (real call_llm -> SDK -> HTTP stub that 400s on same-role
adjacency): before, iteration 1 fails with BadRequestError after 1 request;
after, 2 requests (split -> 400 -> merged -> 200), next turn pre-merged in
1 request; the accepting-stub control sends byte-identical requests.

Fixes #112358
2026-09-18 20:56:35 -07:00

65 lines
3.1 KiB
Python

"""Reactive same-role merge for the MoA aggregator request (#112358, last atom).
The aggregator request deliberately ends ``user(task), user(guidance)`` on iteration 1 of a
turn: the guidance is its own trailing message so every earlier message stays byte-stable and
the provider prefix cache keeps growing (``moa_loop._attach_reference_guidance``). Strict-
alternation chat templates (llama.cpp / vLLM Jinja templates, Mistral, some OpenRouter routes)
400 on that adjacency. Merging proactively for everyone would re-introduce the divergence the
split shape removed and only move the 400, so the merge is reactive and destination-scoped:
* a 400 classified ``FailoverReason.role_alternation`` → retry ONCE with adjacent same-role
messages merged;
* the destination (``base_url``/provider + model) is remembered on the facade for the rest of
the session, so later iterations pre-merge for it and never pay the 400 again;
* destinations that accepted the split shape are never touched — their prefix stays byte-stable.
"""
from __future__ import annotations
import logging
from typing import Any
logger = logging.getLogger(__name__)
def destination_key(runtime: dict[str, Any]) -> tuple[str, str]:
"""``(route, model)`` identity of an aggregator destination: the base_url when the slot
resolved one (two providers can share a model id), else the provider slug."""
route = str(runtime.get("base_url") or runtime.get("provider") or "").strip().rstrip("/")
return route, str(runtime.get("model") or "").strip()
def merge_same_role_messages(messages: list[dict[str, Any]]) -> list[dict[str, Any]]:
"""Return ``messages`` with adjacent user turns folded into one (the only same-role adjacency
the aggregator request produces); other rows are shared, never mutated. Returns the input
object itself when nothing merged so callers can detect a no-op."""
from agent.agent_runtime_helpers import _UNMERGEABLE, _merge_user_content
merged: list[dict[str, Any]] = []
changed = False
for message in messages:
prev = merged[-1] if merged else None
content: Any = _UNMERGEABLE
if prev is not None and prev.get("role") == "user" and message.get("role") == "user":
content = _merge_user_content(prev.get("content", ""), message.get("content", ""))
if content is _UNMERGEABLE:
merged.append(message)
continue
merged[-1] = {**prev, "content": content}
changed = True
return merged if changed else messages
def is_role_alternation_rejection(exc: Exception, runtime: dict[str, Any]) -> bool:
"""True when the aggregator destination rejected the request for adjacent same-role messages."""
from agent.error_classifier import FailoverReason, classify_api_error
try:
classified = classify_api_error(
exc, provider=str(runtime.get("provider") or ""), model=str(runtime.get("model") or ""),
base_url=str(runtime.get("base_url") or ""),
)
except Exception: # pragma: no cover - classification must never mask the original error
return False
return classified.reason is FailoverReason.role_alternation