mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-11-06 05:17:15 +00:00
Core no longer branches on the Copilot/GitHub Models hosts in the Responses adapter, transports, reasoning params or reasoning-carrier replay. A provider profile claims its backend through ProviderProfile.serves_endpoint(base_url) (default False) and fills generic L3 fields/hooks: responses_replay_item_ids, responses_prompt_cache_key, responses_issuer_kind, responses_trusts_completed_status, responses_native_compaction, responses_profile_reasoning, message_carrier_opaque/_text, replays_tool_call_signatures, reasoning_payload(), replay_message_carriers(), replays_function_thought_signature(). The in-tree copilot plugin fills them from plugins/model-providers/copilot/responses.py, keeping the persisted "github_responses" issuer stamp and hermes.native_assistant record keys. Persisted carrier records whose declaring plugin is gone are stripped by their own keys. Parity: an offline oracle over 2079 Copilot cases (main request kwargs incl. preflight, aux Responses, carrier shaping/capture, reasoning gate, native compaction) is byte-identical to the base on the agent paths in both registry states.
115 lines
5.6 KiB
Python
115 lines
5.6 KiB
Python
"""Streamed tool-call delta assembly for the chat-completions stream (and Relay's accumulator)."""
|
|
|
|
from typing import Optional
|
|
|
|
from agent.reasoning_carriers import field
|
|
|
|
|
|
class _ToolCallAccumulator:
|
|
"""Assemble streamed tool-call deltas into complete ``tool_calls`` entries
|
|
(``acc``: slot index -> entry dict). Ollama-compatible endpoints reuse index 0
|
|
for every call in a parallel batch, distinguishing them only by id, so a new
|
|
id at an already-seen raw index is redirected to a fresh slot."""
|
|
|
|
def __init__(self):
|
|
self.acc: dict = {}
|
|
self._notified: set = set()
|
|
self._last_id_at_idx: dict = {} # raw_index -> last seen non-empty id
|
|
self._active_slot_by_idx: dict = {} # raw_index -> current slot in acc
|
|
# Argument deltas are collected per slot and joined once in ``materialize`` —
|
|
# ``+=`` per chunk rebuilds the whole string every delta (quadratic on big args).
|
|
self._argument_parts: dict[int, list[str]] = {}
|
|
# slot -> (parts scanned, depth, in_string, escaped, started); see ``_arguments_closed``.
|
|
self._arg_scan: dict[int, tuple] = {}
|
|
|
|
def materialize(self) -> dict:
|
|
"""Join buffered argument deltas into each entry's ``arguments``; idempotent. Returns ``acc``."""
|
|
for idx, parts in self._argument_parts.items():
|
|
self.acc[idx]["function"]["arguments"] = "".join(parts)
|
|
return self.acc
|
|
|
|
def _starts_new_call(self, tc_delta, slot: int) -> bool:
|
|
"""Whether a fresh id at a reused raw index opens a new call (Ollama parallel batch) or
|
|
continues the current one. LM Studio and Kimi send a fresh id on every argument fragment
|
|
with no name, and some MiniMax routes resend the same name on each fragment too: only a
|
|
named delta can start a call, and a same-name one only once the current call's arguments
|
|
have closed (two parallel calls of one tool must still split)."""
|
|
name = getattr(getattr(tc_delta, "function", None), "name", None)
|
|
if not name:
|
|
return False
|
|
if name != self.acc.get(slot, {}).get("function", {}).get("name"):
|
|
return True
|
|
return self._arguments_closed(slot)
|
|
|
|
def _arguments_closed(self, slot: int) -> bool:
|
|
"""Structural completion (brackets balanced outside strings), not strict JSON validity, so a
|
|
repairable call with a trailing comma still ends its slot. Scans only fragments added since
|
|
the last check: linear over the call, and free for providers that never reach here."""
|
|
parts = self._argument_parts.get(slot, [])
|
|
done, depth, in_string, escaped, started = self._arg_scan.get(slot, (0, 0, False, False, False))
|
|
for ch in "".join(parts[done:]):
|
|
if in_string:
|
|
if escaped:
|
|
escaped = False
|
|
elif ch == "\\":
|
|
escaped = True
|
|
elif ch == '"':
|
|
in_string = False
|
|
elif ch == '"':
|
|
in_string = True
|
|
elif ch in "{[":
|
|
depth, started = depth + 1, True
|
|
elif ch in "}]":
|
|
depth -= 1
|
|
self._arg_scan[slot] = (len(parts), depth, in_string, escaped, started)
|
|
return started and depth == 0 and not in_string
|
|
|
|
def feed(self, tc_delta) -> Optional[str]:
|
|
"""Merge one delta; return the tool name the first time it is complete."""
|
|
raw_idx = getattr(tc_delta, "index", None)
|
|
if raw_idx is None:
|
|
raw_idx = 0
|
|
tc_id = getattr(tc_delta, "id", None)
|
|
delta_id = tc_id or ""
|
|
if isinstance(tc_id, int): # Poolside sends integer ids
|
|
tc_id = str(tc_id)
|
|
|
|
self._active_slot_by_idx.setdefault(raw_idx, raw_idx)
|
|
if (
|
|
delta_id and raw_idx in self._last_id_at_idx and delta_id != self._last_id_at_idx[raw_idx]
|
|
and self._starts_new_call(tc_delta, self._active_slot_by_idx[raw_idx])
|
|
):
|
|
self._active_slot_by_idx[raw_idx] = max(self.acc, default=-1) + 1
|
|
if delta_id:
|
|
self._last_id_at_idx[raw_idx] = delta_id
|
|
idx = self._active_slot_by_idx[raw_idx]
|
|
|
|
entry = self.acc.setdefault(
|
|
idx, {"id": tc_id or "", "type": "function", "function": {"name": "", "arguments": ""}, "extra_content": None},
|
|
)
|
|
parts = self._argument_parts.setdefault(idx, [])
|
|
if tc_id:
|
|
entry["id"] = tc_id
|
|
tc_function = getattr(tc_delta, "function", None)
|
|
if tc_function:
|
|
if getattr(tc_function, "name", None):
|
|
# Assignment, not +=: names arrive complete and some providers (MiniMax via
|
|
# NVIDIA NIM) resend the full name every chunk — += gives "read_fileread_file".
|
|
entry["function"]["name"] = tc_function.name
|
|
if getattr(tc_function, "arguments", None):
|
|
parts.append(tc_function.arguments)
|
|
# Some gateways sign Gemini 3 calls inside ``function``.
|
|
if isinstance(fn_sig := field(tc_function, "thought_signature"), str) and fn_sig:
|
|
entry["thought_signature"] = fn_sig
|
|
extra = getattr(tc_delta, "extra_content", None)
|
|
if extra is None and hasattr(tc_delta, "model_extra"):
|
|
extra = (tc_delta.model_extra if isinstance(tc_delta.model_extra, dict) else {}).get("extra_content")
|
|
if extra is not None:
|
|
from agent.chat_completion_helpers import _dump_if_model
|
|
entry["extra_content"] = _dump_if_model(extra)
|
|
name = entry["function"]["name"]
|
|
if name and idx not in self._notified:
|
|
self._notified.add(idx)
|
|
return name
|
|
return None
|