Proposal: Decompose plan_node into Graph-Level Pipeline¶
Date: 2026-06-07
Context: docs/planner-graph-ref/current-graph.md, src/venturescope/planner/agent.py:846-1193
Author: kimi-k2.6 analysis
Problem Statement¶
The current plan node in the planner subgraph is a god node (~350 lines, lines 846-1193 in agent.py). It:
- Increments iteration counters and checks early-stop conditions.
- Intercepts bootstrap questions (
core.region,core.currency). - Manages proactive decompositions, builds dynamic recipes, and composes ready fields.
- Gates on calculator status (cap reached, success, blocked).
- Resolves blocked-calculation acquisition tasks.
- Runs an auto-finish gate (all fields collected?).
- Calls the planner LLM for a structured
PlannerDecision. - Post-processes the LLM decision (redirects derived fields, redirects premature
ask_userfor web-preferred fields, enforces search caps, enforces ask-user caps, adjusts finish→calculate). - Logs and emits UI events.
Because all of this happens inside a single node, the graph topology is almost irrelevant—plan_node is a black box that emits a decision.action string, and the graph merely dispatches to the target node. This makes the planner hard to test, hard to trace, and hard to extend.
Guiding Principle¶
Each node should do ONE thing. Routing should happen at the graph level via conditional edges, not inside a node via nested
ifblocks.
LangGraph conditional edges exist precisely to solve this. Moving logic to the graph level means: - Nodes become small, pure, and testable. - The Mermaid diagram becomes an honest representation of control flow. - We can attach per-node logging, retries, and telemetry without cluttering a monolithic function.
Proposed New Graph Topology¶
flowchart TD
START([START]) --> tick[tick]
tick -->|aborted or max_iters| finish[finish]
tick -->|needs region| ask_region[ask_user_region]
tick -->|needs currency| ask_currency[ask_user_currency]
tick -->|ready| prepare[prepare]
prepare --> calc_gate[calc_gate]
calc_gate -->|calc success| finish
calc_gate -->|calc cap reached| finish
calc_gate -->|continue| acquire[acquire]
acquire -->|auto_finish| calc_adjust[calc_adjust]
acquire -->|blocked_task| route_direct[route_direct]
acquire -->|needs LLM| decide[decide]
decide --> enforce[enforce_policy]
enforce -->|search| search[search]
enforce -->|ask_user| ask_user[ask_user]
enforce -->|reflect| reflect[reflect]
enforce -->|calculate| calculate[calculate]
enforce -->|finish| finish
search -->|has observation| observe[observe]
search -->|no hits| tick
observe --> tick
calculate --> tick
ask_user --> observe_user[observe_user]
observe_user --> tick
reflect --> tick
ask_region --> observe_user
ask_currency --> observe_user
finish --> END([END])
Node Responsibilities (after decomposition)¶
1. tick (replaces lines 848-863)¶
Does:
- Increment iterations.
- If status == "aborted" → route to finish.
- If iterations > max_iters → route to finish.
Returns: updated iterations, new status if capped.
Routing: aborted | max_iters | needs_region | needs_currency | ready
2. ask_region / ask_currency (extracted from lines 864-887)¶
Does:
- Build the bootstrap ask_user interrupt payload for region or currency.
- These are dedicated nodes, not generic ask_user, because they bypass the normal acquisition pipeline.
Routing: always → observe_user (which already handles region/currency answers via _handle_region_answer / _handle_currency_answer).
Why separate nodes? Bootstrap questions are structural, not planner-LLM decisions. The graph should show them explicitly.
3. prepare (extracted from lines 889-916)¶
Does:
- _proactive_decompositions(...)
- build_dynamic_recipes(...)
- compose_ready_fields(...)
- Store schema_changed flag if composition mutated the schema.
Returns: dynamic_decompositions, schema (if changed).
Routing: unconditional → calc_gate
Why a dedicated node? Recipe building and field composition are expensive, deterministic, and independent of LLM calls. Isolating them makes unit testing trivial and allows caching.
4. calc_gate (extracted from lines 919-943)¶
Does:
- If calculator-backed profile AND calc cap reached AND last status blocked/ERROR → route to finish (aborted).
- If calculator-backed profile AND _successful_calculation_current(state) → route to finish (done).
Routing: finish_success | finish_abort | continue
Why a gate node? Calculator lifecycle checks are pure predicates. A conditional edge makes the "finish on success" path visible in the diagram.
5. acquire (extracted from lines 945-1035)¶
Does:
- If last_calculation_status == BLOCKED → call next_acquisition_task(...). If a task is found, route directly to route_direct (bypass LLM).
- If blocked but no static recipe → try dynamic decomposition for the blocked path, then re-check.
- If auto_finish_ok AND no actionable_missing AND no open_tasks → route to calc_adjust.
- Otherwise → route to decide.
Routing: blocked_task | auto_finish | decide
Why not inside
decide? Acquisition-task resolution is deterministic rule-based logic. It does not need an LLM. Separating it means thedecidenode only calls the planner LLM when no deterministic rule fired.
6. route_direct (extracted from lines 980-988)¶
Does:
- Convert an AcquisitionTask into a PlannerDecision.
- Apply _adjust_calculation_decision.
Returns: decision.
Routing: unconditional → enforce_policy
This is a tiny adapter node. It bridges deterministic acquisition tasks into the same post-processing pipeline as LLM decisions.
7. decide (extracted from lines 1037-1068)¶
Does:
- Build the planner prompt (planner_prompt(...)).
- Call _llm().structured(..., schema=PlannerDecision).
- On LLM failure → emit a finish decision (do not raise).
Returns: decision.
Routing: unconditional → enforce_policy
This node is now ~30 lines. It only builds a prompt and calls the LLM. All pre-conditions (bootstrap, calculator gates, auto-finish) have already been checked upstream.
8. enforce_policy (extracted from lines 1070-1192)¶
Does:
- Post-LLM decomposition generation for requires-components fields (lines 1072-1088).
- _redirect_derived_direct_decision (line 1090).
- _redirect_premature_ask_for_web_field (line 1091).
- Search cap enforcement: if search cap reached → rewrite to ask_user or reflect (lines 1093-1137).
- Ask-user cap enforcement: if ask cap reached → rewrite to finish (lines 1139-1173).
- _adjust_calculation_decision (lines 1177-1178).
- Log and emit the final decision.
Returns: decision, possibly updated schema, dynamic_decompositions.
Routing: search | ask_user | reflect | calculate | finish
This is still the most complex node, but it has a single responsibility: "Take a proposed decision and enforce hard policy rules on it." All upstream nodes feed into it, and it produces the final routable action.
What Stays in plan_node (legacy compat)¶
If the team prefers a phased migration, plan_node can become a thin wrapper that simply chains the new nodes internally during a transition period:
def plan_node(state: State) -> dict[str, Any]:
# Phase-out wrapper: delegates to new pipeline
out = tick_node(state)
if out.get("_route") in {"finish", "ask_region", "ask_currency"}:
return out
out = prepare_node({**state, **out})
# ... etc
However, the end state should be the decomposed graph topology above.
Benefits¶
| Concern | Before | After |
|---|---|---|
| Testability | 350-line function, many branches | Each node is 20-80 lines, single exit point |
| Observability | One log stream for "plan" | Per-node logging, timing, and retry hooks |
| Diagram honesty | Mermaid shows 1 node hiding 10 decisions | Mermaid shows the real control flow |
| Extensibility | Adding a new gate = more if blocks |
Adding a new gate = new node + conditional edge |
| LLM isolation | LLM call buried inside plan_node |
decide node is the only LLM caller; can be swapped or mocked easily |
Migration Path¶
- Extract helper functions (no graph change): Move each logical block inside
plan_nodeinto a standalone function (_tick,_prepare,_calc_gate,_acquire,_decide,_enforce_policy) that acceptsStateand returns updates.plan_nodebecomes a sequence of calls. - Register as nodes (graph change): Add the extracted functions as nodes in
_build_state_graph. Replace the singleplan → route_after_planedge with the pipeline shown above. - Delete
plan_node(cleanup): Once the new graph is stable, remove the legacyplan_nodeandroute_after_plan.
Appendix: Decision Matrix (What Goes Where)¶
| Logic Block | Current Location | Proposed Location | Reason |
|---|---|---|---|
| Iteration increment | plan_node top |
tick |
Bookkeeping |
| Abort / max_iters check | plan_node top |
tick conditional edge |
Early exit gate |
| Region bootstrap | plan_node |
tick → ask_region |
Structural, not LLM |
| Currency bootstrap | plan_node |
tick → ask_currency |
Structural, not LLM |
| Proactive decompositions | plan_node |
prepare |
Deterministic setup |
| Recipe building | plan_node |
prepare |
Deterministic setup |
| Field composition | plan_node |
prepare |
Deterministic setup |
| Calc cap reached | plan_node |
calc_gate conditional edge |
Calculator lifecycle |
| Calc success | plan_node |
calc_gate conditional edge |
Calculator lifecycle |
| Blocked-calc acquisition | plan_node |
acquire |
Rule-based, no LLM |
| Auto-finish check | plan_node |
acquire |
Rule-based, no LLM |
| Open-task resolution | plan_node |
acquire |
Rule-based, no LLM |
| Planner LLM call | plan_node |
decide |
The actual LLM decision |
| Derived redirect | plan_node |
enforce_policy |
Policy enforcement |
| Web-pref redirect | plan_node |
enforce_policy |
Policy enforcement |
| Search cap | plan_node |
enforce_policy |
Policy enforcement |
| Ask-user cap | plan_node |
enforce_policy |
Policy enforcement |
| Finish→calc redirect | plan_node |
enforce_policy |
Policy enforcement |
| Event emission | plan_node bottom |
enforce_policy |
Emit final routed action |