diff --git a/agent/message_sanitization.py b/agent/message_sanitization.py index 4956c1749e..080c4009e9 100644 --- a/agent/message_sanitization.py +++ b/agent/message_sanitization.py @@ -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) diff --git a/agent/reasoning_params.py b/agent/reasoning_params.py index 0a20144d7f..cdb57a8d58 100644 --- a/agent/reasoning_params.py +++ b/agent/reasoning_params.py @@ -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) diff --git a/tests/agent/test_reasoning_replay_routes.py b/tests/agent/test_reasoning_replay_routes.py index 75fe290279..e8182b7259 100644 --- a/tests/agent/test_reasoning_replay_routes.py +++ b/tests/agent/test_reasoning_replay_routes.py @@ -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