fix(planner): allow rationale/thinking by using tool_choice=auto
Tests / Test passed: 855

Forced tool_choice ("any"/"required") makes both Anthropic and OpenAI
skip any text/thinking block before the tool call, which silently made
rationale and thinking always None despite the planner-reflection-history
change's capture code being correct. Switch the primary call to
tool_choice="auto" (Anthropic: type=auto, disable_parallel_tool_use=true;
OpenAI: "auto") so the model can emit its reflection text, and add a
one-time forced retry (Anthropic "any", OpenAI "required", thinking
disabled) if the model responds without a tool call, guaranteeing a step
never stalls. Also add OpenAI text_output capture from message.content,
which was never extracted before (Anthropic-only gap).

Update planner-reflection-history design.md/tasks.md to document the bug
found during the pending manual smoke test (task 8.5) and the fix (new
section 9).
This commit is contained in:
2026-07-15 13:43:39 +08:00
parent 24992fc9fb
commit 367fd0d412
4 changed files with 250 additions and 16 deletions
+62 -3
View File
@@ -84,7 +84,24 @@ class AnthropicToolCallingClient:
screenshot,
tools,
timeout=timeout,
forced=False,
)
if not _anthropic_response_has_tool_use(response):
# tool_choice="any"/"tool" forces Claude to skip any preceding
# text block, so the reflection instructed by the system
# prompt requires tool_choice="auto". That leaves a small
# chance the model responds without calling a tool at all;
# retry once with tool_choice="any" to guarantee progress.
# The forced retry cannot carry rationale/thinking (Anthropic
# rejects thinking combined with forced tool_choice).
response = self._create_message(
system_prompt,
user_prompt,
screenshot,
tools,
timeout=timeout,
forced=True,
)
return _decision_from_anthropic_response(
response,
system_prompt=system_prompt,
@@ -103,13 +120,16 @@ class AnthropicToolCallingClient:
tools: list[ToolSpec],
*,
timeout: float,
forced: bool,
) -> Any:
client = self._client()
budget = self._thinking_budget_tokens
# Forced tool_choice is incompatible with extended thinking.
budget = self._thinking_budget_tokens if not forced else None
# Enforce max_tokens >= budget + 1 when thinking is enabled.
max_tokens = self.max_tokens
if budget is not None:
max_tokens = max(max_tokens, budget + 1)
tool_choice_type = "any" if forced else "auto"
kwargs: dict[str, Any] = {
"model": self.model,
"max_tokens": max_tokens,
@@ -128,7 +148,10 @@ class AnthropicToolCallingClient:
}
],
"tools": [_anthropic_tool(spec) for spec in tools],
"tool_choice": {"type": "any", "disable_parallel_tool_use": True},
"tool_choice": {
"type": tool_choice_type,
"disable_parallel_tool_use": True,
},
}
if budget is not None:
kwargs["thinking"] = {"type": "enabled", "budget_tokens": budget}
@@ -188,7 +211,21 @@ class OpenAIToolCallingClient:
screenshot,
tools,
timeout=timeout,
forced=False,
)
if not _openai_response_has_tool_call(response):
# tool_choice="required" forces a function call and suppresses
# any reflection text, so capturing rationale requires
# tool_choice="auto". Retry once, forcing tool use, if the
# model responds without calling a tool at all.
response = self._create_completion(
system_prompt,
user_prompt,
screenshot,
tools,
timeout=timeout,
forced=True,
)
return _decision_from_openai_response(
response,
system_prompt=system_prompt,
@@ -207,6 +244,7 @@ class OpenAIToolCallingClient:
tools: list[ToolSpec],
*,
timeout: float,
forced: bool,
) -> Any:
client = self._client()
kwargs = {
@@ -218,7 +256,7 @@ class OpenAIToolCallingClient:
{"role": "user", "content": _openai_content(user_prompt, screenshot)},
],
"tools": [_openai_tool(spec) for spec in tools],
"tool_choice": "required",
"tool_choice": "required" if forced else "auto",
"parallel_tool_calls": False,
}
chat = getattr(client, "chat", None)
@@ -282,6 +320,13 @@ def _anthropic_tool(spec: ToolSpec) -> dict[str, Any]:
}
def _anthropic_response_has_tool_use(response: Any) -> bool:
content = _value(response, "content")
if not isinstance(content, list):
return False
return any(_value(block, "type") == "tool_use" for block in content)
def _decision_from_anthropic_response(
response: Any,
*,
@@ -346,6 +391,15 @@ def _openai_tool(spec: ToolSpec) -> dict[str, Any]:
}
def _openai_response_has_tool_call(response: Any) -> bool:
choices = _value(response, "choices")
if not isinstance(choices, list) or not choices:
return False
message = _value(choices[0], "message")
tool_calls = _value(message, "tool_calls")
return isinstance(tool_calls, list) and len(tool_calls) > 0
def _decision_from_openai_response(
response: Any,
*,
@@ -367,6 +421,10 @@ def _decision_from_openai_response(
# Extract reasoning_content from o-series models when present.
raw_reasoning = _value(message, "reasoning_content")
thinking: str | None = raw_reasoning if isinstance(raw_reasoning, str) else None
# Pre-tool reflection text (rationale), when the model emits content
# alongside the tool call under tool_choice="auto".
raw_content = _value(message, "content")
text_output: str | None = raw_content if isinstance(raw_content, str) else None
return ToolCallDecision(
tool_name=name,
arguments=arguments,
@@ -374,6 +432,7 @@ def _decision_from_openai_response(
system_prompt=system_prompt,
user_prompt=user_prompt,
thinking=thinking,
text_output=text_output,
)