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
+11 -1
View File
@@ -60,6 +60,8 @@ class AIPlanner(Planner):
description=f"AI planner: {decision.tool_name}({decision.arguments})",
args=dict(decision.arguments),
prompt=decision.user_prompt or user_prompt,
rationale=decision.text_output,
thinking=decision.thinking,
)
]
@@ -72,4 +74,12 @@ class AIPlanner(Planner):
def _history_summary(world: "WorldState | None") -> list[dict[str, Any]]:
if world is None:
return []
return [event.to_dict() for event in world.history]
return [
{
"page": event.page,
"action": event.action,
"rationale": event.rationale,
"success": event.success,
}
for event in world.history
]
+6
View File
@@ -19,6 +19,12 @@ class PlannedStep:
# The actual prompt sent to the LLM for this step (AI planners only).
# ``None`` for non-LLM planners; TaskRunner falls back to the task goal.
prompt: str | None = None
# Pre-tool text block emitted by the model before the tool call.
# None when the model omits a text block or for non-LLM planners.
rationale: str | None = None
# Extended thinking / reasoning content from the model.
# None when not enabled or not present.
thinking: str | None = None
class Planner:
+13
View File
@@ -15,6 +15,7 @@ ENABLED_ENV = "AI_PLANNER_ENABLED"
PROVIDER_ENV = "AI_PLANNER_PROVIDER"
MODEL_ENV = "AI_PLANNER_MODEL"
TIMEOUT_ENV = "AI_PLANNER_TIMEOUT_SECONDS"
THINKING_BUDGET_ENV = "AI_PLANNER_THINKING_BUDGET_TOKENS"
SUPPORTED_PROVIDERS = frozenset(DEFAULT_MODEL_BY_PROVIDER)
@@ -25,6 +26,7 @@ class PlannerConfig:
provider: str = DEFAULT_PROVIDER
model: str = ""
timeout: float = DEFAULT_TIMEOUT_SECONDS
thinking_budget_tokens: int | None = None
def resolved_model(self) -> str:
return self.model or DEFAULT_MODEL_BY_PROVIDER[self.provider]
@@ -37,6 +39,7 @@ def load_config(env: Mapping[str, str] | None = None) -> PlannerConfig:
provider=_parse_provider(values.get(PROVIDER_ENV)),
model=values.get(MODEL_ENV) or "",
timeout=_parse_timeout(values.get(TIMEOUT_ENV)),
thinking_budget_tokens=_parse_thinking_budget(values.get(THINKING_BUDGET_ENV)),
)
@@ -61,3 +64,13 @@ def _parse_timeout(value: str | None) -> float:
except ValueError:
return DEFAULT_TIMEOUT_SECONDS
return timeout if timeout > 0 else DEFAULT_TIMEOUT_SECONDS
def _parse_thinking_budget(value: str | None) -> int | None:
if value is None:
return None
try:
budget = int(value)
except ValueError:
return None
return budget if budget > 0 else None
+7 -1
View File
@@ -10,7 +10,13 @@ list of UI elements with id, type, text, and pixel bounds), and — when
available — a screenshot of the same screen and a short history of recent
actions and their outcomes.
You must call exactly one tool per turn:
Before calling a tool, output a short text block (1-2 sentences):
1. If this is the first step, state what you intend to do and why.
2. Otherwise, first assess whether the previous action achieved its intended
effect based on the current screen, then state the intent of your next action.
Keep this reflection concise and factual.
You must then call exactly one tool:
- One of `tap`, `swipe`, `input_text`, `launch_app`, `terminate_app` to make
progress toward the goal.
- `finish_task` when the goal has been reached, or when it cannot be reached
+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,
)