Files
agentic-mobile-control/openspec/changes/planner-reflection-history/design.md
T
q792602257 a5aeb8889c
Tests / Test failed: 2, passed: 849
feat(runtime): add planner reflection history with rationale and thinking
- 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
2026-07-15 12:43:22 +08:00

124 lines
10 KiB
Markdown

## 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.