Skip to content

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:

  1. Increments iteration counters and checks early-stop conditions.
  2. Intercepts bootstrap questions (core.region, core.currency).
  3. Manages proactive decompositions, builds dynamic recipes, and composes ready fields.
  4. Gates on calculator status (cap reached, success, blocked).
  5. Resolves blocked-calculation acquisition tasks.
  6. Runs an auto-finish gate (all fields collected?).
  7. Calls the planner LLM for a structured PlannerDecision.
  8. Post-processes the LLM decision (redirects derived fields, redirects premature ask_user for web-preferred fields, enforces search caps, enforces ask-user caps, adjusts finish→calculate).
  9. 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 if blocks.

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 the decide node 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

  1. Extract helper functions (no graph change): Move each logical block inside plan_node into a standalone function (_tick, _prepare, _calc_gate, _acquire, _decide, _enforce_policy) that accepts State and returns updates. plan_node becomes a sequence of calls.
  2. Register as nodes (graph change): Add the extracted functions as nodes in _build_state_graph. Replace the single plan → route_after_plan edge with the pipeline shown above.
  3. Delete plan_node (cleanup): Once the new graph is stable, remove the legacy plan_node and route_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 tickask_region Structural, not LLM
Currency bootstrap plan_node tickask_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