Files
hermes-agent/agent/message_sanitization.py
kshitijk4poor 1a95a75b1a fix(persistence): adopt content only on legacy rows and stamp every inserted row
A legacy (no-digest) dict over a non-blank assistant row adopted the whole
decoded DB row: tool_calls / reasoning* / codex_* were overwritten with the
stored JSON (which still holds the escaped lone surrogate the sanitizer just
fixed, re-injecting it into the provider payload) and live-only fields were
popped. Resumed dicts (_rows_to_conversation stamps _row_id without a
digest) and compaction clones hit this path. Adopt content only, as before
this stack, via a content-only canonical handled like the metadata-only one.

_insert_message_rows dropped a clone's parent digest but only the flush
path restamped it, so clones made by archive_and_compact / replace /
rotation handoff / import reached the legacy path and the first live edit
after a clone was not persisted. Stamp the stored-row digest inside
_insert_message_rows (one batched SELECT, cold paths only; the flush path
statement count is unchanged) and drop the duplicate call in
append_messages_batch.

Define the _db_row_snapshot / _canonical_row keys once in
agent/message_metadata.py and import them everywhere instead of repeating
the literals.
2026-09-27 20:45:44 +05:30

706 lines
36 KiB
Python

"""Message and tool-payload sanitization helpers (pure; documented in-place mutation).
Walk OpenAI-format message lists and structured payloads, repairing or stripping
characters that would crash ``json.dumps`` in the OpenAI SDK or be rejected upstream.
``run_agent`` re-exports them for old imports.
"""
from __future__ import annotations
import hashlib
import json
import logging
import re
from functools import partial
from typing import Any, Callable
from agent.message_metadata import DB_ROW_SNAPSHOT
from agent.vision_message_prep import _provider_model_key
logger = logging.getLogger(__name__)
# Lone surrogates are invalid UTF-8 and crash json.dumps in the OpenAI SDK; also used for
# CLI paste scrubbing.
_SURROGATE_RE = re.compile(r'[\ud800-\udfff]')
# Keys handled explicitly by _sanitize_messages; every OTHER key is swept generically.
# The durable snapshot is an immutable compare-and-swap version, not message payload.
_MESSAGE_CORE_KEYS = frozenset({"content", "name", "tool_calls", "role", DB_ROW_SNAPSHOT})
def _sanitize_surrogates(text: str) -> str:
"""Replace lone surrogate code points with U+FFFD; no-op when none present."""
# ``str.isascii`` is an O(1) flag check; surrogates are never ASCII, so the
# regex scan only runs for the (rare) non-ASCII leaf.
if text.isascii():
return text
return _SURROGATE_RE.sub('\ufffd', text)
# OpenAI / Anthropic / Responses all bound ``function.name`` to this; one poisoned stored name
# (``multi_tool_use.parallel``, a shell command a weak model put in ``name``) 400s every later
# request on a strict endpoint (#51944).
_VALID_TOOL_NAME_RE = re.compile(r"[A-Za-z0-9_-]{1,64}")
def coerce_tool_name(name: Any, fallback: str = "invalid_tool_call") -> str:
"""Coerce a *replayed* tool/function name to ``^[A-Za-z0-9_-]{1,64}$``. Valid names are returned
as-is (identity — prompt-cache safe); invalid runs collapse to ``_`` and the result is cut at 64;
empty/all-invalid → ``fallback``. Deterministic, so the same stored name always renders the same
bytes. Never apply to live tool definitions (schema names must match the dispatch registry)."""
if not isinstance(name, str):
return fallback
if _VALID_TOOL_NAME_RE.fullmatch(name):
return name
coerced = re.sub(r"_+", "_", re.sub(r"[^A-Za-z0-9_-]", "_", name.strip())).strip("_")
return coerced[:64] or fallback
def _strip_non_ascii(text: str) -> str:
"""Drop non-ASCII characters — last resort for ASCII-only system encodings (LANG=C)."""
if text.isascii():
return text
return text.encode('ascii', errors='ignore').decode('ascii')
def _fix_str_field(container: Any, key: Any, fix: Callable[[str], str]) -> bool:
"""Apply ``fix`` to ``container[key]`` if it is a str; True if it changed."""
value = container.get(key) if isinstance(container, dict) else container[key]
fixed = fix(value) if isinstance(value, str) else value
if fixed == value:
return False
container[key] = fixed
return True
def _sanitize_structure(payload: Any, fix: Callable[[str], str]) -> bool:
"""Apply ``fix`` to every str inside nested dict/list ``payload`` in-place."""
found = False
stack = [payload]
while stack:
node = stack.pop()
items = node.items() if isinstance(node, dict) else enumerate(node) if isinstance(node, list) else ()
for key, value in list(items):
if isinstance(value, str):
found |= _fix_str_field(node, key, fix)
elif isinstance(value, (dict, list)):
stack.append(value)
return found
def _sanitize_messages(messages: list, fix: Callable[[str], str], *, deep: bool) -> bool:
"""Apply ``fix`` to the string fields of every message dict in-place (content / part text,
name, tool_call arguments, non-core top-level str fields). ``deep=True`` adds tool_call ids,
function names, and NESTED non-core fields (``reasoning_details`` from byte-level models)."""
from agent.context_compressor import _DB_PERSISTED_MARKER
found = False
for msg in messages:
if not isinstance(msg, dict):
continue
msg_found = False
content = msg.get("content")
parts = [(p, "text") for p in content if isinstance(p, dict)] if isinstance(content, list) else None
fields = parts if parts is not None else [(msg, "content")]
fields.append((msg, "name"))
tool_calls = msg.get("tool_calls")
for tc in tool_calls if isinstance(tool_calls, list) else ():
fn = tc.get("function") if isinstance(tc, dict) else None
fields += [(tc, "id")] if deep and isinstance(tc, dict) else []
fields += ([(fn, "name")] if deep else []) + [(fn, "arguments")] if isinstance(fn, dict) else []
for container, key in fields:
msg_found |= _fix_str_field(container, key, fix)
for key, value in [kv for kv in msg.items() if kv[0] not in _MESSAGE_CORE_KEYS]:
if isinstance(value, str):
msg_found |= _fix_str_field(msg, key, fix)
elif deep and isinstance(value, (dict, list)):
msg_found |= _sanitize_structure(value, fix)
if msg_found:
# In-place repair of a live dict stales its persisted row; pop the marker so the
# flush rewrites it (no-op on api_messages wire copies).
msg.pop(_DB_PERSISTED_MARKER, None)
found = True
return found
# In-place sanitizers; each returns True when anything changed. Surrogate repair is deep
# (tool_call ids, nested reasoning_details); the ASCII-only-locale strip is shallow.
_sanitize_structure_surrogates = partial(_sanitize_structure, fix=_sanitize_surrogates)
_sanitize_messages_surrogates = partial(_sanitize_messages, fix=_sanitize_surrogates, deep=True)
_sanitize_structure_non_ascii = partial(_sanitize_structure, fix=_strip_non_ascii)
_sanitize_messages_non_ascii = partial(_sanitize_messages, fix=_strip_non_ascii, deep=False)
_sanitize_tools_non_ascii = _sanitize_structure_non_ascii
def sanitize_outbound_kwargs(agent: Any, api_kwargs: dict) -> None:
"""Outbound-request chokepoint for every built kwargs dict (main loop and iteration summary).
Tool descriptions, extra_body and kwargs strings can carry invalid code points that
providers reject with a non-retryable 400 (#50959); one in-place walk makes the whole
payload json.dumps()-safe. The ASCII strip is opt-in via the recovery flag set after an
ASCII-codec rejection.
"""
_sanitize_structure_surrogates(api_kwargs)
if agent._force_ascii_payload:
# ``tools`` is built from ``agent.tools`` per attempt and usually aliases it; detach
# before the in-place strip so the retry never rewrites the canonical tool schemas.
# A structural clone suffices: ``_sanitize_structure`` only rebinds str leaves
# inside dict/list containers.
if api_kwargs.get("tools") is not None and api_kwargs["tools"] is getattr(agent, "tools", None):
# Lazy: conversation_loop imports this module (cycle).
from agent.conversation_loop import _clone_message_for_send
api_kwargs["tools"] = _clone_message_for_send(api_kwargs["tools"])
_sanitize_structure_non_ascii(api_kwargs)
def _escape_invalid_chars_in_json_strings(raw: str) -> str:
"""Escape literal control chars (0x00-0x1F) inside JSON string values as ``\\uXXXX``
(for llama.cpp-style output mixing control chars with other malformations)."""
out: list[str] = []
in_string = False
i = 0
while i < len(raw):
ch = raw[i]
if in_string and ch == "\\" and i + 1 < len(raw):
out.append(raw[i:i + 2])
i += 2
continue
if ch == '"':
in_string = not in_string
out.append(f"\\u{ord(ch):04x}" if in_string and ord(ch) < 0x20 else ch)
i += 1
return "".join(out)
# When a repair rewrites arguments to "{}", the WARNING log is the last surviving copy of
# content that can hold real user data (a truncated write_file), so bound it generously.
_FULL_ARGS_LOG_BOUND = 100_000
def _loads_ok(text: str) -> bool:
try:
json.loads(text)
return True
except json.JSONDecodeError:
return False
_JSON_CLOSERS = {"{": "}", "[": "]"}
def _rebalance_json_closers(raw: str) -> str | None:
"""Close a JSON prefix's open braces/brackets in stack order, ignoring delimiters
inside string values (``{"code": "}"}`` keeps one open brace, not a balanced
document). A closer that does not match the stack top but does match a deeper opener
gets the missing inner closers inserted BEFORE it: ``{"a": [{"b": 1}}`` → the model
dropped the ``]`` and let the neighbouring ``}`` close in its place, so the counts
balance and nothing can be appended. ``None`` when the text ends inside an
unterminated string — that content is unrecoverable and must not be guessed.
"""
out: list[str] = []
stack: list[str] = []
in_string = False
i, n = 0, len(raw)
while i < n:
ch = raw[i]
if in_string:
if ch == "\\":
out.append(raw[i:i + 2])
i += 2
continue
if ch == '"':
in_string = False
elif ch == '"':
in_string = True
elif ch in _JSON_CLOSERS:
stack.append(ch)
elif ch in "}]" and ch in (_JSON_CLOSERS[o] for o in stack):
while _JSON_CLOSERS[stack[-1]] != ch:
out.append(_JSON_CLOSERS[stack.pop()])
stack.pop()
out.append(ch)
i += 1
if in_string:
return None
return "".join(out) + "".join(_JSON_CLOSERS[ch] for ch in reversed(stack))
def _repair_tool_call_arguments(raw_args: str, tool_name: str = "?") -> str:
"""Repair malformed tool_call argument JSON (truncation, trailing commas, Python ``None``,
control chars); ``"{}"`` if unrepairable so the request succeeds. Repairs log at WARNING."""
raw_stripped = raw_args.strip() if isinstance(raw_args, str) else ""
if not raw_stripped:
logger.warning("Sanitized empty tool_call arguments for %s", tool_name)
return "{}"
if raw_stripped == "None":
logger.warning("Sanitized Python-None tool_call arguments for %s", tool_name)
return "{}"
# Pass 0: strict=False accepts literal control chars inside strings (the most common
# local-model case) and re-serialises to wire-valid JSON.
try:
reserialised = json.dumps(json.loads(raw_stripped, strict=False), separators=(",", ":"))
if reserialised != raw_stripped:
logger.warning("Repaired unescaped control chars in tool_call arguments for %s", tool_name)
return reserialised
except (json.JSONDecodeError, TypeError, ValueError):
pass
# Passes 2-4: strip trailing commas, close unclosed structures, trim excess closers
# (bounded). Bracket counting is string-aware: delimiters inside string values
# ({"code": "}"}) are not structure, and the closers land in stack order — a truncated
# {"items": [{"n": 1}, {"n": 2 needs "}]}" appended, and a misnested
# {"a": [{"b": 1}, {"c": 2}} needs "]" inserted before the misplaced "}".
fixed = re.sub(r",\s*([}\]])", r"\1", raw_stripped)
fixed = _rebalance_json_closers(fixed) or fixed
for _ in range(50):
if _loads_ok(fixed) or not (
(fixed.endswith('}') and fixed.count('}') > fixed.count('{'))
or (fixed.endswith(']') and fixed.count(']') > fixed.count('['))
):
break
fixed = fixed[:-1]
if _loads_ok(fixed):
logger.warning("Repaired malformed tool_call arguments for %s: %s → %s", tool_name, raw_stripped[:80], fixed[:80])
return fixed
# Pass 5: escape control chars inside strings (strict=False alone fails when other
# malformations are present too), then retry.
escaped = _escape_invalid_chars_in_json_strings(fixed)
if escaped != fixed and _loads_ok(escaped):
logger.warning(
"Repaired control-char-laced tool_call arguments for %s: %s → %s", tool_name, raw_stripped[:80], escaped[:80],
)
return escaped
logger.warning(
"Unrepairable tool_call arguments for %s — replaced with empty object (was: %s)",
tool_name, raw_stripped[:_FULL_ARGS_LOG_BOUND],
)
return "{}"
def close_interrupted_tool_sequence(messages: list, final_response: Any = None) -> bool:
"""Append a synthetic assistant turn when an interrupted tail is a tool result: a transcript
ending on a raw ``tool`` message makes the next user message land as ``tool → user``, an
alternation violation strict providers (Gemini, Claude) answer by hallucinating a
continuation. Mutates in place; True if a closing turn was appended."""
last = messages[-1] if messages else None
if not isinstance(last, dict) or last.get("role") != "tool":
return False
text = final_response if isinstance(final_response, str) else ""
from agent.message_metadata import append_message
append_message(messages, {"role": "assistant", "content": text.strip() or "Operation interrupted."})
return True
# finish_reason wire normalization. Some OpenAI-compatible gateways fronting
# Gemini backends emit the native uppercase reasons (STOP, MAX_TOKENS); every
# downstream comparison uses the lowercase OpenAI literals, so an uppercase
# reason silently skips stop handling and length recovery. Single owner —
# call at wire intake (transport normalize_response, stream chunk capture),
# never re-fold at comparison sites.
_FINISH_REASON_ALIASES = {
"max_tokens": "length", # Gemini-native / Anthropic-style cap reason
"end": "stop", # some gateways' clean-completion spelling
"function_call": "tool_calls", # OpenAI legacy pre-tools spelling
}
def normalize_finish_reason(raw: Any) -> Any:
"""Fold a wire ``finish_reason`` to the lowercase OpenAI contract value.
Non-string and empty values pass through unchanged (callers keep their
``or "stop"`` defaults and the Poolside int-reason path); contract values
are returned byte-identical.
"""
if not isinstance(raw, str) or not raw:
return raw
lowered = raw.lower()
return _FINISH_REASON_ALIASES.get(lowered, lowered)
def serialized_messages_bytes(messages: list) -> int:
"""Exact serialized byte size of ``messages`` (HTTP 413 is a BYTE-size error the token
estimator, pricing images flat, cannot score). Non-serializable values fall back to
``str()`` so a malformed message can never crash recovery."""
if not isinstance(messages, list) or not messages:
return 0
try:
return len(json.dumps(messages, ensure_ascii=False, separators=(",", ":"), default=str).encode("utf-8"))
except (TypeError, ValueError):
return sum(len(str(m)) for m in messages)
_IMAGE_PART_TYPES = {"image_url", "image", "input_image"}
def _strip_images_from_messages(messages: list) -> bool:
"""Remove image content parts from all messages in-place (server rejected images).
``tool`` / ``tool_calls`` messages left empty get a placeholder, NOT deleted (deleting
orphans the paired ``tool_call_id`` → HTTP 400); other now-empty messages are dropped.
Rewritten messages lose their ``api_content`` sidecar (it carries the removed images):
a caller rewriting a persisted row must not leave bytes that replay them next turn. The
current callers pass per-call clones, where this is a no-op.
"""
from agent.context_compressor import _DB_PERSISTED_MARKER
from agent.turn_context import drop_stale_api_content
found = False
to_delete = []
for i, msg in enumerate(messages):
content = msg.get("content") if isinstance(msg, dict) else None
if not isinstance(content, list):
continue
new_parts = [p for p in content if not (isinstance(p, dict) and p.get("type") in _IMAGE_PART_TYPES)]
if len(new_parts) < len(content):
found = True
if new_parts:
msg["content"] = new_parts
# Rewriting a stamped live dict stales its persisted row; pop the marker.
msg.pop(_DB_PERSISTED_MARKER, None)
elif msg.get("role") == "tool" or msg.get("tool_calls"):
msg["content"] = "[image content removed — server does not support images]"
msg.pop(_DB_PERSISTED_MARKER, None)
else:
to_delete.append(i)
drop_stale_api_content(msg)
for i in reversed(to_delete):
del messages[i]
return found
# Provider error bodies (lowercased substring match) meaning "image/multimodal input
# unsupported" — the loop then strips images and retries text-only instead of cascading
# into compression / context-too-large recovery or wedging on retries.
_IMAGE_REJECTION_PHRASES = (
"only 'text' content type is supported", "only text content type is supported",
"image_url is not supported", "image content is not supported",
"multimodal is not supported", "multimodal content is not supported", "multimodal input is not supported",
"vision is not supported", "vision input is not supported",
"does not support images", "does not support image input", "does not support multimodal",
"does not support vision", "model does not support image",
# DashScope-style gateways reject non-text blocks with this generic body.
# Some OpenAI-compatible endpoints (e.g. (issue #57948)
"unexpected item type in content",
# ChatGPT-account Codex backend rejects data:image URLs in input_image; keyed on the
# field-path apostrophe so other URL errors don't false-trip.
"image_url'. expected",
# DeepSeek's text-only request-body variant error.
"unknown variant `image_url`, expected `text`", "unknown variant image_url, expected text",
# OpenRouter HTTP 404 when no upstream endpoint accepts image input (passes the 4xx
# gate; without this the gateway queue wedges behind the stuck turn).
# Without this phrase the agent never strips the images, the retry loop re-sends the same rejected
# request until exhaustion, and the gateway leaves every subsequent message queued behind the stuck turn
# — the P1 in issue #21160.
"no endpoints found that support image input",
)
# Provider error bodies meaning "this particular image payload is bad" — the model CAN see, it
# just could not decode what it was sent. Disjoint from ``_IMAGE_REJECTION_PHRASES``: the turn
# recovers the same way (strip and retry) but must NOT remember the model as image-rejecting,
# or the next request with a good image would be needlessly stripped for the rest of the session.
_IMAGE_CORRUPT_PHRASES = (
# ChatGPT-account Codex backend's wording for corrupt/unsupported native image payloads.
"image data you provided does not represent a valid image",
# Kimi/Moonshot et al. reject truncated/corrupt image bytes baked into history.
# Kimi / Moonshot / other OpenAI-compatible Chinese providers reject truncated or corrupt image bytes
# with HTTP 400 "Invalid request: prepare image failed ... failed to decode image: invalid or
# unsupported image format". Like the Codex case above, the bad bytes are baked into immutable
# conversation history and re-sent on every retry, wedging the session. Strip the images so the turn
# recovers instead of exhausting retries. (issue #76884; complements the proactive full-decode
# validation in tools/vision_tools._normalize_to_supported_image)
"failed to decode image",
)
def strip_images_for_rejecting_model(agent: Any, api_messages: Any) -> bool:
"""Send-path image strip for a model that rejected image content (see turn_recovery).
Runs on the per-call ``api_messages`` copy in Hermes's own message format, BEFORE the
provider-specific conversion: the part types this stripper knows are that format's, and a
converted payload (Bedrock Converse ``{"image": ...}`` blocks carry no ``type``) would slip
past it. History is never touched. Keyed on each rejecting (provider, model), so a model
that accepts images gets them again.
"""
if _provider_model_key(agent) not in agent._image_rejecting_models:
return False
return isinstance(api_messages, list) and _strip_images_from_messages(api_messages)
def _looks_like_image_content_rejection(error_body: str) -> bool:
"""Return True when a provider error says image/multimodal input is unsupported."""
body = str(error_body or "").lower()
return any(phrase in body for phrase in _IMAGE_REJECTION_PHRASES)
def _looks_like_corrupt_image_rejection(error_body: str) -> bool:
"""Return True when the rejection is about a bad image payload, not the model's capability."""
body = str(error_body or "").lower()
return any(phrase in body for phrase in _IMAGE_CORRUPT_PHRASES)
__all__ = [
"_SURROGATE_RE", "close_interrupted_tool_sequence",
"_sanitize_surrogates", "_sanitize_structure_surrogates", "_sanitize_messages_surrogates",
"coerce_tool_name",
"_escape_invalid_chars_in_json_strings", "_repair_tool_call_arguments",
"_strip_non_ascii", "_sanitize_messages_non_ascii", "_sanitize_tools_non_ascii",
"_strip_images_from_messages", "_sanitize_structure_non_ascii", "sanitize_outbound_kwargs",
"strip_images_for_rejecting_model",
# call_id policy owners
"deterministic_call_id", "coalesce_tool_call_id", "tool_call_id_variants",
"tool_result_id_variants", "uniquify_tool_call_ids",
# reasoning_content policy owners
"reasoning_echo_family", "matches_reasoning_echo_family", "needs_reasoning_echo",
"stale_thinking_reaches_wire", "apply_reasoning_content_policy", "reapply_reasoning_echo",
]
# -- call_id policy: hash synthesis, ``call_id or id`` coalescing, duplicate-id repair ----
# NOT merged with codex_event_projector._deterministic_call_id (maps app-server ITEM ids,
# not chat tool-call content; merging would change ids and invalidate caches).
# HARD INVARIANT: deterministic (never uuid4) and byte-identical for existing inputs —
# these ids feed prompt-cache prefixes.
def _tc_field(tc: Any, key: str) -> Any:
"""Read ``key`` from a tool-call entry that may be a dict or an SDK object."""
return tc.get(key) if isinstance(tc, dict) else getattr(tc, key, None)
def _tc_set(tc: Any, key: str, value: Any) -> None:
tc.__setitem__(key, value) if isinstance(tc, dict) else setattr(tc, key, value)
# --------------------------------------------------------------------------- call_id policy — single owner
# (audit F4, incident chain I4) ---------------------------------------------------------------------------
# Three forked policy sites converged here: * agent/codex_responses_adapter.py `_deterministic_call_id` —
# hash synthesis when a provider omits call_id (fa3ab2ffd0 → e45f2b39e2). *
# run_agent.AIAgent._get_tool_call_id_static — `call_id or id` coalescing for dicts and SDK objects. *
# run_agent.AIAgent._uniquify_tool_call_ids — duplicate-id repair with deterministic `_d<n>` suffixes
# (#58327 loss class). NOT consolidated (different scheme on purpose):
# agent/transports/codex_event_projector._deterministic_call_id maps codex app-server ITEM ids
# (`codex_<type>_<item_id>`), not chat tool-call content; merging the two would change ids and invalidate
# prompt caches. HARD INVARIANT: everything here must stay deterministic (never uuid4) and byte-identical
# for existing inputs — these ids feed prompt-cache prefixes.
def deterministic_call_id(fn_name: str, arguments: str, index: int = 0) -> str:
"""Deterministic call_id fallback when the API omits one (random ids would break caching)."""
seed = f"{fn_name}:{arguments}:{index}"
return f"call_{hashlib.sha256(seed.encode('utf-8', errors='replace')).hexdigest()[:12]}"
def _expand_tool_id_variants(values: tuple[Any, ...]) -> frozenset[str]:
"""Every wire spelling of one tool-call identifier: Responses bridges may expose the pairing
id and response-item id separately or as ``call_id|response_item_id``; all alias ONE call."""
variants: set[str] = set()
for raw in values:
value = raw.strip() if isinstance(raw, str) else ""
if value:
variants.add(value)
variants.update(p for p in (part.strip() for part in value.split("|")) if p)
return frozenset(variants)
def tool_call_id_variants(tc: Any) -> frozenset[str]:
"""Return all pairing-id variants carried by a tool-call entry."""
return _expand_tool_id_variants(tuple(_tc_field(tc, k) for k in ("call_id", "id", "response_item_id")))
def tool_result_id_variants(tool_call_id: Any) -> frozenset[str]:
"""Return all matching variants for a role=tool ``tool_call_id``."""
return _expand_tool_id_variants((tool_call_id,))
def coalesce_tool_call_id(tc: Any) -> str:
"""Effective call id of a tool_call entry (dict or object); ``""`` when none. Codex Responses
carry ``call_id`` (authoritative pairing key), Chat Completions ``id`` only, and bridge ids
may be ``call_id|response_item_id``."""
for raw in (_tc_field(tc, "call_id"), _tc_field(tc, "id")):
value = raw.strip() if isinstance(raw, str) else ""
if value:
return value.split("|", 1)[0].strip() or value
return ""
def uniquify_tool_call_ids(tool_calls: list) -> list:
"""Ensure every tool call in one assistant turn has a distinct id.
Some providers reuse one id across a batch; the pre-API sanitizer then keeps only the
first call/result pair per id and strict providers reject duplicates. Later collisions
get a deterministic ``<id>_d<n>`` suffix (never uuid4 — cache-prefix stability). Mutates
entries (SDK models / SimpleNamespace / dicts) in place. Blank ids are left for the
deterministic fallback in ``build_assistant_message``.
"""
seen: set = set()
for tc in tool_calls or []:
# Same coalescing rule as coalesce_tool_call_id, tolerant of non-string ids.
raw = _tc_field(tc, "call_id") or _tc_field(tc, "id") or ""
raw = raw.strip() if isinstance(raw, str) else ""
# Composite Responses ids ("call_x|fc_y") collide on the call half — the pairing key.
cid = raw.split("|", 1)[0]
if not cid:
continue
if cid not in seen:
seen.add(cid)
continue
# range is bounded: at most len(seen) suffixes can already be taken.
new_id = next(f"{cid}_d{n}" for n in range(2, len(seen) + 3) if f"{cid}_d{n}" not in seen)
seen.add(new_id)
try:
# Keep a composite id's response-item half so the provider's fc_/item id survives.
old = _tc_field(tc, "id")
_tc_set(tc, "id", f"{new_id}|{old.split('|', 1)[1]}" if isinstance(old, str) and "|" in old else new_id)
if _tc_field(tc, "call_id"):
_tc_set(tc, "call_id", new_id)
except Exception:
logger.warning("Could not uniquify duplicate tool call id %s", cid)
continue
_fn_name = _tc_field(_tc_field(tc, "function"), "name") or "?"
logger.warning(
"Model reused tool call id %s within one turn; renamed the duplicate to %s (tool=%s) to keep "
"call/result pairing lossless.", cid, new_id, _fn_name,
)
return tool_calls
# -- reasoning_content policy: single owner of strip-vs-re-pad; adapters keep only SYNTAX --
# Require side (echo-back enforced; replays 400 without the field): the families below. Kimi
# is host-driven on purpose (aggregators re-exporting kimi reject it); DeepSeek V4 rejects
# empty-string pads → " ". Strict side (400/422 "Extra inputs are not permitted"): everyone
# else — Mistral, Cerebras, Groq, SambaNova, … Strip the key entirely, even a one-space pad.
# --------------------------------------------------------------------------- reasoning_content policy —
# single owner (audit F4) --------------------------------------------------------------------------- The
# strip-vs-repad decision was previously forked across the wire files in separate incident commits
# (2b3a4f0af8 strip for strict providers, b5495db701 re-pad for require-side, 94b3131be7/9a9f8a6d99 kimi
# pad). The POLICY — which provider direction gets which treatment — lives here as one rule table + apply
# functions; adapters keep only SYNTAX mapping (e.g. anthropic_adapter turning reasoning_content into a
# thinking block). Direction table: require-side (echo-back enforced; replays 400 without the field): kimi
# — provider kimi-coding/kimi-coding-cn, or host api.kimi.com / moonshot.ai / moonshot.cn. Host-driven on
# purpose: aggregators re-exporting kimi models reject the echo. deepseek — provider "deepseek", model
# contains "deepseek", or host api.deepseek.com (#15250; V4 rejects empty-string pads, hence the " "
# single-space pad, #17341). mimo — provider "xiaomi", model contains "mimo", or host *.xiaomimimo.com.
# strict side (field rejected with 400/422 "Extra inputs are not permitted"): everyone else — Mistral,
# Cerebras, Groq, SambaNova, … (#45655). Strip the key entirely, even a single-space pad.
_REASONING_ECHO_RULES: tuple = (
# (family, exact providers (raw), exact providers (lowered), model substrings (lowered), hosts)
("kimi", frozenset({"kimi-coding", "kimi-coding-cn"}), frozenset(), (), ("api.kimi.com", "moonshot.ai", "moonshot.cn")),
("deepseek", frozenset(), frozenset({"deepseek"}), ("deepseek",), ("api.deepseek.com",)),
("mimo", frozenset(), frozenset({"xiaomi"}), ("mimo",), ("api.xiaomimimo.com", "xiaomimimo.com")),
)
_REASONING_ECHO_RULE_BY_FAMILY = {rule[0]: rule for rule in _REASONING_ECHO_RULES}
def matches_reasoning_echo_family(family: str, provider: Any, model: Any, base_url: Any) -> bool:
"""True when (provider, model, base_url) matches one echo-back family (families can overlap;
membership is tested independently). Raises KeyError for an unknown family."""
from utils import base_url_host_matches
_, raw_providers, lowered_providers, model_subs, hosts = _REASONING_ECHO_RULE_BY_FAMILY[family]
model_lower = (model or "").lower()
return (
provider in raw_providers or (provider or "").lower() in lowered_providers
or any(sub in model_lower for sub in model_subs) or any(base_url_host_matches(base_url, host) for host in hosts)
)
def reasoning_echo_family(provider: Any, model: Any, base_url: Any) -> "str | None":
"""``"kimi"`` / ``"deepseek"`` / ``"mimo"`` (first match in table order) when the
endpoint enforces reasoning_content echo-back, else ``None`` (strip side)."""
families = (rule[0] for rule in _REASONING_ECHO_RULES)
return next((f for f in families if matches_reasoning_echo_family(f, provider, model, base_url)), None)
def needs_reasoning_echo(provider: Any, model: Any, base_url: Any) -> bool:
"""True when the endpoint requires reasoning_content echo-back."""
return reasoning_echo_family(provider, model, base_url) is not None
def stale_thinking_reaches_wire(api_mode: Any, provider: Any, model: Any, base_url: Any) -> bool:
"""True when stale assistant reasoning text is actually replayed on the wire for the route.
The single wire-truth predicate the compaction TRIGGER estimator and the tail-budget
walks must share: if they disagree, a reasoning-heavy session can look over-threshold
to preflight yet fully tail-protected to the walk — an infinite compaction loop.
``codex_responses`` never reads the text keys (continuity rides the encrypted sidecar).
"""
return (api_mode or "") != "codex_responses" and needs_reasoning_echo(provider, model, base_url)
def apply_reasoning_content_policy(source_msg: dict, api_msg: dict, needs_thinking_pad: bool) -> None:
"""Copy provider-facing reasoning fields onto an API replay message (mutates ``api_msg``).
``needs_thinking_pad`` is the require-side flag (``needs_reasoning_echo``)."""
if source_msg.get("role") != "assistant":
return
if not needs_thinking_pad:
# Strict side: never carry the field — a reasoning primary pads history with " ",
# then a fallback to Mistral/Cerebras/Groq replays the pad and 422s. Also drops a
# non-string value (None after compaction): never pass null to the API.
api_msg.pop("reasoning_content", None)
return
existing, reasoning = source_msg.get("reasoning_content"), source_msg.get("reasoning")
# 1. Explicit reasoning_content already set. When the active provider enforces the thinking-mode
# echo-back (DeepSeek / Kimi / MiMo), preserve it verbatim — that includes their own space-placeholder
# written at creation time and any valid reasoning from the same provider. Sessions persisted BEFORE
# #17341 have empty-string placeholders pinned at creation time; DeepSeek V4 Pro rejects those with
# HTTP 400, so upgrade "" → " " on replay. When the active provider does NOT enforce echo-back, strip
# the field entirely. Strict OpenAI-compatible providers (Mistral, Cerebras, Groq, SambaNova, …)
# reject ANY reasoning_content key in input messages with HTTP 400/422 ("Extra inputs are not
# permitted"), even an empty string or a single-space pad. Stripping here covers the rebuild path;
# ``reapply_reasoning_echo`` covers the already-built api_messages path. Refs #45655.
if isinstance(existing, str):
# Explicit value: preserve verbatim, upgrading legacy "" to " " (DeepSeek V4 400s on "").
api_msg["reasoning_content"] = existing or " "
elif isinstance(reasoning, str) and reasoning and not source_msg.get("tool_calls"):
# Healthy session: promote internal 'reasoning' → 'reasoning_content'.
api_msg["reasoning_content"] = reasoning
else:
# tool_calls + 'reasoning' but no 'reasoning_content' means the reasoning came from
# ANOTHER provider (DeepSeek's own build pins reasoning_content for tool-call turns):
# pad without leaking foreign CoT. No reasoning at all: every assistant turn still needs
# the field; " " (not "") because DeepSeek V4 rejects empty string.
api_msg["reasoning_content"] = " "
def reapply_reasoning_echo(api_messages: list, needs_thinking_pad: bool) -> int:
"""Re-pad (or strip) assistant turns' reasoning_content for the ACTIVE provider.
``api_messages`` is built once under the primary provider; a mid-conversation fallback
can switch providers, so baked-in fields must be reconciled: TO a require-side provider
re-applies the pad (else 400), TO a strict one strips it (else 422). Idempotent.
Returns the number of assistant turns changed.
"""
changed = 0
for api_msg in api_messages:
if api_msg.get("role") != "assistant":
continue
# 3. Healthy session: promote 'reasoning' field to 'reasoning_content' for providers that use the
# internal 'reasoning' key. This must happen before the unconditional empty-string fallback so
# genuine reasoning content is not overwritten (#15812 regression in PR #15478). Only promote for
# providers that enforce echo-back — strict providers reject the field (refs #45655).
# 4. DeepSeek / Kimi thinking mode: all assistant messages need reasoning_content. Inject a single
# space to satisfy the provider's requirement when no explicit reasoning content is present.
# Covers both tool-call turns (already-poisoned history with no reasoning at all) and plain text
# turns. Space (not "") because DeepSeek V4 Pro tightened validation and rejects empty string with
# HTTP 400 ("The reasoning content in the thinking mode must be passed back to the API"). Refs
# #17341.
if needs_thinking_pad:
if not api_msg.get("reasoning_content"):
apply_reasoning_content_policy(api_msg, api_msg, needs_thinking_pad)
changed += 1 if api_msg.get("reasoning_content") else 0
elif "reasoning_content" in api_msg:
api_msg.pop("reasoning_content", None)
changed += 1
return changed
# Image / multimodal parts are deliberately NOT consolidated here: per-adapter handling is
# format-specific SYNTAX. The one shared image POLICY is ``_strip_images_from_messages``.