fix: a learned reasoning-field rejection survives a resumed process

A strict lane (the Portal's Fireworks :US models) rejects `reasoning` /
`reasoning_details` by name. The in-memory record stopped the 400 for the
rest of the process, but `hermes chat --resume` (or a gateway restart)
re-learned it with another 400 on its first request. The record now lives in
the session's model_config (same seam as the Anthropic rejected-thinking
fingerprints) and is loaded once per session_id. Still keyed by
(provider, host, model), so sibling models keep every carrier.
This commit is contained in:
Teknium
2026-10-10 13:54:50 -07:00
parent 336b11584c
commit f89688440e
3 changed files with 45 additions and 12 deletions

View File

@@ -466,7 +466,8 @@ __all__ = [
"close_interrupted_tool_sequence", "coalesce_tool_call_id", "coerce_tool_name",
"deterministic_call_id", "matches_reasoning_echo_family", "needs_reasoning_echo",
"normalize_provider_tool_call_ids", "reapply_reasoning_echo", "reasoning_echo_family",
"reasoning_replay_route", "record_reasoning_field_rejection", "route_reasoning_carriers",
"reasoning_replay_route", "record_reasoning_field_rejection", "rejected_reasoning_carriers",
"route_reasoning_carriers",
"sanitize_outbound_kwargs", "stale_thinking_reaches_wire", "strip_images_for_rejecting_model",
"tool_call_id_variants", "tool_result_id_variants", "uniquify_tool_call_ids",
]
@@ -946,6 +947,27 @@ def reasoning_route_key(agent: Any) -> tuple[str, str, str]:
)
_REJECTION_MODEL_CONFIG_KEY = "reasoning_rejected_carriers"
def rejected_reasoning_carriers(agent: Any) -> frozenset:
"""Reasoning keys the active (provider, host, model) rejected in this session.
Persisted in the session's ``model_config`` so a resumed process (``--resume``, a gateway
restart) never re-learns a rejection with another 400. Loaded once per ``session_id``.
"""
routes = agent.__dict__.setdefault("_reasoning_rejecting_routes", {})
session_id = getattr(agent, "session_id", None)
if session_id and getattr(agent, "_reasoning_rejections_loaded_for", None) != session_id:
agent._reasoning_rejections_loaded_for = session_id
getter = getattr(getattr(agent, "_session_db", None), "get_session_model_config_value", None)
stored = getter(session_id, _REJECTION_MODEL_CONFIG_KEY, []) if callable(getter) else []
for entry in stored if isinstance(stored, list) else ():
if isinstance(entry, list) and len(entry) == 4 and isinstance(entry[3], list):
routes.setdefault(tuple(entry[:3]), set()).update(k for k in entry[3] if k in _ALL_CARRIERS)
return frozenset(routes.get(reasoning_route_key(agent), ()))
def record_reasoning_field_rejection(agent: Any, error_body: Any, sent_messages: Any) -> frozenset:
"""Remember the reasoning keys this route rejected; returns the NEW ones (empty = no retry).
@@ -956,9 +978,14 @@ def record_reasoning_field_rejection(agent: Any, error_body: Any, sent_messages:
fields = rejected_reasoning_fields(error_body, sent_messages)
if not fields:
return frozenset()
known = agent._reasoning_rejecting_routes.setdefault(reasoning_route_key(agent), set())
new = fields - known
known.update(new)
new = fields - rejected_reasoning_carriers(agent)
if not new:
return frozenset()
routes = agent._reasoning_rejecting_routes
routes.setdefault(reasoning_route_key(agent), set()).update(new)
patcher = getattr(getattr(agent, "_session_db", None), "patch_session_model_config", None)
if callable(patcher) and getattr(agent, "session_id", None) and not getattr(agent, "_persist_disabled", False):
patcher(agent.session_id, {_REJECTION_MODEL_CONFIG_KEY: [[*key, sorted(v)] for key, v in sorted(routes.items())]})
return frozenset(new)

View File

@@ -161,9 +161,9 @@ class ReasoningParamsMixin:
per turn — and recomputed after ``switch_model()`` / fallback activation or a recorded
field rejection, the only events allowed to change the replayed bytes mid-session.
"""
from agent.message_sanitization import reasoning_replay_route, reasoning_route_key
from agent.message_sanitization import reasoning_replay_route, rejected_reasoning_carriers
rejected = frozenset(getattr(self, "_reasoning_rejecting_routes", {}).get(reasoning_route_key(self), ()))
rejected = rejected_reasoning_carriers(self)
key = (getattr(self, "api_mode", None), self.provider, self.model, getattr(self, "_base_url_lower", self.base_url),
self._reasoning_echo_opt_in(), rejected)
cached = getattr(self, "_reasoning_route_cache", None)

View File

@@ -8,7 +8,7 @@ from types import SimpleNamespace
import pytest
from agent.message_sanitization import (
apply_reasoning_content_policy, reapply_reasoning_echo, reasoning_replay_route,
apply_reasoning_content_policy, reapply_reasoning_echo, reasoning_replay_route, rejected_reasoning_carriers,
)
from agent.transports.chat_completions import ChatCompletionsTransport
@@ -74,11 +74,11 @@ class _Rejection(Exception):
self.body = body
def _agent(model):
def _agent(model, db=None):
return SimpleNamespace(
provider="nous", model=model, base_url="https://inference-api.nousresearch.com/v1",
api_mode="chat_completions", _force_ascii_payload=False, _image_rejecting_models=set(),
_reasoning_rejecting_routes={}, log_prefix="", _vprint=lambda *a, **k: None,
log_prefix="", _vprint=lambda *a, **k: None, session_id="s1" if db else None, _session_db=db,
)
@@ -91,14 +91,18 @@ def _recover(agent, body, sent):
)[0]
def test_named_field_rejection_strips_once_per_model_and_never_loops():
lane = _agent("z-ai/glm-5.3-flash:US")
def test_named_field_rejection_strips_once_per_model_and_never_loops(tmp_path):
from hermes_state import SessionDB
db = SessionDB(db_path=tmp_path / "state.db")
db.create_session("s1", source="cli", model="z-ai/glm-5.3-flash:US")
lane = _agent("z-ai/glm-5.3-flash:US", db)
sent = [{"role": "user", "content": "hi"}, deepcopy(_TURN)]
sent[1].pop("tool_calls")
body = "Extra inputs are not permitted, field: 'messages[1].reasoning_details'"
# A generic upstream error (content filter behind "Provider returned error") is not a rejection.
assert _recover(lane, "Provider returned error", sent) is False
assert lane._reasoning_rejecting_routes == {}
assert not rejected_reasoning_carriers(lane)
assert _recover(lane, body, sent) is True
rejected = lane._reasoning_rejecting_routes[("nous", "inference-api.nousresearch.com", "z-ai/glm-5.3-flash:US")]
@@ -110,6 +114,8 @@ def test_named_field_rejection_strips_once_per_model_and_never_loops():
assert "reasoning" not in sent[1] and "reasoning_details" not in sent[1]
# Same rejection again: nothing new to strip -> normal error path, no loop.
assert _recover(lane, body, [{"role": "assistant", "reasoning_details": _DETAILS}]) is False
# A resumed process (fresh agent, same session) never re-learns it with another 400.
assert rejected_reasoning_carriers(_agent(lane.model, db)) == {"reasoning", "reasoning_details"}
# A sibling model on the same gateway keeps every carrier.
sibling = reasoning_replay_route("chat_completions", "nous", "moonshotai/kimi-k3", lane.base_url)
assert "reasoning_details" in sibling.carriers