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
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-07-15
@@ -0,0 +1,123 @@
## Context
The AI Planner currently invokes one LLM call per step via `AIPlanner.plan()`, returning a `PlannedStep` with action/args. The `ToolCallingClient` extracts only the `tool_use` block from the response; any `thinking` or `text` blocks in the Anthropic response are silently discarded.
`WorldModel._append_history()` records one `WorldEvent` per step, storing the full `scene_summary` (SemanticScene or Scene). `_history_summary()` in `ai_planner.py` serialises these as full JSON for the next prompt, causing rapid token growth. The planner has no mechanism to reflect on whether a prior action achieved its intended effect.
Current state of the pipeline:
```
plan(scene, world) → user_prompt → LLM → [thinking?, text?, tool_use] → only tool_use kept
WorldEvent(scene_summary=<full UI tree>, action, success) → history → next prompt (fat)
```
Target state:
```
plan(scene, world) → user_prompt → LLM → thinking (kept) + text=rationale (kept) + tool_use
WorldEvent(rationale, thinking, current_page, action, success) → compact history → next prompt
```
Constraints:
- `runtime/` package must not import `cloud` or `host_agent` concerns (enforced by existing test).
- `CloudProxyToolCallingClient` lives in `host_agent/` and must remain opaque to this change — it forwards raw prompts to the Cloud API and receives `tool_name`/`arguments` back; thinking/text are not surfaced in that transport path.
- Extended thinking requires Anthropic SDK and is Anthropic-only; must be opt-in and degrade gracefully when not configured.
- `WorldEvent` schema change must be backward-compatible: existing stored events without `rationale`/`thinking` fields must remain readable.
## Goals / Non-Goals
**Goals:**
- Capture AI rationale (pre-tool text reflection) from every planning call where the model outputs one.
- Capture extended thinking when `AI_PLANNER_THINKING_BUDGET_TOKENS` is configured.
- Reduce per-step history token cost by replacing `scene_summary` with compact `{page, rationale, action, success}` in the prompt history format.
- Prompt the AI to evaluate the previous step's outcome before selecting the next action (method C: same LLM call, no extra API round-trip).
- Persist `thinking` and `rationale` in the Cloud-side `planner_decision_log` table alongside existing prompt/tool records.
- Capture OpenAI `reasoning_content` when present (o-series models).
**Non-Goals:**
- Not adding a dedicated reflection LLM call (explicit method B) — method C (pre-tool text block) achieves the intent within the existing call budget.
- Not changing `CloudProxyToolCallingClient` — it transparently proxies and is not aware of thinking/text blocks.
- Not changing the `scene_summary` field in `WorldEvent` for stored/displayed task history in the Console — only the prompt-construction path (`_history_summary()`) is changed.
- Not surfacing thinking/rationale in the Cloud Console UI (that's a follow-on concern).
- Not making `thinking_budget_tokens` configurable per-task at runtime (only via environment variable).
## Decisions
### D1: Method C — reflection within the same LLM call via pre-tool text block
**Decision**: Update `PLANNER_SYSTEM_PROMPT` to instruct the AI to output a short text block before the tool call assessing the previous step's outcome and stating the current step's intent. Extract this text block as `text_output` alongside the `tool_use` block.
**Why over method B (explicit reflection call)**: Method B doubles LLM calls per step (and associated latency/cost). The AI already has the previous action and current scene available; requiring a text block costs one extra sentence in the response, not a round-trip.
**Why over method A (implicit)**: Method A relies on the AI organically reflecting without prompting, which is unreliable. Explicit instruction in the system prompt makes it consistent.
**Constraint**: `tool_choice: {type: "any"}` already allows text blocks before a tool use block. No API parameter changes needed for text output capture.
### D2: Extend `ToolCallDecision` with `thinking` and `text_output`
**Decision**: Add `thinking: str | None = None` and `text_output: str | None = None` to `ToolCallDecision`. `_decision_from_anthropic_response()` iterates all content blocks, collecting the first `thinking` block's text and concatenating any `text` blocks. `_decision_from_openai_response()` extracts `reasoning_content` from the first choice's message when present.
**Why not a separate data structure**: `ToolCallDecision` is the single return type of `decide()`; adding optional fields keeps the interface minimal and backward-compatible for `CloudProxyToolCallingClient` which sets neither field.
### D3: Extended thinking via `thinking_budget_tokens` in `PlannerConfig`
**Decision**: Add `thinking_budget_tokens: int | None = None` to `PlannerConfig`, loaded from `AI_PLANNER_THINKING_BUDGET_TOKENS` env var. When set and provider is `anthropic`, `AnthropicToolCallingClient._create_message()` adds `thinking: {type: "enabled", budget_tokens: N}` and the `interleaved-thinking-2025-05-14` beta header. `max_tokens` must be ≥ `budget_tokens + 1`; the client enforces this by taking `max(max_tokens, budget_tokens + 1)`.
**Why opt-in via env var**: Extended thinking increases latency and cost significantly. Most tasks don't need it. Keeping it off by default preserves existing behaviour exactly.
**Why enforce max_tokens floor**: The Anthropic API rejects requests where `max_tokens ≤ budget_tokens`; silent enforcement avoids a confusing runtime error.
### D4: `PlannedStep` carries `rationale` and `thinking`
**Decision**: Add `rationale: str | None = None` and `thinking: str | None = None` to `PlannedStep`. `AIPlanner.plan()` populates these from `decision.text_output` and `decision.thinking`.
**Why on `PlannedStep`**: `WorldModel._append_history()` receives the `PlannedStep`; this is the natural carrier from the planner layer to the world model layer without adding a new coupling.
### D5: `WorldEvent` gets `rationale` and `thinking`; `scene_summary` becomes optional
**Decision**: `WorldEvent.scene_summary` changes from `SemanticScene | Scene` to `SemanticScene | Scene | None`, defaulting to `None`. New fields `rationale: str | None = None` and `thinking: str | None = None` are added. `_append_history()` in `WorldModel` passes `step.rationale` and `step.thinking` and no longer requires a non-None `scene_summary`.
**Why keep `scene_summary` as optional rather than removing it**: Some callers (e.g., direct non-AI planner paths) still populate it. Keeping it optional is backward-compatible and avoids breaking the field in stored timelines.
**Why `current_page` instead of `scene_summary` in prompt history**: `WorldState.current_page` is a single string (e.g. `"Settings > Account"`), already maintained by `WorldModel`. Including it in the compact history record gives the AI verifiable page-level context to validate the previous step's navigation intent, at near-zero token cost.
### D6: Compact history format in `_history_summary()`
**Decision**: `_history_summary()` in `ai_planner.py` switches from `[event.to_dict() for event in world.history]` to a compact list:
```python
[{
"page": event.page,
"action": event.action,
"rationale": event.rationale,
"success": event.success,
} for event in world.history]
```
`event.page` is added as a field to `WorldEvent` (populated from `WorldState.current_page` at the time of recording).
**Why not expose `thinking` in history**: Thinking blocks are verbose internal reasoning, not summaries. Exposing them in history would re-introduce token bloat. Rationale (the AI's own compact 1–2 sentence reflection) is the right unit for history.
### D7: `planner_decision_log` table extended with `thinking` and `rationale`
**Decision**: Add nullable `thinking TEXT` and `rationale TEXT` columns to `planner_decision_log` via a new Alembic migration. `record_planner_decision()` in `cloud/internal_api/api.py` and `PlannerDecisionRecord` in `cloud/internal_api/models.py` are extended to carry these fields. Existing rows without the columns read as `NULL`.
**Why in the cloud decision log**: The cloud-proxy path receives the full `PlannerDecisionRecord` including `thinking`/`rationale` from the Host Agent. Persisting them completes the audit trail for cloud-transport tasks.
**Why separate columns rather than a JSON blob**: The existing table uses discrete columns for `system_prompt`, `user_prompt`, `tool_name`, `tool_arguments` — consistency favours discrete columns. Both fields are optional (cloud-proxy path only; `direct` transport never produces cloud decision records).
## Risks / Trade-offs
- **AI may not always output a text block**: `tool_choice: {type: "any"}` does not guarantee a text block. When absent, `text_output` is `None` and `rationale` is `None`. History degrades gracefully to `{page, rationale: null, action, success}`. No retries or fallbacks needed.
- **Extended thinking increases latency**: `budget_tokens` directly adds to minimum response time. This is opt-in and accepted by the operator who enables it.
- **`WorldEvent` schema divergence from stored data**: Existing `WorldEvent` instances in memory or serialised timelines lack `rationale`/`thinking`. The `to_dict()` method will emit `null` for these fields; downstream consumers should treat `null` as absent, not as a failure.
- **Cloud-proxy transport never produces thinking/rationale at the client layer**: The proxy returns only `tool_name`/`arguments`. `ToolCallDecision.thinking` and `.text_output` will always be `None` for cloud-transport tasks. The `planner_decision_log` on the cloud side will be populated from the cloud-proxied call itself (D7), which does see the full LLM response.
## Migration Plan
1. Apply Alembic migration for new `planner_decision_log` columns (additive, nullable, no data migration required).
2. Deploy Cloud API with updated `PlannerDecisionRecord` and `record_planner_decision()`.
3. Deploy Host Agent with updated `tool_calling_client`, `planner_config`, `ai_planner`, `planner_prompts`, `world/models`, `world/model`.
4. Rollback: revert Host Agent to prior version (no DB rollback needed — new columns simply go unused).
## Open Questions
- None blocking implementation.
@@ -0,0 +1,41 @@
## Why
The AI planner's execution history currently stores raw `scene_summary` (full UI tree / semantic scene JSON) per step, which bloats context tokens rapidly and conveys no semantic intent. The LLM receives a long list of "tap succeeded" entries with no understanding of why each action was taken or whether it achieved its intended effect, making it prone to repeating mistakes and unable to self-correct mid-task.
## What Changes
- **Planner reflection loop (method C)**: The system prompt is updated to require the AI to output a short text block before each tool call — first evaluating whether the previous step achieved its intended effect, then stating the intent of the current step. This reflection happens within the same LLM call (no extra API round-trip).
- **Rationale capture**: `ToolCallDecision` captures the AI's pre-tool text output (`text_output`) and, when extended thinking is enabled, the thinking block (`thinking`). Both flow through `PlannedStep` into `WorldEvent`.
- **Extended thinking support**: `AnthropicToolCallingClient` gains optional `thinking_budget_tokens` config. When set, the Anthropic API is called with `thinking` enabled (interleaved thinking beta); the thinking block is extracted and stored.
- **OpenAI reasoning capture**: `OpenAIToolCallingClient` extracts `reasoning_content` from responses when present (o-series models).
- **WorldEvent schema change**: `scene_summary` is replaced by `rationale` (from `text_output`) and `thinking` (from thinking block), plus `current_page` from `WorldState` for minimal page-level verification context. This reduces per-step history token cost by an order of magnitude.
- **History format**: `_history_summary()` in `ai_planner.py` switches from full `WorldEvent.to_dict()` to a compact `{page, rationale, action, success}` format.
- **planner_decision_log extension**: The Cloud-side decision log table adds `thinking` and `rationale` columns to persist these fields alongside existing prompt/tool records.
## Capabilities
### New Capabilities
- `planner-reflection-history`: AI planner captures per-step rationale (pre-tool text reflection) and optional thinking (extended thinking block), stored in execution history and surfaced in the Cloud planner decision log.
### Modified Capabilities
- `world-model`: `WorldEvent` schema changes — `scene_summary` becomes optional (backward-compat), new `rationale: str | None` and `thinking: str | None` fields added; `history_summary` format for prompt construction changes to compact representation.
- `agent-runtime`: `PlannedStep` gains `rationale` and `thinking` fields; `ToolCallDecision` gains `text_output` and `thinking` fields.
- `cloud-task-progress-visibility`: `planner_decision_log` table extended with `thinking` and `rationale` columns.
## Impact
- `runtime/tool_calling_client.py``ToolCallDecision`, `AnthropicToolCallingClient`, `OpenAIToolCallingClient`, response parsers
- `runtime/planner.py``PlannedStep`
- `runtime/ai_planner.py``AIPlanner.plan()`, `_history_summary()`
- `runtime/planner_prompts.py``PLANNER_SYSTEM_PROMPT`, `planner_user_prompt`
- `runtime/planner_config.py` — new `thinking_budget_tokens` field
- `world/models.py``WorldEvent`
- `world/model.py``_append_history()`
- `packages/cloud-platform/cloud/db_models.py``planner_decision_log` table
- `packages/cloud-platform/cloud/schema.py` — Alembic migration
- `packages/cloud-platform/cloud/internal_api/api.py``record_planner_decision()`
- `packages/cloud-platform/cloud/internal_api/models.py``PlannerDecisionRecord`
- No changes to `CloudProxyToolCallingClient` — thinking/text are surfaced at the local client layer only; the cloud proxy is transparent to them.
- No new external dependencies; Anthropic extended thinking uses existing SDK via beta header.
@@ -0,0 +1,55 @@
## MODIFIED Requirements
### Requirement: LLM-driven Planner selects exactly one grounded action per turn
The system SHALL provide a Planner implementation that, given a goal, the current Scene, recent task history in compact rationale-based format, and optional prior-step context, uses native LLM tool/function calling to select exactly one action (or the completion signal defined below) per `plan()` invocation, grounding any coordinates in the current turn's Scene element bounds. The Planner SHALL instruct the LLM to output a short text block (rationale) before the tool call, reflecting on the previous step's outcome and stating the current step's intent.
#### Scenario: Planner selects a single action for the current turn
- **WHEN** the AI Planner is invoked with a goal and the current Scene
- **THEN** it returns at most one `PlannedStep`, whose action and arguments come from exactly one tool call chosen by the underlying LLM for that turn
#### Scenario: Planner re-decides every turn from the current Scene
- **WHEN** the AI Planner is invoked again after a prior step has executed
- **THEN** its decision is grounded in the newly observed Scene for that turn, not in coordinates or assumptions carried over from a previous turn
#### Scenario: PlannedStep carries rationale when the LLM outputs a text block
- **WHEN** the LLM response includes a text block before the tool call
- **THEN** the returned `PlannedStep` has a non-None `rationale` containing that text
#### Scenario: PlannedStep carries thinking when extended thinking is enabled and the LLM outputs a thinking block
- **WHEN** extended thinking is enabled and the LLM response includes a thinking block
- **THEN** the returned `PlannedStep` has a non-None `thinking` containing the thinking block text
#### Scenario: PlannedStep carries None rationale when no text block is present
- **WHEN** the LLM response contains only a tool call with no preceding text block
- **THEN** `PlannedStep.rationale` is `None` and the step is returned normally
### Requirement: Pluggable dual-provider tool-calling abstraction
The system SHALL support at least two interchangeable LLM providers (Anthropic native tool use and OpenAI function calling) for the AI Planner's decision calls, selectable via configuration, with both providers constrained to return exactly one tool call per request. The Anthropic client SHALL additionally support optional extended thinking via a configurable `thinking_budget_tokens` value. The OpenAI client SHALL capture `reasoning_content` from responses when present. Independently of provider selection, the system SHALL support at least two transports for making that decision call — direct-to-provider and cloud-proxy — selectable via configuration without requiring any change to `AIPlanner`'s own decision logic.
#### Scenario: Provider selected via configuration
- **WHEN** the AI Planner is configured with a given provider identifier
- **THEN** it constructs and uses the tool-calling client for that provider without requiring any change to `AIPlanner`'s own decision logic
#### Scenario: Provider response resolves to a single decision
- **WHEN** either supported provider returns a response to a tool-calling request
- **THEN** the response is parsed into exactly one tool name and one arguments object, regardless of which provider produced it
#### Scenario: Transport selected via configuration
- **WHEN** the Host Agent is configured with a given transport (direct or cloud-proxy)
- **THEN** `AIPlanner` is constructed with the tool-calling client for that transport, and its own decision logic is unchanged regardless of which transport is in effect
#### Scenario: Cloud-proxy transport is the default
- **WHEN** no transport is explicitly configured
- **THEN** the AI Planner uses the cloud-proxy transport and the Cloud Control Plane's planner-decision endpoint
#### Scenario: Direct transport remains available by explicit configuration
- **WHEN** the Host Agent is configured with the direct transport
- **THEN** the AI Planner uses the direct-to-provider transport with locally configured credentials
#### Scenario: Cloud-proxy transport resolves a decision without a local provider client
- **WHEN** the Host Agent is configured with the cloud-proxy transport
- **THEN** its tool-calling client sends the decision request to the Cloud Control Plane's planner-decision endpoint instead of constructing a local Anthropic or OpenAI SDK client
#### Scenario: Cloud-proxy transport returns None for thinking and text_output
- **WHEN** the Host Agent uses cloud-proxy transport
- **THEN** `ToolCallDecision.thinking` and `ToolCallDecision.text_output` are `None` because the proxy surface does not expose them
@@ -0,0 +1,39 @@
## MODIFIED Requirements
### Requirement: Cloud Console displays a task's full LLM interaction history
Cloud Console SHALL provide a view, for a given task, listing each persisted planner decision in step order, including its full prompt, resulting decision, and — when available — the AI's rationale (pre-tool text reflection) and thinking (extended thinking block), sourced from the Cloud Control Plane's persisted planner-decision log.
#### Scenario: Task has persisted planner decisions with rationale
- **WHEN** an operator opens the LLM interaction history view for a task that has one or more persisted planner decisions with non-null rationale
- **THEN** Cloud Console shows each decision in step order with its prompt, resulting tool call, and rationale text
#### Scenario: Task has persisted planner decisions with thinking
- **WHEN** an operator opens the LLM interaction history view for a task whose planner decisions include a non-null thinking field
- **THEN** Cloud Console shows the thinking content alongside the prompt and tool call for that step
#### Scenario: Task has persisted planner decisions without rationale or thinking
- **WHEN** an operator opens the LLM interaction history view for a task whose decisions have null rationale and null thinking
- **THEN** Cloud Console shows each decision without those fields, without error or placeholder text
#### Scenario: Task's Host used direct-to-provider transport
- **WHEN** an operator opens the LLM interaction history view for a task whose Host used direct-to-provider transport
- **THEN** Cloud Console indicates that no LLM interaction history is available because the Host does not report it, rather than showing an empty history with no explanation
### Requirement: Cloud Control Plane persists rationale and thinking in the planner decision log
The Cloud Control Plane's planner-decision log SHALL store the AI's rationale and thinking fields alongside the existing prompt and tool-call fields for each persisted decision. Both fields SHALL be nullable; absence of either field SHALL NOT prevent a decision record from being stored or queried.
#### Scenario: Decision record includes rationale
- **WHEN** the Host Agent reports a planner decision with a non-null rationale
- **THEN** the persisted `planner_decision_log` row stores that rationale text in the `rationale` column
#### Scenario: Decision record includes thinking
- **WHEN** the Host Agent reports a planner decision with a non-null thinking block
- **THEN** the persisted `planner_decision_log` row stores that thinking text in the `thinking` column
#### Scenario: Decision record has no rationale or thinking
- **WHEN** the Host Agent reports a planner decision with null rationale and null thinking (e.g., cloud-proxy transport where these are not surfaced)
- **THEN** the persisted row stores NULL for both columns without error
#### Scenario: Existing decision records without rationale or thinking remain readable
- **WHEN** the system queries a `planner_decision_log` row created before this migration
- **THEN** both `rationale` and `thinking` read as NULL, and the row is returned normally
@@ -0,0 +1,61 @@
## ADDED Requirements
### Requirement: AI planner outputs a rationale text block before each tool call
The system SHALL instruct the AI planner to output a short text block (1–2 sentences) before each tool call, first assessing whether the previous action achieved its intended effect and then stating the intent of the current action. This reflection SHALL be produced within the same LLM call as the tool selection, without a separate round-trip.
#### Scenario: Planner outputs rationale on a non-first step
- **WHEN** the AI planner is invoked for a step that has at least one prior executed step in history
- **THEN** the planner's response includes a text block that references whether the previous action succeeded and states the intent of the current action, alongside the tool call
#### Scenario: Planner outputs rationale on the first step
- **WHEN** the AI planner is invoked for the first step of a task (no prior history)
- **THEN** the planner's response may include a text block stating the intent of the first action, and the absence of a text block does not constitute a failure
#### Scenario: Absent rationale degrades gracefully
- **WHEN** the AI planner produces a tool call with no preceding text block
- **THEN** the system stores `rationale=None` for that step and continues normally without error or retry
### Requirement: Tool-calling client captures thinking and text output from LLM responses
The system SHALL extract and preserve the AI model's thinking block (when extended thinking is enabled) and any pre-tool text block (rationale) from the raw LLM response, making both available on the `ToolCallDecision` returned from `decide()`.
#### Scenario: Anthropic response contains a thinking block
- **WHEN** the Anthropic client receives a response with a `thinking`-type content block
- **THEN** `ToolCallDecision.thinking` is populated with the text of that thinking block
#### Scenario: Anthropic response contains a text block before the tool call
- **WHEN** the Anthropic client receives a response with a `text`-type content block preceding the `tool_use` block
- **THEN** `ToolCallDecision.text_output` is populated with that text
#### Scenario: OpenAI response contains reasoning content
- **WHEN** the OpenAI client receives a response whose message includes a `reasoning_content` field (o-series models)
- **THEN** `ToolCallDecision.thinking` is populated with that reasoning content
#### Scenario: Neither thinking nor text block is present
- **WHEN** the LLM response contains only a tool call block (no thinking, no text)
- **THEN** `ToolCallDecision.thinking` and `ToolCallDecision.text_output` are both `None`, and the decision is returned normally
### Requirement: Extended thinking is opt-in via configuration
The system SHALL support enabling Anthropic extended thinking for the AI planner via a `thinking_budget_tokens` configuration value. When not configured, the planner SHALL operate identically to its pre-existing behavior.
#### Scenario: Extended thinking enabled
- **WHEN** `AI_PLANNER_THINKING_BUDGET_TOKENS` is set to a positive integer and the provider is `anthropic`
- **THEN** the Anthropic client includes the thinking parameter in the API request with the configured budget, and the `interleaved-thinking` beta header is sent
#### Scenario: Extended thinking not configured (default)
- **WHEN** `AI_PLANNER_THINKING_BUDGET_TOKENS` is not set
- **THEN** the Anthropic client makes requests without the thinking parameter, identical to prior behavior
#### Scenario: Extended thinking with OpenAI provider
- **WHEN** `AI_PLANNER_THINKING_BUDGET_TOKENS` is set and the provider is `openai`
- **THEN** the OpenAI client does not apply the Anthropic thinking parameter; reasoning content is captured only if the model returns it naturally
### Requirement: Execution history uses compact rationale-based representation
The system SHALL construct the AI planner's history prompt from a compact per-step record containing the page context, rationale, action, and success flag — not the full scene JSON. This compact history SHALL be the sole format used when constructing the `history_summary` passed to `planner_user_prompt`.
#### Scenario: History prompt uses compact format
- **WHEN** `_history_summary()` is called with a `WorldState` that has one or more history entries
- **THEN** each entry in the returned list contains `page`, `rationale`, `action`, and `success` fields only, without any scene element data
#### Scenario: History prompt handles None rationale
- **WHEN** a `WorldEvent` in history has `rationale=None`
- **THEN** the compact history entry for that step includes `"rationale": null` without omitting the field or raising an error
@@ -0,0 +1,36 @@
## MODIFIED Requirements
### Requirement: Bounded history of recent scene/action pairs
The system SHALL maintain `WorldState.history` as a fixed-size, bounded collection of the most recent per-step records, automatically evicting the oldest entry when a new entry is added past the configured bound. Each history record SHALL store the action name, success flag, page context (from `WorldState.current_page` at the time of recording), and optional rationale and thinking fields sourced from the executed `PlannedStep`. The `scene_summary` field SHALL be retained as an optional field for backward compatibility but SHALL NOT be required for new entries.
#### Scenario: WorldState survives across steps within a task
- **WHEN** a task executes multiple steps in sequence
- **THEN** the `WorldState` object associated with the task is the same object (or reflects continuously accumulated updates) across those steps, not reset between steps
#### Scenario: WorldState is scoped to a single task
- **WHEN** two different tasks run (sequentially or concurrently) against the same or different devices
- **THEN** each task has its own independent `WorldState`, and neither task's `WorldState` reflects the other task's app/page/variables/history
#### Scenario: History record includes rationale when planner provides it
- **WHEN** the executed `PlannedStep` carries a non-None `rationale`
- **THEN** the resulting `WorldEvent` stores that rationale string
#### Scenario: History record stores None rationale when planner does not provide one
- **WHEN** the executed `PlannedStep` has `rationale=None`
- **THEN** the resulting `WorldEvent` stores `rationale=None` without error
#### Scenario: History record includes thinking when planner provides it
- **WHEN** the executed `PlannedStep` carries a non-None `thinking`
- **THEN** the resulting `WorldEvent` stores that thinking string
#### Scenario: History record captures current page at time of recording
- **WHEN** a step is appended to history and `WorldState.current_page` is non-None at that moment
- **THEN** `WorldEvent.page` is set to that page value
#### Scenario: History record stores None page when current_page is unavailable
- **WHEN** a step is appended to history and `WorldState.current_page` is None
- **THEN** `WorldEvent.page` is `None` without error
#### Scenario: History size remains bounded
- **WHEN** steps are appended beyond the configured history bound
- **THEN** the oldest entries are evicted so history size never exceeds the bound
@@ -0,0 +1,51 @@
## 1. ToolCallDecision — capture thinking and text output
- [x] 1.1 Add `thinking: str | None = None` and `text_output: str | None = None` fields to `ToolCallDecision` in `runtime/tool_calling_client.py`
- [x] 1.2 Update `_decision_from_anthropic_response()` to iterate all content blocks: collect first `thinking`-type block text into `thinking`, concatenate all `text`-type blocks into `text_output`, continue to `tool_use` as before
- [x] 1.3 Update `_decision_from_openai_response()` to extract `reasoning_content` from the first choice's message into `thinking` when present
- [x] 1.4 Add unit tests: Anthropic response with thinking block → `decision.thinking` populated; Anthropic response with text block → `decision.text_output` populated; response with only `tool_use` → both `None`; OpenAI response with `reasoning_content``decision.thinking` populated
## 2. PlannerConfig — extended thinking configuration
- [x] 2.1 Add `thinking_budget_tokens: int | None = None` to `PlannerConfig` in `runtime/planner_config.py`
- [x] 2.2 Add `AI_PLANNER_THINKING_BUDGET_TOKENS` env var constant and `_parse_thinking_budget()` helper; wire into `load_config()`
- [x] 2.3 Update `AnthropicToolCallingClient._create_message()`: when `thinking_budget_tokens` is set, add `thinking: {type: "enabled", budget_tokens: N}` to kwargs, add `interleaved-thinking-2025-05-14` beta header via `betas` parameter, and enforce `max_tokens >= thinking_budget_tokens + 1`
- [x] 2.4 Add unit tests: `load_config()` with env var set → `thinking_budget_tokens` parsed; `_create_message()` with budget set → kwargs include thinking param and beta header; without budget → no thinking param in kwargs
## 3. PlannedStep — carry rationale and thinking
- [x] 3.1 Add `rationale: str | None = None` and `thinking: str | None = None` to the `PlannedStep` dataclass in `runtime/planner.py`
- [x] 3.2 Update `AIPlanner.plan()` in `runtime/ai_planner.py`: populate `PlannedStep.rationale = decision.text_output` and `PlannedStep.thinking = decision.thinking`
## 4. Planner prompts — reflection instruction
- [x] 4.1 Update `PLANNER_SYSTEM_PROMPT` in `runtime/planner_prompts.py` to instruct the AI to output a short text block (1–2 sentences) before each tool call: first assessing whether the previous action achieved its intended effect (or noting "first step" if no history), then stating the intent of the current action
- [x] 4.2 Verify via prompt review that the instruction is consistent with `tool_choice: {type: "any"}` (text blocks are already allowed before tool_use blocks — no API change needed)
## 5. History format — compact rationale-based representation
- [x] 5.1 Update `_history_summary()` in `runtime/ai_planner.py` to return compact dicts `{page, rationale, action, success}` instead of `[event.to_dict() for event in world.history]`
- [x] 5.2 Add unit test: `_history_summary()` with a populated `WorldState` returns list of compact dicts without scene element data
## 6. WorldEvent — rationale, thinking, page fields
- [x] 6.1 Update `WorldEvent` in `world/models.py`: make `scene_summary` optional (`SemanticScene | Scene | None = None`), add `rationale: str | None = None`, `thinking: str | None = None`, `page: str | None = None`
- [x] 6.2 Update `WorldEvent.to_dict()` to include `rationale`, `thinking`, `page` fields; keep `scene_summary` serialisation for backward compatibility
- [x] 6.3 Update `WorldModel._append_history()` in `world/model.py` to pass `rationale=step.rationale`, `thinking=step.thinking`, `page=world_state.current_page` when constructing `WorldEvent`; `scene_summary` becomes optional (pass `None` or existing value as appropriate)
- [x] 6.4 Add unit tests: `WorldEvent` with rationale/thinking/page → `to_dict()` includes those fields; `WorldEvent` with all-None optional fields → readable without error; `_append_history()` picks up `current_page` from `WorldState` at time of call
## 7. Cloud DB — planner_decision_log extension
- [x] 7.1 Add `thinking` and `rationale` nullable TEXT columns to the `planner_decision_log` table in `packages/cloud-platform/cloud/db_models.py`
- [x] 7.2 Create a new Alembic migration (e.g. `0010_planner_decision_log_reflection`) adding the two nullable columns; verify it applies cleanly on both SQLite and PostgreSQL migration paths
- [x] 7.3 Add `thinking: str | None = None` and `rationale: str | None = None` to `PlannerDecisionRecord` in `packages/cloud-platform/cloud/internal_api/models.py`
- [x] 7.4 Update `record_planner_decision()` in `packages/cloud-platform/cloud/internal_api/api.py` to write `thinking` and `rationale` from the incoming `PlannerDecisionRecord` to the new columns
- [x] 7.5 Add unit tests: decision record with rationale/thinking → both stored; decision record with nulls → stored without error; existing rows queried → both columns read as NULL without error
## 8. End-to-end verification
- [x] 8.1 Run full non-integration test suite (`uv run --all-packages pytest -m "not integration"`) and confirm no regressions
- [x] 8.2 Run `ruff check` and `ruff format --check` on all modified files
- [x] 8.3 Run `python -m compileall` on modified packages
- [x] 8.4 Run `openspec validate --strict --change "planner-reflection-history"` and confirm all artifacts pass validation
- [ ] 8.5 Manual smoke test (optional, requires Host Agent + Appium + iPhone): verify that a live task run produces non-null `rationale` in `WorldEvent` history and that the history prompt passed to the LLM is in compact format
@@ -155,6 +155,8 @@ class PlannerDecisionLogRow(Base):
tool_name: Mapped[str] = mapped_column(String, nullable=False) tool_name: Mapped[str] = mapped_column(String, nullable=False)
arguments_json: Mapped[str] = mapped_column(Text, nullable=False) arguments_json: Mapped[str] = mapped_column(Text, nullable=False)
created_at: Mapped[str] = mapped_column(String, nullable=False) created_at: Mapped[str] = mapped_column(String, nullable=False)
rationale: Mapped[str | None] = mapped_column(Text, nullable=True)
thinking: Mapped[str | None] = mapped_column(Text, nullable=True)
class PluginRow(Base): class PluginRow(Base):
@@ -504,6 +504,8 @@ def create_internal_router(
tool_name=decision.tool_name, tool_name=decision.tool_name,
arguments_json=json.dumps(decision.arguments), arguments_json=json.dumps(decision.arguments),
now=utc_now(), now=utc_now(),
rationale=getattr(decision, "text_output", None),
thinking=getattr(decision, "thinking", None),
) )
logger.info( logger.info(
"planner-decision request resolved", "planner-decision request resolved",
@@ -0,0 +1,27 @@
"""Add rationale and thinking columns to planner_decision_log."""
from __future__ import annotations
import sqlalchemy as sa
from alembic import op
revision = "0011_planner_decision_log_reflection"
down_revision = "0010_skill_management"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column(
"planner_decision_log",
sa.Column("rationale", sa.Text(), nullable=True),
)
op.add_column(
"planner_decision_log",
sa.Column("thinking", sa.Text(), nullable=True),
)
def downgrade() -> None:
op.drop_column("planner_decision_log", "thinking")
op.drop_column("planner_decision_log", "rationale")
@@ -134,6 +134,8 @@ class PlannerDecisionRecord:
tool_name: str tool_name: str
arguments_json: str arguments_json: str
created_at: datetime created_at: datetime
rationale: str | None = None
thinking: str | None = None
class CloudRepository(Protocol): class CloudRepository(Protocol):
@@ -518,6 +520,8 @@ class CloudRepository(Protocol):
tool_name: str, tool_name: str,
arguments_json: str, arguments_json: str,
now: datetime, now: datetime,
rationale: str | None = None,
thinking: str | None = None,
) -> int: ) -> int:
"""Insert one planner-decision log row, returning the assigned step_index. """Insert one planner-decision log row, returning the assigned step_index.
+1 -1
View File
@@ -9,7 +9,7 @@ from alembic.runtime.migration import MigrationContext
from cloud.database import create_database_engine, normalize_database_url from cloud.database import create_database_engine, normalize_database_url
HEAD_REVISION = "0010_skill_management" HEAD_REVISION = "0011_planner_decision_log_reflection"
class SchemaVersionError(RuntimeError): class SchemaVersionError(RuntimeError):
@@ -1694,6 +1694,8 @@ class SQLAlchemyCloudRepository:
tool_name: str, tool_name: str,
arguments_json: str, arguments_json: str,
now: datetime, now: datetime,
rationale: str | None = None,
thinking: str | None = None,
) -> int: ) -> int:
with self._sessions.begin() as session: with self._sessions.begin() as session:
current_max = session.scalars( current_max = session.scalars(
@@ -1714,6 +1716,8 @@ class SQLAlchemyCloudRepository:
tool_name=tool_name, tool_name=tool_name,
arguments_json=arguments_json, arguments_json=arguments_json,
created_at=_iso(now), created_at=_iso(now),
rationale=rationale,
thinking=thinking,
) )
) )
session.flush() session.flush()
@@ -1766,6 +1770,8 @@ class SQLAlchemyCloudRepository:
tool_name=row.tool_name, tool_name=row.tool_name,
arguments_json=row.arguments_json, arguments_json=row.arguments_json,
created_at=_parse_dt(row.created_at), # type: ignore[arg-type] created_at=_parse_dt(row.created_at), # type: ignore[arg-type]
rationale=row.rationale,
thinking=row.thinking,
) )
for row in rows for row in rows
] ]
+11 -1
View File
@@ -60,6 +60,8 @@ class AIPlanner(Planner):
description=f"AI planner: {decision.tool_name}({decision.arguments})", description=f"AI planner: {decision.tool_name}({decision.arguments})",
args=dict(decision.arguments), args=dict(decision.arguments),
prompt=decision.user_prompt or user_prompt, 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]]: def _history_summary(world: "WorldState | None") -> list[dict[str, Any]]:
if world is None: if world is None:
return [] 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). # 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. # ``None`` for non-LLM planners; TaskRunner falls back to the task goal.
prompt: str | None = None 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: class Planner:
+13
View File
@@ -15,6 +15,7 @@ ENABLED_ENV = "AI_PLANNER_ENABLED"
PROVIDER_ENV = "AI_PLANNER_PROVIDER" PROVIDER_ENV = "AI_PLANNER_PROVIDER"
MODEL_ENV = "AI_PLANNER_MODEL" MODEL_ENV = "AI_PLANNER_MODEL"
TIMEOUT_ENV = "AI_PLANNER_TIMEOUT_SECONDS" TIMEOUT_ENV = "AI_PLANNER_TIMEOUT_SECONDS"
THINKING_BUDGET_ENV = "AI_PLANNER_THINKING_BUDGET_TOKENS"
SUPPORTED_PROVIDERS = frozenset(DEFAULT_MODEL_BY_PROVIDER) SUPPORTED_PROVIDERS = frozenset(DEFAULT_MODEL_BY_PROVIDER)
@@ -25,6 +26,7 @@ class PlannerConfig:
provider: str = DEFAULT_PROVIDER provider: str = DEFAULT_PROVIDER
model: str = "" model: str = ""
timeout: float = DEFAULT_TIMEOUT_SECONDS timeout: float = DEFAULT_TIMEOUT_SECONDS
thinking_budget_tokens: int | None = None
def resolved_model(self) -> str: def resolved_model(self) -> str:
return self.model or DEFAULT_MODEL_BY_PROVIDER[self.provider] 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)), provider=_parse_provider(values.get(PROVIDER_ENV)),
model=values.get(MODEL_ENV) or "", model=values.get(MODEL_ENV) or "",
timeout=_parse_timeout(values.get(TIMEOUT_ENV)), 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: except ValueError:
return DEFAULT_TIMEOUT_SECONDS return DEFAULT_TIMEOUT_SECONDS
return timeout if timeout > 0 else 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 available a screenshot of the same screen and a short history of recent
actions and their outcomes. 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 - One of `tap`, `swipe`, `input_text`, `launch_app`, `terminate_app` to make
progress toward the goal. progress toward the goal.
- `finish_task` when the goal has been reached, or when it cannot be reached - `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. # CloudProxyToolCallingClient) that don't surface them.
system_prompt: str = "" system_prompt: str = ""
user_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): class ToolCallingClient(Protocol):
@@ -53,12 +59,14 @@ class AnthropicToolCallingClient:
max_tokens: int = 1024, max_tokens: int = 1024,
api_key: str | None = None, api_key: str | None = None,
base_url: str | None = None, base_url: str | None = None,
thinking_budget_tokens: int | None = None,
) -> None: ) -> None:
self.model = model self.model = model
self._transport = transport self._transport = transport
self.max_tokens = max_tokens self.max_tokens = max_tokens
self._api_key = api_key self._api_key = api_key
self._base_url = base_url self._base_url = base_url
self._thinking_budget_tokens = thinking_budget_tokens
def decide( def decide(
self, self,
@@ -97,9 +105,14 @@ class AnthropicToolCallingClient:
timeout: float, timeout: float,
) -> Any: ) -> Any:
client = self._client() 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, "model": self.model,
"max_tokens": self.max_tokens, "max_tokens": max_tokens,
"timeout": timeout, "timeout": timeout,
"system": [ "system": [
{ {
@@ -117,6 +130,9 @@ class AnthropicToolCallingClient:
"tools": [_anthropic_tool(spec) for spec in tools], "tools": [_anthropic_tool(spec) for spec in tools],
"tool_choice": {"type": "any", "disable_parallel_tool_use": True}, "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) messages = getattr(client, "messages", None)
if messages is not None: if messages is not None:
return messages.create(**kwargs) return messages.create(**kwargs)
@@ -232,7 +248,10 @@ def build_client(config: PlannerConfig) -> ToolCallingClient:
model = config.resolved_model() model = config.resolved_model()
if config.provider == "openai": if config.provider == "openai":
return OpenAIToolCallingClient(model=model) return OpenAIToolCallingClient(model=model)
return AnthropicToolCallingClient(model=model) return AnthropicToolCallingClient(
model=model,
thinking_budget_tokens=config.thinking_budget_tokens,
)
def _anthropic_content( def _anthropic_content(
@@ -272,19 +291,31 @@ def _decision_from_anthropic_response(
content = _value(response, "content") content = _value(response, "content")
if not isinstance(content, list): if not isinstance(content, list):
raise ValueError("anthropic tool-call response missing content list") raise ValueError("anthropic tool-call response missing content list")
thinking: str | None = None
text_parts: list[str] = []
for block in content: for block in content:
if _value(block, "type") != "tool_use": block_type = _value(block, "type")
continue if block_type == "thinking" and thinking is None:
name = _value(block, "name") raw = _value(block, "thinking")
arguments = _value(block, "input") if isinstance(raw, str):
if isinstance(name, str) and isinstance(arguments, dict): thinking = raw
return ToolCallDecision( elif block_type == "text":
tool_name=name, raw = _value(block, "text")
arguments=arguments, if isinstance(raw, str):
usage=_anthropic_usage(response), text_parts.append(raw)
system_prompt=system_prompt, elif block_type == "tool_use":
user_prompt=user_prompt, 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") 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): if not isinstance(name, str):
raise ValueError("openai tool call missing a function name") raise ValueError("openai tool call missing a function name")
arguments = _decode_openai_arguments(_value(function, "arguments")) 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( return ToolCallDecision(
tool_name=name, tool_name=name,
arguments=arguments, arguments=arguments,
usage=_openai_usage(response), usage=_openai_usage(response),
system_prompt=system_prompt, system_prompt=system_prompt,
user_prompt=user_prompt, user_prompt=user_prompt,
thinking=thinking,
) )
+59
View File
@@ -195,3 +195,62 @@ def test_ai_planner_step_prompt_reflects_scene_changes() -> None:
assert steps_a[0].prompt != steps_b[0].prompt assert steps_a[0].prompt != steps_b[0].prompt
assert "Alpha" in steps_a[0].prompt assert "Alpha" in steps_a[0].prompt
assert "Beta" in steps_b[0].prompt assert "Beta" in steps_b[0].prompt
def test_ai_planner_propagates_rationale_and_thinking_to_planned_step() -> None:
client = FakeToolCallingClient(
ToolCallDecision(
tool_name="tap",
arguments={"x": 1, "y": 2},
text_output="Previous step opened settings. Now tapping account.",
thinking="I need to navigate deeper.",
)
)
planner = AIPlanner(client=client)
steps = planner.plan(goal="open account", scene=_scene(), context=_context())
assert steps[0].rationale == "Previous step opened settings. Now tapping account."
assert steps[0].thinking == "I need to navigate deeper."
def test_history_summary_returns_compact_format() -> None:
from collections import deque
from runtime.ai_planner import _history_summary
from world.models import WorldEvent, WorldState
state = WorldState(
history=deque(
[
WorldEvent(
action="tap",
success=True,
rationale="Opened settings.",
page="Home",
),
WorldEvent(
action="swipe",
success=False,
rationale=None,
page="Settings",
),
]
)
)
summary = _history_summary(state)
assert summary == [
{"page": "Home", "action": "tap", "rationale": "Opened settings.", "success": True},
{"page": "Settings", "action": "swipe", "rationale": None, "success": False},
]
# Must not contain scene element data
for entry in summary:
assert "scene_summary" not in entry
assert "elements" not in entry
def test_history_summary_returns_empty_for_none_world() -> None:
from runtime.ai_planner import _history_summary
assert _history_summary(None) == []
+59
View File
@@ -1751,3 +1751,62 @@ def test_prune_planner_decision_log_deletes_only_old_terminal_tasks(
) )
finally: finally:
database.close() database.close()
def test_record_planner_decision_stores_rationale_and_thinking(
database_url: str,
) -> None:
database = CloudDatabase(database_url)
task_id = _unique_id("reflect-task")
host_id = _unique_id("reflect-host")
now = datetime(2026, 7, 15, 0, 0, tzinfo=UTC)
try:
database.repository.record_planner_decision(
host_id=host_id,
task_id=task_id,
attempt=1,
system_prompt="system",
user_prompt="user",
tool_name="tap",
arguments_json='{"x": 1}',
now=now,
rationale="Previous step opened settings. Now tapping account.",
thinking="I need to navigate to account settings.",
)
decisions = database.repository.list_planner_decisions(task_id=task_id, attempt=1)
assert len(decisions) == 1
assert decisions[0].rationale == "Previous step opened settings. Now tapping account."
assert decisions[0].thinking == "I need to navigate to account settings."
finally:
database.close()
def test_record_planner_decision_stores_null_rationale_and_thinking(
database_url: str,
) -> None:
database = CloudDatabase(database_url)
task_id = _unique_id("reflect-null-task")
host_id = _unique_id("reflect-null-host")
now = datetime(2026, 7, 15, 0, 0, tzinfo=UTC)
try:
database.repository.record_planner_decision(
host_id=host_id,
task_id=task_id,
attempt=1,
system_prompt="system",
user_prompt="user",
tool_name="tap",
arguments_json="{}",
now=now,
# rationale and thinking omitted (default None)
)
decisions = database.repository.list_planner_decisions(task_id=task_id, attempt=1)
assert len(decisions) == 1
assert decisions[0].rationale is None
assert decisions[0].thinking is None
finally:
database.close()
+18
View File
@@ -70,3 +70,21 @@ def test_load_config_falls_back_to_default_timeout_when_invalid_or_non_positive(
for value in ["not-a-number", "0", "-5"]: for value in ["not-a-number", "0", "-5"]:
config = load_config({"AI_PLANNER_TIMEOUT_SECONDS": value, **_NO_RELEVANT_VARS}) config = load_config({"AI_PLANNER_TIMEOUT_SECONDS": value, **_NO_RELEVANT_VARS})
assert config.timeout == DEFAULT_TIMEOUT_SECONDS assert config.timeout == DEFAULT_TIMEOUT_SECONDS
def test_load_config_parses_thinking_budget_tokens() -> None:
config = load_config({"AI_PLANNER_THINKING_BUDGET_TOKENS": "4096"})
assert config.thinking_budget_tokens == 4096
def test_load_config_thinking_budget_tokens_unset_defaults_to_none() -> None:
config = load_config(_NO_RELEVANT_VARS)
assert config.thinking_budget_tokens is None
def test_load_config_thinking_budget_tokens_invalid_or_non_positive_gives_none() -> None:
for value in ["not-a-number", "0", "-1"]:
config = load_config({"AI_PLANNER_THINKING_BUDGET_TOKENS": value, **_NO_RELEVANT_VARS})
assert config.thinking_budget_tokens is None
+184
View File
@@ -435,3 +435,187 @@ def test_build_client_selects_provider_and_resolves_default_model() -> None:
def test_build_client_honors_explicit_model_override() -> None: def test_build_client_honors_explicit_model_override() -> None:
client = build_client(PlannerConfig(provider="openai", model="gpt-5.6-custom")) client = build_client(PlannerConfig(provider="openai", model="gpt-5.6-custom"))
assert client.model == "gpt-5.6-custom" assert client.model == "gpt-5.6-custom"
# --- thinking / text_output capture -----------------------------------------
def test_anthropic_client_captures_thinking_block() -> None:
messages = FakeMessages(
response={
"content": [
{"type": "thinking", "thinking": "I should tap the button."},
{"type": "tool_use", "name": "tap", "input": {"x": 10, "y": 20}},
]
}
)
client = AnthropicToolCallingClient(
model="test-model", transport=FakeTransport(messages)
)
decision = client.decide(
system_prompt="s", user_prompt="u", screenshot=None, tools=[TAP_SPEC], timeout=1
)
assert decision.thinking == "I should tap the button."
assert decision.text_output is None
def test_anthropic_client_captures_text_block_as_text_output() -> None:
messages = FakeMessages(
response={
"content": [
{"type": "text", "text": "Previous step succeeded. Now tapping login."},
{"type": "tool_use", "name": "tap", "input": {"x": 5, "y": 5}},
]
}
)
client = AnthropicToolCallingClient(
model="test-model", transport=FakeTransport(messages)
)
decision = client.decide(
system_prompt="s", user_prompt="u", screenshot=None, tools=[TAP_SPEC], timeout=1
)
assert decision.text_output == "Previous step succeeded. Now tapping login."
assert decision.thinking is None
def test_anthropic_client_captures_both_thinking_and_text_output() -> None:
messages = FakeMessages(
response={
"content": [
{"type": "thinking", "thinking": "Deep thought."},
{"type": "text", "text": "Step succeeded. Tapping next."},
{"type": "tool_use", "name": "tap", "input": {"x": 1, "y": 1}},
]
}
)
client = AnthropicToolCallingClient(
model="test-model", transport=FakeTransport(messages)
)
decision = client.decide(
system_prompt="s", user_prompt="u", screenshot=None, tools=[TAP_SPEC], timeout=1
)
assert decision.thinking == "Deep thought."
assert decision.text_output == "Step succeeded. Tapping next."
def test_anthropic_client_tool_only_response_has_none_thinking_and_text_output() -> None:
messages = FakeMessages(
response={
"content": [
{"type": "tool_use", "name": "tap", "input": {"x": 0, "y": 0}},
]
}
)
client = AnthropicToolCallingClient(
model="test-model", transport=FakeTransport(messages)
)
decision = client.decide(
system_prompt="s", user_prompt="u", screenshot=None, tools=[TAP_SPEC], timeout=1
)
assert decision.thinking is None
assert decision.text_output is None
def test_openai_client_captures_reasoning_content() -> None:
completions = FakeCompletions(
response={
"choices": [
{
"message": {
"reasoning_content": "I reasoned about this step.",
"tool_calls": [
{"function": {"name": "tap", "arguments": '{"x": 1, "y": 2}'}}
],
}
}
]
}
)
client = OpenAIToolCallingClient(
model="test-model", transport=FakeOpenAITransport(completions)
)
decision = client.decide(
system_prompt="s", user_prompt="u", screenshot=None, tools=[TAP_SPEC], timeout=1
)
assert decision.thinking == "I reasoned about this step."
assert decision.text_output is None
def test_openai_client_no_reasoning_content_gives_none_thinking() -> None:
completions = FakeCompletions(
response={
"choices": [
{
"message": {
"tool_calls": [
{"function": {"name": "tap", "arguments": '{"x": 1, "y": 2}'}}
]
}
}
]
}
)
client = OpenAIToolCallingClient(
model="test-model", transport=FakeOpenAITransport(completions)
)
decision = client.decide(
system_prompt="s", user_prompt="u", screenshot=None, tools=[TAP_SPEC], timeout=1
)
assert decision.thinking is None
def test_anthropic_client_sends_thinking_param_when_budget_set() -> None:
messages = FakeMessages(
response={
"content": [{"type": "tool_use", "name": "tap", "input": {"x": 1, "y": 2}}]
}
)
client = AnthropicToolCallingClient(
model="test-model",
transport=FakeTransport(messages),
thinking_budget_tokens=2048,
)
client.decide(
system_prompt="s", user_prompt="u", screenshot=None, tools=[TAP_SPEC], timeout=1
)
call = messages.calls[0]
assert call["thinking"] == {"type": "enabled", "budget_tokens": 2048}
assert "interleaved-thinking-2025-05-14" in call["betas"]
# max_tokens must be >= budget + 1
assert call["max_tokens"] >= 2049
def test_anthropic_client_enforces_max_tokens_floor_for_thinking() -> None:
messages = FakeMessages(
response={
"content": [{"type": "tool_use", "name": "tap", "input": {"x": 1, "y": 2}}]
}
)
# max_tokens=1024, budget=4096 → max_tokens should be raised to 4097
client = AnthropicToolCallingClient(
model="test-model",
transport=FakeTransport(messages),
max_tokens=1024,
thinking_budget_tokens=4096,
)
client.decide(
system_prompt="s", user_prompt="u", screenshot=None, tools=[TAP_SPEC], timeout=1
)
assert messages.calls[0]["max_tokens"] == 4097
def test_anthropic_client_no_thinking_param_when_budget_not_set() -> None:
messages = FakeMessages(
response={
"content": [{"type": "tool_use", "name": "tap", "input": {"x": 1, "y": 2}}]
}
)
client = AnthropicToolCallingClient(
model="test-model", transport=FakeTransport(messages)
)
client.decide(
system_prompt="s", user_prompt="u", screenshot=None, tools=[TAP_SPEC], timeout=1
)
call = messages.calls[0]
assert "thinking" not in call
assert "betas" not in call
+32 -4
View File
@@ -27,9 +27,9 @@ def test_world_event_and_state_to_dict() -> None:
widgets=[SemanticWidget(element_id="send", purpose="send message")], widgets=[SemanticWidget(element_id="send", purpose="send message")],
) )
event = WorldEvent( event = WorldEvent(
scene_summary=semantic_scene,
action="tap", action="tap",
success=True, success=True,
scene_summary=semantic_scene,
) )
state = WorldState.with_history_bound(2) state = WorldState.with_history_bound(2)
state.current_app = "com.example.chat" state.current_app = "com.example.chat"
@@ -51,10 +51,38 @@ def test_world_event_and_state_to_dict() -> None:
def test_world_state_history_evicts_oldest_entry_at_bound() -> None: def test_world_state_history_evicts_oldest_entry_at_bound() -> None:
state = WorldState.with_history_bound(2) state = WorldState.with_history_bound(2)
state.history.append(WorldEvent(_scene(), "first", True)) state.history.append(WorldEvent(action="first", success=True))
state.history.append(WorldEvent(_scene(), "second", True)) state.history.append(WorldEvent(action="second", success=True))
state.history.append(WorldEvent(_scene(), "third", True)) state.history.append(WorldEvent(action="third", success=True))
assert len(state.history) == 2 assert len(state.history) == 2
assert [event.action for event in state.history] == ["second", "third"] assert [event.action for event in state.history] == ["second", "third"]
assert state.history.maxlen == 2 assert state.history.maxlen == 2
def test_world_event_with_rationale_thinking_page() -> None:
event = WorldEvent(
action="tap",
success=True,
rationale="Previous step opened settings. Now tapping account.",
thinking="I should navigate to account settings.",
page="Settings",
)
data = event.to_dict()
assert data["rationale"] == "Previous step opened settings. Now tapping account."
assert data["thinking"] == "I should navigate to account settings."
assert data["page"] == "Settings"
assert data["scene_summary"] is None
def test_world_event_all_optional_fields_none() -> None:
event = WorldEvent(action="swipe", success=False)
data = event.to_dict()
assert data["rationale"] is None
assert data["thinking"] is None
assert data["page"] is None
assert data["scene_summary"] is None
assert data["action"] == "swipe"
assert data["success"] is False
+4 -1
View File
@@ -135,9 +135,12 @@ class WorldModel:
) -> None: ) -> None:
state.history.append( state.history.append(
WorldEvent( WorldEvent(
scene_summary=semantic_scene or scene,
action=str(getattr(step, "action", "unknown")), action=str(getattr(step, "action", "unknown")),
success=bool(getattr(result, "success", False)), success=bool(getattr(result, "success", False)),
scene_summary=semantic_scene or scene,
rationale=getattr(step, "rationale", None),
thinking=getattr(step, "thinking", None),
page=state.current_page,
) )
) )
+10 -2
View File
@@ -12,16 +12,24 @@ from world.config import DEFAULT_HISTORY_SIZE
@dataclass(frozen=True) @dataclass(frozen=True)
class WorldEvent: class WorldEvent:
scene_summary: SemanticScene | Scene
action: str action: str
success: bool success: bool
# Optional for backward compatibility; new entries created by AIPlanner
# paths leave this as None and use rationale/thinking instead.
scene_summary: SemanticScene | Scene | None = None
rationale: str | None = None
thinking: str | None = None
page: str | None = None
timestamp: datetime = field(default_factory=utc_now) timestamp: datetime = field(default_factory=utc_now)
def to_dict(self) -> dict[str, Any]: def to_dict(self) -> dict[str, Any]:
return { return {
"scene_summary": self.scene_summary.to_dict(), "scene_summary": self.scene_summary.to_dict() if self.scene_summary is not None else None,
"action": self.action, "action": self.action,
"success": self.success, "success": self.success,
"rationale": self.rationale,
"thinking": self.thinking,
"page": self.page,
"timestamp": self.timestamp.isoformat(), "timestamp": self.timestamp.isoformat(),
} }