feat(runtime): add planner reflection history with rationale and thinking
Tests / Test failed: 2, passed: 849
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:
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user