fix(agent): drop thinking-only assistant turns before provider call (#16959)
Adds a pre-call sanitizer that detects assistant messages containing only reasoning (reasoning / reasoning_content, no visible content, no tool_calls) and drops them from the API copy. Adjacent user messages left behind are merged so role alternation is preserved for the provider. Mirrors Claude Code's approach in src/utils/messages.ts (filterOrphanedThinkingOnlyMessages + mergeAdjacentUserMessages). We drop the whole turn rather than fabricate stub text (the '.' / '(continued)' pattern from contributor PRs #11098, #13010, #16842 that were rejected because they put words in the model's mouth). The stored conversation history (self.messages) is never mutated — only the per-call api_messages copy. Users still see the reasoning block in the CLI/gateway transcript; only the wire copy is cleaned. Session persistence keeps the full trace. Two call sites covered: - Main agent loop, after _sanitize_api_messages (catches every turn). - Iteration-limit-summary fallback path. Tests: tests/run_agent/test_thinking_only_sanitizer.py — 25 cases covering detection (string/list content, whitespace-only, tool_calls, reasoning_details list form), drop behavior, adjacent-user merge (string+string, list+list, mixed), non-mutation of input dicts, and system-message handling. E2E live-tested against 5 providers with a poisoned history (empty assistant message + reasoning_content): OpenRouter→Anthropic/OpenAI/ DeepSeek-R1/Qwen, native Gemini. All 5 accepted the cleaned request. Happy-path regression (5/5) confirms the sanitizer is a noop when no thinking-only turn exists. Related: #16823 (wontfix — stub-text approach rejected). Co-authored-by: teknium1 <teknium@users.noreply.github.com>
This commit is contained in:
153
run_agent.py
153
run_agent.py
@@ -4793,6 +4793,145 @@ class AIAgent:
|
||||
)
|
||||
return messages
|
||||
|
||||
@staticmethod
|
||||
def _is_thinking_only_assistant(msg: Dict[str, Any]) -> bool:
|
||||
"""Return True if ``msg`` is an assistant turn whose only payload is reasoning.
|
||||
|
||||
"Thinking-only" means the model emitted reasoning (``reasoning`` or
|
||||
``reasoning_content``) but no visible text and no tool_calls. When sent
|
||||
back to providers that convert reasoning into thinking blocks (native
|
||||
Anthropic, OpenRouter Anthropic, third-party Anthropic-compatible
|
||||
gateways), the resulting message has only thinking blocks — which
|
||||
Anthropic rejects with HTTP 400 "The final block in an assistant
|
||||
message cannot be `thinking`."
|
||||
|
||||
Symmetric with Claude Code's ``filterOrphanedThinkingOnlyMessages``
|
||||
(src/utils/messages.ts). We drop the whole turn from the API copy
|
||||
rather than fabricating stub text — the message log (UI transcript)
|
||||
keeps the reasoning block; only the wire copy is cleaned.
|
||||
"""
|
||||
if not isinstance(msg, dict) or msg.get("role") != "assistant":
|
||||
return False
|
||||
if msg.get("tool_calls"):
|
||||
return False
|
||||
# Does it have any actual output?
|
||||
content = msg.get("content")
|
||||
if isinstance(content, str):
|
||||
if content.strip():
|
||||
return False
|
||||
elif isinstance(content, list):
|
||||
for block in content:
|
||||
if not isinstance(block, dict):
|
||||
if block: # non-empty non-dict string etc.
|
||||
return False
|
||||
continue
|
||||
btype = block.get("type")
|
||||
if btype in ("thinking", "redacted_thinking"):
|
||||
continue
|
||||
if btype == "text":
|
||||
text = block.get("text", "")
|
||||
if isinstance(text, str) and text.strip():
|
||||
return False
|
||||
continue
|
||||
# tool_use, image, document, etc. — real payload
|
||||
return False
|
||||
elif content is not None and content != "":
|
||||
return False
|
||||
# Content is empty-ish. Is there reasoning to make it thinking-only?
|
||||
reasoning = msg.get("reasoning_content") or msg.get("reasoning")
|
||||
if isinstance(reasoning, str) and reasoning.strip():
|
||||
return True
|
||||
# reasoning_details list form
|
||||
rd = msg.get("reasoning_details")
|
||||
if isinstance(rd, list) and rd:
|
||||
return True
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
def _drop_thinking_only_and_merge_users(
|
||||
messages: List[Dict[str, Any]],
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""Drop thinking-only assistant turns; merge any adjacent user messages left behind.
|
||||
|
||||
Runs on the per-call ``api_messages`` copy only. The stored
|
||||
conversation history (``self.messages``) is never mutated, so the
|
||||
user still sees the thinking block in the CLI/gateway transcript and
|
||||
session persistence keeps the full trace. Only the wire copy sent to
|
||||
the provider is cleaned.
|
||||
|
||||
Why drop-and-merge rather than inject stub text:
|
||||
- Fabricating ``"."`` / ``"(continued)"`` text lies in the history
|
||||
and makes future turns see model output the model didn't emit.
|
||||
- Dropping the turn preserves honesty; merging adjacent user messages
|
||||
preserves the provider's role-alternation invariant.
|
||||
- This is the pattern used by Claude Code's ``normalizeMessagesForAPI``
|
||||
(filterOrphanedThinkingOnlyMessages + mergeAdjacentUserMessages).
|
||||
"""
|
||||
if not messages:
|
||||
return messages
|
||||
|
||||
# Pass 1: drop thinking-only assistant turns.
|
||||
kept = [m for m in messages if not AIAgent._is_thinking_only_assistant(m)]
|
||||
dropped = len(messages) - len(kept)
|
||||
if dropped == 0:
|
||||
return messages
|
||||
|
||||
# Pass 2: merge any newly-adjacent user messages.
|
||||
merged: List[Dict[str, Any]] = []
|
||||
merges = 0
|
||||
for m in kept:
|
||||
prev = merged[-1] if merged else None
|
||||
if (
|
||||
prev is not None
|
||||
and prev.get("role") == "user"
|
||||
and m.get("role") == "user"
|
||||
):
|
||||
prev_content = prev.get("content", "")
|
||||
cur_content = m.get("content", "")
|
||||
# Work on a copy of ``prev`` so the caller's input dicts are
|
||||
# never mutated. ``_sanitize_api_messages`` upstream already
|
||||
# hands us per-call copies, but staying pure here means we
|
||||
# can be called safely from anywhere (tests, other loops).
|
||||
prev_copy = dict(prev)
|
||||
# Only string-content merge is meaningful for role-alternation
|
||||
# purposes. If either side is a list (multimodal), append as a
|
||||
# separate block rather than collapsing.
|
||||
if isinstance(prev_content, str) and isinstance(cur_content, str):
|
||||
sep = "\n\n" if prev_content and cur_content else ""
|
||||
prev_copy["content"] = prev_content + sep + cur_content
|
||||
elif isinstance(prev_content, list) and isinstance(cur_content, list):
|
||||
prev_copy["content"] = list(prev_content) + list(cur_content)
|
||||
elif isinstance(prev_content, list) and isinstance(cur_content, str):
|
||||
if cur_content:
|
||||
prev_copy["content"] = list(prev_content) + [
|
||||
{"type": "text", "text": cur_content}
|
||||
]
|
||||
else:
|
||||
prev_copy["content"] = list(prev_content)
|
||||
elif isinstance(prev_content, str) and isinstance(cur_content, list):
|
||||
new_blocks: List[Dict[str, Any]] = []
|
||||
if prev_content:
|
||||
new_blocks.append({"type": "text", "text": prev_content})
|
||||
new_blocks.extend(cur_content)
|
||||
prev_copy["content"] = new_blocks
|
||||
else:
|
||||
# Unknown content shape — fall back to appending separately
|
||||
# (violates alternation, but safer than raising in a hot path).
|
||||
merged.append(m)
|
||||
continue
|
||||
merged[-1] = prev_copy
|
||||
merges += 1
|
||||
else:
|
||||
merged.append(m)
|
||||
|
||||
logger.debug(
|
||||
"Pre-call sanitizer: dropped %d thinking-only assistant turn(s), "
|
||||
"merged %d adjacent user message(s)",
|
||||
dropped,
|
||||
merges,
|
||||
)
|
||||
return merged
|
||||
|
||||
@staticmethod
|
||||
def _cap_delegate_task_calls(tool_calls: list) -> list:
|
||||
"""Truncate excess delegate_task calls to max_concurrent_children.
|
||||
@@ -9498,6 +9637,10 @@ class AIAgent:
|
||||
for idx, pfm in enumerate(self.prefill_messages):
|
||||
api_messages.insert(sys_offset + idx, pfm.copy())
|
||||
|
||||
# Same safety net as the main loop: drop thinking-only assistant
|
||||
# turns so Anthropic-family providers don't 400 the summary call.
|
||||
api_messages = self._drop_thinking_only_and_merge_users(api_messages)
|
||||
|
||||
summary_extra_body = {}
|
||||
try:
|
||||
from agent.auxiliary_client import _fixed_temperature_for_model, OMIT_TEMPERATURE as _OMIT_TEMP
|
||||
@@ -10208,6 +10351,16 @@ class AIAgent:
|
||||
# manual message manipulation are always caught.
|
||||
api_messages = self._sanitize_api_messages(api_messages)
|
||||
|
||||
# Drop thinking-only assistant turns (reasoning but no visible
|
||||
# output and no tool_calls) and merge any adjacent user messages
|
||||
# left behind. Prevents Anthropic 400s ("The final block in an
|
||||
# assistant message cannot be `thinking`.") and equivalent errors
|
||||
# from third-party Anthropic-compatible gateways that can't replay
|
||||
# a thinking-only turn. Runs on the per-call copy only — the
|
||||
# stored conversation history keeps the reasoning block for the
|
||||
# UI transcript and session persistence.
|
||||
api_messages = self._drop_thinking_only_and_merge_users(api_messages)
|
||||
|
||||
# Normalize message whitespace and tool-call JSON for consistent
|
||||
# prefix matching. Ensures bit-perfect prefixes across turns,
|
||||
# which enables KV cache reuse on local inference servers
|
||||
|
||||
Reference in New Issue
Block a user