- 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
10 KiB
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 importcloudorhost_agentconcerns (enforced by existing test).CloudProxyToolCallingClientlives inhost_agent/and must remain opaque to this change — it forwards raw prompts to the Cloud API and receivestool_name/argumentsback; 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.
WorldEventschema change must be backward-compatible: existing stored events withoutrationale/thinkingfields 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_TOKENSis configured. - Reduce per-step history token cost by replacing
scene_summarywith 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
thinkingandrationalein the Cloud-sideplanner_decision_logtable alongside existing prompt/tool records. - Capture OpenAI
reasoning_contentwhen 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_summaryfield inWorldEventfor 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_tokensconfigurable 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:
[{
"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_outputisNoneandrationaleisNone. History degrades gracefully to{page, rationale: null, action, success}. No retries or fallbacks needed. - Extended thinking increases latency:
budget_tokensdirectly adds to minimum response time. This is opt-in and accepted by the operator who enables it. WorldEventschema divergence from stored data: ExistingWorldEventinstances in memory or serialised timelines lackrationale/thinking. Theto_dict()method will emitnullfor these fields; downstream consumers should treatnullas absent, not as a failure.- Cloud-proxy transport never produces thinking/rationale at the client layer: The proxy returns only
tool_name/arguments.ToolCallDecision.thinkingand.text_outputwill always beNonefor cloud-transport tasks. Theplanner_decision_logon the cloud side will be populated from the cloud-proxied call itself (D7), which does see the full LLM response.
Migration Plan
- Apply Alembic migration for new
planner_decision_logcolumns (additive, nullable, no data migration required). - Deploy Cloud API with updated
PlannerDecisionRecordandrecord_planner_decision(). - Deploy Host Agent with updated
tool_calling_client,planner_config,ai_planner,planner_prompts,world/models,world/model. - Rollback: revert Host Agent to prior version (no DB rollback needed — new columns simply go unused).
Open Questions
- None blocking implementation.