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:
+11
-1
@@ -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
|
||||
]
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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