Files
hermes-agent/gateway/platforms/api_server_turn_boundary.py
teknium1 d19963782b fix(api-server): anchor the Responses current turn on this turn's user row, not a history prefix match
`_response_messages_turn_start_index` located the current turn by matching
`result["messages"]` against `conversation_history + [user]` row by row. The
loop repairs host-fed history before its first call (consecutive assistant or
user rows merge, orphan tool results drop) and compaction rewrites it, so the
transcript legitimately stops sharing a prefix with the client history; the
match then returned 0 and the WHOLE transcript was treated as the current
turn: earlier turns' function_call / function_call_output items were replayed
as this turn's `output` / `run.completed` turn_messages, and the stored
`previous_response_id` chain grew by another copy of the history every turn.

Anchor on the loop's canonical current-turn user index instead
(`agent.turn_context.reanchor_current_turn_user_idx`: last user row that
says this turn's text, else the last user-originated row), and keep the
semantic prefix match only for suffix-only results without a user row
(mocked/legacy paths). The boundary logic moves to the topical sibling
`api_server_turn_boundary.py`; the routes mixin delegates.

The dedupe test that asserted "divergent transcript => append history +
transcript" encoded the duplication itself; it now asserts the fallback for
the case it actually exists for (a suffix-only result).

Fixes #89891
Co-authored-by: heyf <tonyheyifan@gmail.com>
2026-09-19 12:11:23 -07:00

62 lines
2.9 KiB
Python

"""Current-turn boundary inside an agent transcript returned to the OpenAI-compat routes.
``AIAgent.run_conversation`` hands back the FULL transcript (client history + this turn),
and the routes need the index where this turn starts to build Responses ``output`` items,
``run.completed`` turn transcripts and the stored ``previous_response_id`` history.
"""
from typing import Any, Dict, List
_TRANSCRIPT_IDENTITY_KEYS = ("role", "content", "tool_calls", "tool_call_id")
def _same_transcript_prefix(agent_messages: List[Any], prefix: List[Any]) -> bool:
"""True when ``agent_messages`` starts with ``prefix`` by what each message *says*.
The API layer builds bare ``{"role", "content"}`` dicts while the agent stamps its copies
with ``timestamp`` / ``_db_persisted`` / ``reasoning`` / ``finish_reason``; whole-dict
equality therefore never matched and every chained turn re-appended the full prior
transcript (#95137, #101644, #82513)."""
if len(agent_messages) < len(prefix):
return False
for got, want in zip(agent_messages, prefix):
if not isinstance(got, dict) or not isinstance(want, dict):
if got != want:
return False
continue
if any(got.get(k) != want.get(k) for k in _TRANSCRIPT_IDENTITY_KEYS):
return False
return True
def response_turn_start_index(
conversation_history: List[Dict[str, Any]], user_message: Any, result: Dict[str, Any],
) -> int:
"""Index in ``result["messages"]`` where this turn's assistant/tool rows begin (0 = all).
Anchored on this turn's user row (the loop's canonical ``reanchor_current_turn_user_idx``),
not on prefix equality with the client history: the loop repairs host-fed history before
the first call (merging consecutive assistant/user rows, dropping stray tool results) and
compaction rewrites it, so the returned transcript legitimately stops sharing a prefix with
``conversation_history`` and a prefix match then returned 0 — the whole transcript was
treated as the current turn, replaying earlier ``function_call`` items and doubling the
stored history on every chained turn (#89891).
Mocked/legacy paths return only this turn's suffix (no user row): the prefix match stays as
the fallback for them.
"""
from agent.turn_context import reanchor_current_turn_user_idx
agent_messages = result.get("messages") if isinstance(result, dict) else None
if not isinstance(agent_messages, list) or not agent_messages:
return 0
user_idx = reanchor_current_turn_user_idx(agent_messages, user_message)
if user_idx >= 0:
return user_idx + 1
prior = list(conversation_history)
expected_prefix = prior + [{"role": "user", "content": user_message}]
if _same_transcript_prefix(agent_messages, expected_prefix):
return len(expected_prefix)
if prior and _same_transcript_prefix(agent_messages, prior):
return len(prior)
return 0