fix: gate reasoning_content replay by provider policy

Prevent historical assistant reasoning_content from being replayed to
generic/local providers by default while preserving current behavior for
known reasoning-capable provider protocols.

Adds a webui.reasoning_content_replay policy read by the sanitizer:
auto, strip, and preserve. Auto strips for local/generic backends such as
LM Studio/Ollama/llama.cpp and preserves for explicitly known provider
protocols that may require reasoning replay.

This only affects provider-facing sanitized history. It does not modify
stored session/display reasoning metadata.
This commit is contained in:
Stacey2911
2026-06-27 09:01:58 +10:00
committed by justanotherAIcontributor
parent 1c4096aa9d
commit 43a0a39efa
4 changed files with 649 additions and 7 deletions
+7 -1
View File
@@ -18263,7 +18263,13 @@ def _handle_chat_sync(handler, body):
result = agent.run_conversation(
user_message=workspace_ctx + msg,
system_message=workspace_system_msg,
conversation_history=_sanitize_messages_for_api(_previous_context_messages, cfg=get_config()),
conversation_history=_sanitize_messages_for_api(
_previous_context_messages,
cfg=get_config(),
effective_model=_model,
effective_provider=_provider,
effective_base_url=_base_url,
),
task_id=s.session_id,
persist_user_message=msg,
)
+163 -4
View File
@@ -3738,7 +3738,136 @@ def _is_reasoning_only_assistant_message(msg) -> bool:
return _content_has_reasoning_only_parts(content)
def _sanitize_messages_for_api(messages, *, cfg: dict = None):
def _is_local_reasoning_replay_base_url(base_url: str | None) -> bool:
"""Return True when a custom provider base URL confidently points at localhost."""
if not base_url:
return False
try:
from urllib.parse import urlsplit
raw = str(base_url or '').strip()
if not raw:
return False
parsed = urlsplit(raw)
if not parsed.hostname and '://' not in raw:
parsed = urlsplit(f"http://{raw}")
host = (parsed.hostname or '').strip().lower()
except Exception:
return False
return host in {'localhost', '127.0.0.1', '::1', 'localhost.localdomain'}
def _should_strip_reasoning_content(
cfg: dict | None,
*,
mode: str | None = None,
effective_model: str | None = None,
effective_provider: str | None = None,
effective_base_url: str | None = None,
) -> bool:
"""Decide whether historical assistant reasoning_content should be stripped from model-facing history.
This is a provider/protocol decision, not a model-capability heuristic.
Local/generic backends (LM Studio, llama.cpp, Ollama, and custom localhost
OpenAI-compatible endpoints) do not require historical reasoning replay and
receive stale content when it's preserved. Unknown providers preserve by
default so replay does not break providers that require reasoning/tool-call
continuity.
Args:
cfg: Config dict from get_config(), expected to contain webui.reasoning_content_replay.
mode: Explicit override mode ("strip", "preserve", "auto"). If provided, bypasses config lookup.
effective_model: Runtime-resolved model for the current session/request.
effective_provider: Runtime-resolved provider for the current session/request.
effective_base_url: Runtime-resolved base URL for custom providers.
Returns:
True if reasoning_content should be stripped from sanitized output.
False if it should be preserved in sanitized output.
"""
# Explicit mode override takes priority
if mode is not None:
return mode == "strip"
# Config lookup. Missing/invalid config preserves shipped behavior: do not
# strip reasoning_content unless config explicitly requests it or auto can
# identify a local/generic effective backend.
if cfg is None:
return False
webui_cfg = cfg.get("webui", {}) or {}
if not isinstance(webui_cfg, dict):
return False
replay_mode = webui_cfg.get("reasoning_content_replay")
if not isinstance(replay_mode, str):
return False
normalized = replay_mode.strip().lower()
if normalized == "preserve":
return False
if normalized == "strip":
return True
if normalized == "auto":
# Auto mode: prefer runtime-resolved provider/model because profile
# defaults may differ from a per-session/request override.
model_cfg = cfg.get("model", {}) or {}
if not isinstance(model_cfg, dict):
model_cfg = {}
provider_id = str(effective_provider or model_cfg.get("provider", "") or "").strip().lower()
model_id = str(
effective_model or model_cfg.get("default") or model_cfg.get("name") or ""
).strip().lower()
base_url = effective_base_url or model_cfg.get("base_url")
if not provider_id:
return False
# Known providers that require historical reasoning_content replay:
# - DeepSeek thinking mode distinguishes normal history from tool-call reasoning chains
# - Anthropic Claude 4+/3.7+ uses structured reasoning in tool-use contexts
# - OpenAI GPT-5+/o-series requires reasoning replay for tool-use continuity
if provider_id == "deepseek":
return False # preserve for DeepSeek
if provider_id == "anthropic" and model_id.startswith("claude"):
return False # preserve for Claude models
if provider_id == "openai":
# Preserve for GPT-5+ and o-series (not GPT-4o, etc.)
# Use exact match + dash-prefixed suffix to avoid broad substring matches.
_is_reasoning_model = (
model_id == "gpt-5"
or model_id.startswith("gpt-5-")
or model_id in {"o1", "o3", "o4"}
or model_id.startswith(("o1-", "o3-", "o4-"))
)
if _is_reasoning_model:
return False # preserve for reasoning-capable OpenAI models
if provider_id in {"lmstudio", "ollama", "llamacpp", "llama.cpp"}:
return True
if provider_id == "custom" or provider_id.startswith("custom:"):
return _is_local_reasoning_replay_base_url(base_url)
# Unknown/cloud providers preserve by default.
return False
# Unknown mode -- preserve default behavior.
return False
def _sanitize_messages_for_api(
messages,
*,
cfg: dict = None,
effective_model: str | None = None,
effective_provider: str | None = None,
effective_base_url: str | None = None,
):
"""Return a deep copy of messages with only API-safe fields.
The webui stores extra metadata on messages (attachments, timestamp, _ts)
@@ -3804,6 +3933,18 @@ def _sanitize_messages_for_api(messages, *, cfg: dict = None):
# Orphaned tool result — skip to avoid 400 from strict providers.
continue
sanitized = {k: v for k, v in msg.items() if k in _API_SAFE_MSG_KEYS}
# Provider-aware reasoning_content stripping from model-facing history.
# Historical assistant reasoning_content is stripped only when the user
# explicitly requests strip mode or auto mode identifies a local/generic
# effective backend.
if msg.get('role') == 'assistant' and 'reasoning_content' in sanitized:
if _should_strip_reasoning_content(
cfg,
effective_model=effective_model,
effective_provider=effective_provider,
effective_base_url=effective_base_url,
):
del sanitized['reasoning_content']
if is_recovered:
sanitized['_recovered'] = True # temporary marker — stripped before return
if 'content' in sanitized:
@@ -7800,7 +7941,13 @@ def _run_agent_streaming(
_run_conversation_kwargs = dict(
user_message=user_message,
system_message=workspace_system_msg,
conversation_history=_sanitize_messages_for_api(_previous_context_messages, cfg=_cfg),
conversation_history=_sanitize_messages_for_api(
_previous_context_messages,
cfg=_cfg,
effective_model=resolved_model,
effective_provider=resolved_provider,
effective_base_url=resolved_base_url,
),
task_id=session_id,
persist_user_message=msg_text,
)
@@ -8195,7 +8342,13 @@ def _run_agent_streaming(
_heal_kwargs = dict(
user_message=user_message,
system_message=workspace_system_msg,
conversation_history=_sanitize_messages_for_api(_previous_context_messages, cfg=_cfg),
conversation_history=_sanitize_messages_for_api(
_previous_context_messages,
cfg=_cfg,
effective_model=resolved_model,
effective_provider=resolved_provider,
effective_base_url=resolved_base_url,
),
task_id=session_id,
persist_user_message=msg_text,
)
@@ -9254,7 +9407,13 @@ def _run_agent_streaming(
_heal_kwargs2 = dict(
user_message=user_message,
system_message=workspace_system_msg,
conversation_history=_sanitize_messages_for_api(_previous_context_messages, cfg=_cfg),
conversation_history=_sanitize_messages_for_api(
_previous_context_messages,
cfg=_cfg,
effective_model=resolved_model,
effective_provider=resolved_provider,
effective_base_url=resolved_base_url,
),
task_id=session_id,
persist_user_message=msg_text,
)
+5 -2
View File
@@ -357,8 +357,11 @@ class TestBuildNativeMultimodalMessage:
def test_sync_chat_history_sanitizer_receives_config(self):
"""#2398: fallback POST /api/chat must use the text-mode history sanitizer too."""
src = Path('api/routes.py').read_text()
call = 'conversation_history=_sanitize_messages_for_api(_previous_context_messages, cfg=get_config())'
assert call in src, (
assert 'conversation_history=_sanitize_messages_for_api(' in src, (
'The legacy synchronous /api/chat endpoint must sanitize history through '
'_sanitize_messages_for_api.'
)
assert 'cfg=get_config(),' in src, (
'The legacy synchronous /api/chat endpoint must pass current config into '
'_sanitize_messages_for_api so historical image_url parts are stripped '
'for text-mode providers just like the streaming endpoint.'
+474
View File
@@ -0,0 +1,474 @@
"""Tests for provider-aware reasoning_content history replay stripping.
Historical assistant reasoning_content is preserved by default. It is stripped
only when the user explicitly chooses strip mode, or when auto mode can identify
a local/generic effective backend for the current session/request.
"""
import copy
import pathlib
import sys
REPO_ROOT = pathlib.Path(__file__).parent.parent.resolve()
sys.path.insert(0, str(REPO_ROOT))
from api.streaming import _sanitize_messages_for_api
def _asst_with_reasoning(content="hello", reasoning="I think about this..."):
"""Create an assistant message with both content and reasoning_content."""
return {
"role": "assistant",
"content": content,
"reasoning_content": reasoning,
"_ts": 12345,
}
def _asst_with_tool_calls_and_reasoning(reasoning="Let me call a tool..."):
"""Create an assistant message with tool_calls AND reasoning_content."""
return {
"role": "assistant",
"content": None,
"reasoning_content": reasoning,
"tool_calls": [
{"type": "function", "id": "call-1", "function": {"name": "search", "arguments": "{}"}}
],
}
def _tool_result(call_id="call-1"):
return {"role": "tool", "tool_call_id": call_id, "content": "ok"}
def _user(text="hello"):
return {"role": "user", "content": text}
def _assistant(result):
return [m for m in result if m["role"] == "assistant"][0]
def _auto_cfg(provider="openai", default="gpt-4o", **model_overrides):
model = {"provider": provider, "default": default}
model.update(model_overrides)
return {"model": model, "webui": {"reasoning_content_replay": "auto"}}
def _assert_reasoning_preserved(result, expected):
assert _assistant(result).get("reasoning_content") == expected
def _assert_reasoning_stripped(result):
assert "reasoning_content" not in _assistant(result)
def test_cfg_none_preserves_reasoning_content():
msgs = [_user("Q"), _asst_with_reasoning("A1", "Internal thought")]
result = _sanitize_messages_for_api(msgs, cfg=None)
_assert_reasoning_preserved(result, "Internal thought")
def test_empty_cfg_preserves_reasoning_content():
msgs = [_user("Q"), _asst_with_reasoning("A1", "Internal thought")]
result = _sanitize_messages_for_api(msgs, cfg={})
_assert_reasoning_preserved(result, "Internal thought")
def test_empty_webui_cfg_preserves_reasoning_content():
msgs = [_user("Q"), _asst_with_reasoning("A1", "Internal thought")]
cfg = {"webui": {}, "model": {"provider": "ollama", "default": "qwen3:latest"}}
result = _sanitize_messages_for_api(msgs, cfg=cfg)
_assert_reasoning_preserved(result, "Internal thought")
def test_non_dict_webui_cfg_preserves_reasoning_content():
msgs = [_user("Q"), _asst_with_reasoning("A1", "Internal thought")]
cfg = {"webui": "not-a-dict", "model": {"provider": "ollama", "default": "qwen3:latest"}}
result = _sanitize_messages_for_api(msgs, cfg=cfg)
_assert_reasoning_preserved(result, "Internal thought")
def test_unknown_mode_preserves_reasoning_content():
msgs = [_user("Q"), _asst_with_reasoning("A1", "Internal thought")]
cfg = {"webui": {"reasoning_content_replay": "foobar"}}
result = _sanitize_messages_for_api(msgs, cfg=cfg)
_assert_reasoning_preserved(result, "Internal thought")
def test_strip_mode_removes_reasoning_content():
msgs = [_user("Q"), _asst_with_reasoning("A1", "Internal thought")]
cfg = {"webui": {"reasoning_content_replay": "strip"}}
result = _sanitize_messages_for_api(msgs, cfg=cfg)
assert len(result) == 2
assistant_msg = _assistant(result)
assert assistant_msg["content"] == "A1"
assert "reasoning_content" not in assistant_msg
def test_preserve_mode_keeps_reasoning_content():
msgs = [_user("Q"), _asst_with_reasoning("A1", "Internal thought")]
cfg = {"webui": {"reasoning_content_replay": "preserve"}}
result = _sanitize_messages_for_api(msgs, cfg=cfg)
_assert_reasoning_preserved(result, "Internal thought")
def test_auto_with_profile_openai_effective_lmstudio_strips():
msgs = [_user("Q"), _asst_with_reasoning("A1", "LM Studio stale thought")]
cfg = _auto_cfg(provider="openai", default="gpt-4o")
result = _sanitize_messages_for_api(
msgs,
cfg=cfg,
effective_model="qwen3-235b-a22b",
effective_provider="lmstudio",
)
_assert_reasoning_stripped(result)
def test_auto_with_profile_openai_effective_ollama_strips():
msgs = [_user("Q"), _asst_with_reasoning("A1", "Ollama stale thought")]
cfg = _auto_cfg(provider="openai", default="gpt-4o")
result = _sanitize_messages_for_api(
msgs,
cfg=cfg,
effective_model="qwen3:latest",
effective_provider="ollama",
)
_assert_reasoning_stripped(result)
def test_auto_with_profile_openai_effective_llamacpp_strips():
msgs = [_user("Q"), _asst_with_reasoning("A1", "llama.cpp stale thought")]
cfg = _auto_cfg(provider="openai", default="gpt-4o")
result = _sanitize_messages_for_api(
msgs,
cfg=cfg,
effective_model="qwen3-235b-a22b",
effective_provider="llamacpp",
)
_assert_reasoning_stripped(result)
def test_auto_with_custom_without_local_base_url_preserves():
msgs = [_user("Q"), _asst_with_reasoning("A1", "Custom cloud thought")]
cfg = _auto_cfg(provider="openai", default="gpt-4o")
result = _sanitize_messages_for_api(
msgs,
cfg=cfg,
effective_model="custom-reasoner",
effective_provider="custom",
)
_assert_reasoning_preserved(result, "Custom cloud thought")
def test_auto_with_custom_local_base_url_strips():
msgs = [_user("Q"), _asst_with_reasoning("A1", "Custom local stale thought")]
cfg = _auto_cfg(provider="openai", default="gpt-4o")
result = _sanitize_messages_for_api(
msgs,
cfg=cfg,
effective_model="qwen3-235b-a22b",
effective_provider="custom",
effective_base_url="http://127.0.0.1:1234/v1",
)
_assert_reasoning_stripped(result)
def test_auto_with_anthropic_claude_effective_preserves():
msgs = [_user("Q"), _asst_with_reasoning("A1", "Claude thinking")]
cfg = _auto_cfg(provider="openai", default="gpt-4o")
result = _sanitize_messages_for_api(
msgs,
cfg=cfg,
effective_model="claude-sonnet-4.6",
effective_provider="anthropic",
)
_assert_reasoning_preserved(result, "Claude thinking")
def test_auto_with_deepseek_effective_preserves():
msgs = [_user("Q"), _asst_with_reasoning("A1", "DeepSeek thinking")]
cfg = _auto_cfg(provider="openai", default="gpt-4o")
result = _sanitize_messages_for_api(
msgs,
cfg=cfg,
effective_model="deepseek-v4-flash",
effective_provider="deepseek",
)
_assert_reasoning_preserved(result, "DeepSeek thinking")
def test_auto_with_openai_gpt5_effective_preserves():
msgs = [_user("Q"), _asst_with_reasoning("A1", "GPT-5 reasoning")]
cfg = _auto_cfg(provider="ollama", default="qwen3:latest")
result = _sanitize_messages_for_api(
msgs,
cfg=cfg,
effective_model="gpt-5-mini",
effective_provider="openai",
)
_assert_reasoning_preserved(result, "GPT-5 reasoning")
def test_auto_with_openai_o_series_effective_preserves():
msgs = [_user("Q"), _asst_with_reasoning("A1", "o1 reasoning")]
cfg = _auto_cfg(provider="ollama", default="qwen3:latest")
result = _sanitize_messages_for_api(
msgs,
cfg=cfg,
effective_model="o1-preview",
effective_provider="openai",
)
_assert_reasoning_preserved(result, "o1 reasoning")
def test_auto_with_openai_gpt4o_effective_preserves():
msgs = [_user("Q"), _asst_with_reasoning("A1", "gpt-4o thought")]
cfg = _auto_cfg(provider="openai", default="gpt-4o")
result = _sanitize_messages_for_api(
msgs,
cfg=cfg,
effective_model="gpt-4o",
effective_provider="openai",
)
_assert_reasoning_preserved(result, "gpt-4o thought")
def test_auto_with_unknown_effective_provider_preserves():
msgs = [_user("Q"), _asst_with_reasoning("A1", "Unknown provider thought")]
cfg = _auto_cfg(provider="openai", default="gpt-4o")
result = _sanitize_messages_for_api(
msgs,
cfg=cfg,
effective_model="mystery-model",
effective_provider="unknown-cloud",
)
_assert_reasoning_preserved(result, "Unknown provider thought")
def test_auto_without_effective_provider_uses_production_profile_keys_for_local_fallback():
msgs = [_user("Q"), _asst_with_reasoning("A1", "Profile local stale thought")]
cfg = _auto_cfg(provider="ollama", default="qwen3:latest")
result = _sanitize_messages_for_api(msgs, cfg=cfg)
_assert_reasoning_stripped(result)
def test_auto_without_effective_provider_preserves_openai_gpt4o_profile():
msgs = [_user("Q"), _asst_with_reasoning("A1", "OpenAI non-reasoning thought")]
cfg = _auto_cfg(provider="openai", default="gpt-4o")
result = _sanitize_messages_for_api(msgs, cfg=cfg)
_assert_reasoning_preserved(result, "OpenAI non-reasoning thought")
def test_auto_profile_model_name_fallback_preserves_anthropic_claude():
msgs = [_user("Q"), _asst_with_reasoning("A1", "Claude profile thinking")]
cfg = {
"model": {"provider": "anthropic", "name": "claude-sonnet-4"},
"webui": {"reasoning_content_replay": "auto"},
}
result = _sanitize_messages_for_api(msgs, cfg=cfg)
_assert_reasoning_preserved(result, "Claude profile thinking")
def test_input_messages_not_mutated():
original_messages = [_user("Q"), _asst_with_reasoning("A1", "Internal thought")]
msgs = copy.deepcopy(original_messages)
cfg = {"webui": {"reasoning_content_replay": "strip"}}
_sanitize_messages_for_api(msgs, cfg=cfg)
assert msgs == original_messages
def test_multiple_assistant_reasoning_all_stripped():
msgs = [
_user("Q1"),
_asst_with_reasoning("A1", "Thought 1"),
_user("Q2"),
_asst_with_reasoning("A2", "Thought 2"),
]
cfg = {"webui": {"reasoning_content_replay": "strip"}}
result = _sanitize_messages_for_api(msgs, cfg=cfg)
for msg in result:
if msg["role"] == "assistant":
assert "reasoning_content" not in msg
def test_tool_call_chain_preserved_with_reasoning_stripped():
msgs = [
_user("Q"),
_asst_with_tool_calls_and_reasoning("Let me search..."),
_tool_result("call-1"),
_user("A2"),
]
cfg = {"webui": {"reasoning_content_replay": "strip"}}
result = _sanitize_messages_for_api(msgs, cfg=cfg)
roles = [m["role"] for m in result]
assert roles == ["user", "assistant", "tool", "user"]
asst_msg = _assistant(result)
assert asst_msg.get("tool_calls") is not None
assert len(asst_msg["tool_calls"]) == 1
assert asst_msg["tool_calls"][0]["id"] == "call-1"
assert "reasoning_content" not in asst_msg
tool_ids = {m["tool_call_id"] for m in result if m["role"] == "tool"}
assistant_tool_ids = {tc["id"] for tc in asst_msg["tool_calls"]}
assert tool_ids == assistant_tool_ids
def test_orphaned_tool_messages_still_dropped_with_reasoning_strip():
msgs = [
_user("Q"),
_asst_with_tool_calls_and_reasoning(),
{"role": "tool", "tool_call_id": "call-orphan", "content": "orphan"},
_user("A2"),
]
cfg = {"webui": {"reasoning_content_replay": "strip"}}
result = _sanitize_messages_for_api(msgs, cfg=cfg)
roles = [m["role"] for m in result]
assert "tool" not in roles
assert "assistant" not in roles
def test_oob_terminal_marker_behavior_unchanged_with_reasoning_strip():
msgs = [
_user("Q"),
{
"role": "assistant",
"content": "Here is the answer. [[hermes:agent:terminal]]\n\nDone.",
"reasoning_content": "I should not leak this.",
},
]
cfg = {"webui": {"reasoning_content_replay": "strip"}}
result = _sanitize_messages_for_api(msgs, cfg=cfg)
asst_msg = _assistant(result)
assert asst_msg.get("content") == "Here is the answer. [[hermes:agent:terminal]]\n\nDone."
assert "reasoning_content" not in asst_msg
def test_empty_messages_list():
cfg = {"webui": {"reasoning_content_replay": "strip"}}
assert _sanitize_messages_for_api([], cfg=cfg) == []
def test_assistant_without_reasoning_unchanged():
msgs = [_user("Q"), {"role": "assistant", "content": "A1"}]
cfg = {"webui": {"reasoning_content_replay": "strip"}}
result = _sanitize_messages_for_api(msgs, cfg=cfg)
assert len(result) == 2
assert _assistant(result)["content"] == "A1"
def test_reasoning_only_assistant_message_not_duplicated():
msgs = [
_user("Q"),
{"role": "assistant", "content": "", "reasoning_content": "Thinking..."},
_user("A2"),
]
cfg = {"webui": {"reasoning_content_replay": "preserve"}}
result = _sanitize_messages_for_api(msgs, cfg=cfg)
roles = [m["role"] for m in result]
assert roles == ["user", "user"]
def test_system_messages_unaffected():
msgs = [
{"role": "system", "content": "You are helpful."},
_user("Q"),
_asst_with_reasoning("A1", "Internal thought"),
]
cfg = {"webui": {"reasoning_content_replay": "strip"}}
result = _sanitize_messages_for_api(msgs, cfg=cfg)
assert len(result) == 3
assert result[0]["role"] == "system"
assert result[0]["content"] == "You are helpful."
def test_multiple_turns_with_mixed_reasoning():
msgs = [
_user("Q1"),
{"role": "assistant", "content": "A1", "reasoning_content": "Thought 1"},
_user("Q2"),
{"role": "assistant", "content": "A2"},
_user("Q3"),
{"role": "assistant", "content": "A3", "reasoning_content": "Thought 3"},
]
cfg = {"webui": {"reasoning_content_replay": "strip"}}
result = _sanitize_messages_for_api(msgs, cfg=cfg)
assert len(result) == 6
for msg in result:
if msg["role"] == "assistant":
assert "reasoning_content" not in msg
if msg.get("content") and msg["role"] != "system":
assert msg["content"] is not None
def test_reasoning_content_on_user_message_untouched():
msgs = [
_user("Q"),
{"role": "assistant", "content": "A1"},
{"role": "user", "content": "Q2", "reasoning_content": "unexpected field"},
]
cfg = {"webui": {"reasoning_content_replay": "strip"}}
result = _sanitize_messages_for_api(msgs, cfg=cfg)
user_msg = [m for m in result if m["role"] == "user" and m.get("reasoning_content")][0]
assert user_msg.get("reasoning_content") == "unexpected field"