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:
@@ -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)
|
||||
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user