diff --git a/agent/message_sanitization.py b/agent/message_sanitization.py index f76c3f0969..82de39b51f 100644 --- a/agent/message_sanitization.py +++ b/agent/message_sanitization.py @@ -625,7 +625,7 @@ def _set_provider_tool_id(tc: Any, key: str, value: str) -> None: # -- 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 +# Require side (echo-back enforced or consumed by the template): 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. @@ -636,12 +636,12 @@ def _set_provider_tool_id(tc: Any, key: str, value: str) -> None: # (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 +# thinking block). Direction table: require-side (echo-back enforced, or consumed by the template): 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. -# ollama — provider "ollama-cloud", host ollama.com, or a local ":cloud" model (Ollama's /v1 feeds the +# ollama — provider "ollama-cloud", host ollama.com, or a local ":cloud"/"-cloud" tag (Ollama's /v1 feeds the # field back into the template's thinking slot; stripping it drops the model's own CoT mid tool loop). # 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. @@ -662,6 +662,8 @@ def matches_reasoning_echo_family(family: str, provider: Any, model: Any, base_u _, raw_providers, lowered_providers, model_subs, hosts = _REASONING_ECHO_RULE_BY_FAMILY[family] model_lower = (model or "").lower() + if ":" in model_lower and model_lower.endswith("-cloud"): + model_lower += ":cloud" # Ollama's "-cloud" tag (gpt-oss:120b-cloud) == ":cloud" 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) @@ -669,7 +671,7 @@ def matches_reasoning_echo_family(family: str, provider: Any, model: Any, base_u def reasoning_echo_family(provider: Any, model: Any, base_url: Any) -> str | None: - """``"kimi"`` / ``"deepseek"`` / ``"mimo"`` (first match in table order) when the + """``"kimi"`` / ``"deepseek"`` / ``"mimo"`` / ``"ollama"`` (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) diff --git a/agent/reasoning_params.py b/agent/reasoning_params.py index 0f03498bab..af1cd22423 100644 --- a/agent/reasoning_params.py +++ b/agent/reasoning_params.py @@ -156,7 +156,7 @@ class ReasoningParamsMixin: def _needs_thinking_reasoning_pad(self) -> bool: """True when the provider enforces ``reasoning_content`` echo-back on tool-call replays (DeepSeek, Kimi, - MiMo thinking all 400 without it). Cached per (provider, model, base_url), invalidated by + MiMo thinking all 400 without it; Ollama Cloud feeds it to the template). Cached per (provider, model, base_url), invalidated by ``switch_model()`` / ``_try_activate_fallback()`` — called ~16× per turn. DeepSeek v4 thinking and Kimi / Moonshot thinking both reject replays of assistant tool-call @@ -207,7 +207,7 @@ class ReasoningParamsMixin: return matches_reasoning_echo_family("mimo", (self.provider or "").lower(), self.model, self.base_url) def _needs_ollama_tool_reasoning(self) -> bool: - """True for Ollama Cloud (provider, host, or a local ``:cloud`` model).""" + """True for Ollama Cloud (provider, host, or a local ``:cloud``/``-cloud`` tag).""" return matches_reasoning_echo_family("ollama", (self.provider or "").lower(), self.model, self.base_url) _copy_reasoning_content_for_api = _forward("agent.agent_runtime_helpers", "copy_reasoning_content_for_api") diff --git a/tests/agent/test_message_sanitization_policy.py b/tests/agent/test_message_sanitization_policy.py index 3ac5cdd843..e0f19145d2 100644 --- a/tests/agent/test_message_sanitization_policy.py +++ b/tests/agent/test_message_sanitization_policy.py @@ -166,6 +166,8 @@ class TestReasoningEchoFamily: ("ollama-cloud", "kimi-k3", "https://x", "ollama"), ("custom", "kimi-k3", "https://ollama.com/v1", "ollama"), ("custom", "kimi-k3:cloud", "http://127.0.0.1:11434/v1", "ollama"), + ("custom", "gpt-oss:120b-cloud", "http://127.0.0.1:11434/v1", "ollama"), + ("gmi", "gmi-cloud", "https://x", None), ("custom", "qwen3:8b", "http://127.0.0.1:11434/v1", None), ("openai", "gpt-5", "https://api.openai.com/v1", None), ("mistral", "mistral-large", "https://api.mistral.ai/v1", None),