feat(runtime): add planner reflection history with rationale and thinking
Tests / Test failed: 2, passed: 849

- ToolCallDecision captures thinking blocks and pre-tool text output
- AnthropicToolCallingClient supports optional extended thinking (budget_tokens + beta header)
- PlannedStep carries rationale and thinking from each LLM decision
- WorldEvent replaces scene_summary with rationale/thinking/page fields (backward-compatible)
- AI planner system prompt instructs reflection before each tool call
- _history_summary() emits compact {page, rationale, action, success} dicts
- Cloud DB migration 0011 adds nullable rationale/thinking columns to planner_decision_log
- OpenAI client extracts reasoning_content into thinking field
This commit is contained in:
2026-07-15 12:43:22 +08:00
parent 96e403ee47
commit a5aeb8889c
26 changed files with 903 additions and 25 deletions
+50 -15
View File
@@ -30,6 +30,12 @@ class ToolCallDecision:
# CloudProxyToolCallingClient) that don't surface them.
system_prompt: str = ""
user_prompt: str = ""
# Pre-tool text block emitted by the model (rationale / reflection).
# None when the model omits a text block before the tool call.
text_output: str | None = None
# Extended thinking block (Anthropic) or reasoning_content (OpenAI o-series).
# None when not enabled or not present in the response.
thinking: str | None = None
class ToolCallingClient(Protocol):
@@ -53,12 +59,14 @@ class AnthropicToolCallingClient:
max_tokens: int = 1024,
api_key: str | None = None,
base_url: str | None = None,
thinking_budget_tokens: int | None = None,
) -> None:
self.model = model
self._transport = transport
self.max_tokens = max_tokens
self._api_key = api_key
self._base_url = base_url
self._thinking_budget_tokens = thinking_budget_tokens
def decide(
self,
@@ -97,9 +105,14 @@ class AnthropicToolCallingClient:
timeout: float,
) -> Any:
client = self._client()
kwargs = {
budget = self._thinking_budget_tokens
# 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)
kwargs: dict[str, Any] = {
"model": self.model,
"max_tokens": self.max_tokens,
"max_tokens": max_tokens,
"timeout": timeout,
"system": [
{
@@ -117,6 +130,9 @@ class AnthropicToolCallingClient:
"tools": [_anthropic_tool(spec) for spec in tools],
"tool_choice": {"type": "any", "disable_parallel_tool_use": True},
}
if budget is not None:
kwargs["thinking"] = {"type": "enabled", "budget_tokens": budget}
kwargs["betas"] = ["interleaved-thinking-2025-05-14"]
messages = getattr(client, "messages", None)
if messages is not None:
return messages.create(**kwargs)
@@ -232,7 +248,10 @@ def build_client(config: PlannerConfig) -> ToolCallingClient:
model = config.resolved_model()
if config.provider == "openai":
return OpenAIToolCallingClient(model=model)
return AnthropicToolCallingClient(model=model)
return AnthropicToolCallingClient(
model=model,
thinking_budget_tokens=config.thinking_budget_tokens,
)
def _anthropic_content(
@@ -272,19 +291,31 @@ def _decision_from_anthropic_response(
content = _value(response, "content")
if not isinstance(content, list):
raise ValueError("anthropic tool-call response missing content list")
thinking: str | None = None
text_parts: list[str] = []
for block in content:
if _value(block, "type") != "tool_use":
continue
name = _value(block, "name")
arguments = _value(block, "input")
if isinstance(name, str) and isinstance(arguments, dict):
return ToolCallDecision(
tool_name=name,
arguments=arguments,
usage=_anthropic_usage(response),
system_prompt=system_prompt,
user_prompt=user_prompt,
)
block_type = _value(block, "type")
if block_type == "thinking" and thinking is None:
raw = _value(block, "thinking")
if isinstance(raw, str):
thinking = raw
elif block_type == "text":
raw = _value(block, "text")
if isinstance(raw, str):
text_parts.append(raw)
elif block_type == "tool_use":
name = _value(block, "name")
arguments = _value(block, "input")
if isinstance(name, str) and isinstance(arguments, dict):
return ToolCallDecision(
tool_name=name,
arguments=arguments,
usage=_anthropic_usage(response),
system_prompt=system_prompt,
user_prompt=user_prompt,
text_output="\n".join(text_parts) if text_parts else None,
thinking=thinking,
)
raise ValueError("anthropic response did not include a tool_use block")
@@ -333,12 +364,16 @@ def _decision_from_openai_response(
if not isinstance(name, str):
raise ValueError("openai tool call missing a function name")
arguments = _decode_openai_arguments(_value(function, "arguments"))
# 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
return ToolCallDecision(
tool_name=name,
arguments=arguments,
usage=_openai_usage(response),
system_prompt=system_prompt,
user_prompt=user_prompt,
thinking=thinking,
)