Files
hermes-agent/agent/chat_completion_helpers_relay.py
kshitijk4poor 74de0fd4fe fix(relay): build the chat-completions Relay response from collector-observed chunks
Relay invokes its finalizer as soon as the provider stream ends, concurrently with
Hermes' consumer thread, so any finalizer that reads the consumer loop's closures
races the last chunk. #103104 moved `usage` onto the collector; the same race still
truncated the final tool-call `arguments` delta, dropped the last content delta, and
downgraded `finish_reason` to "stop" in Relay's annotated response (reproduced ~50%
of runs). Every other Relay integration (Anthropic, Bedrock, Codex) already
rebuilds from collector-observed chunks; this makes chat_completions match via
`_RelayChatAccumulator` and removes the single-field nonlocal stopgap.

Test: the ordering harness is shared, and a second invariant test pins the
tool-call/finish_reason case (fails on the stopgap, passes here). Hermes' own
returned response was never affected.
2026-09-05 10:54:19 +05:30

75 lines
3.3 KiB
Python

"""Relay-side accumulator for the chat_completions streaming wire.
Relay invokes its collector for every post-intercept chunk and then its finalizer as soon
as the provider stream ends — concurrently with Hermes' consumer thread, which may not have
read the last chunk yet. The finalizer therefore builds Relay's recorded response from
collector-observed state only, never from the consumer loop's closures. Sibling of
``relay_llm.AnthropicStreamAccumulator``; Bedrock and Codex follow the same contract.
"""
from __future__ import annotations
from types import SimpleNamespace
from typing import Any
from agent.chat_completion_helpers import _ToolCallAccumulator
from agent.message_content import flatten_message_text
from agent.reasoning_summaries import separate_glued_reasoning_blocks
def _tool_call_delta_view(tc_delta: Any) -> Any:
"""Attribute view of a JSON tool-call delta for ``_ToolCallAccumulator.feed`` (written
against SDK objects). Only ``function`` is wrapped: ``feed`` passes ``extra_content``
(a dict) straight through ``_dump_if_model``, so a recursive view would corrupt it."""
if not isinstance(tc_delta, dict):
return tc_delta
function = tc_delta.get("function")
return SimpleNamespace(**{**tc_delta,
"function": SimpleNamespace(**function) if isinstance(function, dict) else function})
class RelayChatAccumulator:
"""Rebuild a chat.completion from Relay's post-intercept chunk dicts."""
def __init__(self) -> None:
self._content: list[str] = []
self._reasoning: list[str] = []
self._tool_calls = _ToolCallAccumulator()
self._model = self._usage = self._finish_reason = None
self._role = "assistant"
def observe(self, chunk: Any) -> None:
if not isinstance(chunk, dict):
return
self._model = chunk.get("model") or self._model
if chunk.get("usage"):
self._usage = chunk["usage"]
choices = chunk.get("choices") or []
choice = choices[0] if choices else None # Hermes never requests n>1
if not isinstance(choice, dict):
return
self._finish_reason = choice.get("finish_reason") or self._finish_reason
delta = choice.get("delta")
if not isinstance(delta, dict):
return
if delta.get("role"):
self._role = delta["role"]
text = flatten_message_text(delta.get("content"), sep="")
if text:
self._content.append(text)
reasoning = delta.get("reasoning_content") or delta.get("reasoning")
if reasoning:
self._reasoning.append(separate_glued_reasoning_blocks(
self._reasoning[-1] if self._reasoning else "", reasoning))
for tc_delta in delta.get("tool_calls") or []:
self._tool_calls.feed(_tool_call_delta_view(tc_delta))
def finalize(self) -> dict[str, Any]:
acc = self._tool_calls.materialize()
message = {"role": self._role, "content": "".join(self._content) or None,
"reasoning_content": "".join(self._reasoning) or None,
"tool_calls": [acc[i] for i in sorted(acc)] or None}
# "stop" also covers Nous Portal ``lastOne`` usage frames, which carry no finish_reason.
return {"model": self._model, "usage": self._usage,
"choices": [{"message": message, "finish_reason": self._finish_reason or "stop"}]}