Compare commits

...

1 Commits

Author SHA1 Message Date
Wesley Simplicio
8849d18d79 fix(agent): skip post-tool empty nudge when reasoning_content is populated
When a model's parser (Ollama qwen3.5, DeepSeek-R1, etc.) splits thinking
out of content and into a separate reasoning_content field, final_response
is empty AND contains no <think> tag. The existing inline-thinking check
_has_inline_thinking was False, so the post-tool empty nudge fired
erroneously — causing a wasted retry round-trip after every tool call.

Compute _has_separate_reasoning from the structured API fields
(reasoning_content / reasoning / reasoning_details) and gate the nudge
on it alongside _has_inline_thinking. When either flag is set, the empty
response routes to the existing prefill branch instead. Fixes #21811.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-09 13:17:16 -07:00
2 changed files with 52 additions and 0 deletions

View File

@@ -14245,10 +14245,24 @@ class AIAgent:
re.IGNORECASE,
)
)
# Parsers like Ollama's qwen3.5 PARSER or DeepSeek's
# reasoning channel split thinking out of content and
# into a separate API field (reasoning_content /
# reasoning / reasoning_details). When that happens,
# final_response is empty AND contains no <think> tag,
# so the inline check misses it and the nudge fires.
# Gate on the structured field too so the response
# routes to the prefill branch instead. Fixes #21811.
_has_separate_reasoning = bool(
getattr(assistant_message, "reasoning_content", None)
or getattr(assistant_message, "reasoning", None)
or getattr(assistant_message, "reasoning_details", None)
)
if (
_prior_was_tool
and not getattr(self, "_post_tool_empty_retried", False)
and not _has_inline_thinking # thinking model still working — let prefill handle
and not _has_separate_reasoning # ditto for parser-split reasoning fields
):
self._post_tool_empty_retried = True
# Clear stale narration so it doesn't resurface

View File

@@ -2671,6 +2671,44 @@ class TestRunConversation:
assert result["final_response"] == "Here is the actual answer."
assert result["api_calls"] == 2 # 1 original + 1 nudge retry
def test_reasoning_content_after_tool_skips_nudge_routes_to_prefill(self, agent):
"""Regression for #21811: when the model returns empty content but populated
reasoning_content after a tool call, the post-tool empty nudge must NOT fire.
The response should route to the prefill branch instead."""
self._setup_agent(agent)
agent.base_url = "http://127.0.0.1:1234/v1"
tc = _mock_tool_call(name="memory_save", arguments="{}", call_id="c1")
# Turn 1: tool call from model
tool_resp = _mock_response(content="", finish_reason="tool_calls", tool_calls=[tc])
# Turn 2: parser-split reasoning (empty content, reasoning in separate field)
reasoning_resp = _mock_response(
content=None,
finish_reason="stop",
reasoning_content="Thought: The user wants me to save…",
)
# Turn 3: prefill continuation produces the real answer
answer_resp = _mock_response(content="Saved your preference.", finish_reason="stop")
agent.client.chat.completions.create.side_effect = [tool_resp, reasoning_resp, answer_resp]
status_messages = []
with (
patch("run_agent.handle_function_call", return_value="Saved."),
patch.object(agent, "_persist_session"),
patch.object(agent, "_save_trajectory"),
patch.object(agent, "_cleanup_task_resources"),
patch.object(agent, "_emit_status", side_effect=status_messages.append),
):
result = agent.run_conversation("Save my preference: concise responses")
assert result["completed"] is True
assert result["final_response"] == "Saved your preference."
# Nudge must not have fired: no "nudging to continue" status emitted
nudge_msgs = [m for m in status_messages if "nudging" in m.lower()]
assert not nudge_msgs, (
f"Post-tool empty nudge fired despite reasoning_content being populated: {nudge_msgs}"
)
def test_empty_response_triggers_fallback_provider(self, agent):
"""After 3 empty retries, fallback provider is activated and produces content."""
self._setup_agent(agent)