Skip to content

Proposal: Refactoring Planner Logic to the Graph Level

1. Problem Statement: The Monolithic plan Node

Currently, the planner subgraph delegates almost all of its orchestration to a single, monolithic node: plan_node (located in src/venturescope/planner/agent.py).

As documented in current-graph.md, the graph topology is: START -> plan -> [search | ask_user | reflect | calculate | finish]

However, this hides the actual control flow. Inside plan_node, there is a ~150-line procedural waterfall of deterministic rules and state mutations that execute before the LLM is ever called.

Specifically, plan_node currently performs: 1. State Mutation: Updates iteration counts. 2. Deterministic Early Exits: Checks for max_iters or aborted status. 3. Hardcoded Overrides: Checks _needs_region_question and _needs_currency_question to force an ask_user action. 4. Data Preparation (Schema Mutation): Proactively generates dynamic decompositions (_proactive_decompositions) and builds the schema dictionary (compose_ready_fields). 5. Calculator Enforcement: Checks max_calculation_attempts and whether the calculator succeeded (_successful_calculation_current). 6. Task Extraction: Pulls the next task via next_acquisition_task if calculations are blocked. 7. Auto-Finish Logic: Sweeps schema leaves to determine if all raw inputs are collected (auto_finish_ok). 8. LLM Invocation: Only if none of the above rules trigger, it builds the prompt and invokes the Gemini LLM.

Why this is an anti-pattern in LangGraph: - Hidden Control Flow: LangGraph/LangSmith visualizers cannot see the deterministic loops (e.g., region missing → ask user). It all looks like the plan node making opaque decisions. - Mixed Concerns: State mutation (composing the schema) is tightly coupled with routing logic (checking limits) and heuristic inference (calling the LLM). - Hard to Test: Testing the LLM prompt behavior requires setting up the state perfectly to bypass the 7 deterministic rules above it.


2. Proposed Architecture: Lifting Logic to the Graph

To fix this, we should decompose the procedural waterfall into discrete graph nodes and use LangGraph's conditional edges (add_conditional_edges) to handle the routing.

Proposed Nodes

Instead of one plan node, we split it into three focused nodes:

  1. prepare_state (State Mutation Node)
  2. Role: Pure state transformation.
  3. Logic: Increments iterations. Calls _proactive_decompositions and compose_ready_fields. Updates the schema and dynamic_decompositions in the state.
  4. Output: Returns the updated schema and iteration count. No routing decisions.

  5. evaluate_rules (Deterministic Routing Node)

  6. Role: Evaluates business rules that do not require an LLM.
  7. Logic:
    • Checks max_iters / aborted.
    • Checks region/currency requirements.
    • Checks calculator status/caps.
    • Checks next_acquisition_task for blocked states.
    • Checks auto_finish_ok.
  8. Output: If a rule triggers, it returns a PlannerDecision (e.g., action="finish", action="ask_user", or action="calculate"). If no rules trigger, it returns None (or a needs_llm flag).

  9. llm_plan (Heuristic Routing Node)

  10. Role: Calls Gemini when rules are exhausted.
  11. Logic: Calls planner_prompt and the LLM structured output.
  12. Output: Returns a PlannerDecision based purely on LLM reasoning (search, ask_user, reflect, etc.).

Proposed Graph Topology

flowchart TD
    planner_start([START]) --> prepare_state[prepare_state]

    %% 1. State preparation happens first
    prepare_state --> evaluate_rules[evaluate_rules]

    %% 2. Evaluate deterministic rules
    evaluate_rules --> route_after_rules{route_after_rules}

    %% 3. Route based on deterministic rules
    route_after_rules -->|decision=ask_user| ask_user[ask_user]
    route_after_rules -->|decision=calculate| calculate[calculate]
    route_after_rules -->|decision=search| search[search]
    route_after_rules -->|decision=finish| finish[finish]
    route_after_rules -->|needs_llm| llm_plan[llm_plan]

    %% 4. Route based on LLM output
    llm_plan --> route_after_llm{route_after_llm}
    route_after_llm -->|search| search
    route_after_llm -->|ask_user| ask_user
    route_after_llm -->|reflect| reflect[reflect]
    route_after_llm -->|finish| finish

    %% 5. Action nodes return to the start of the loop
    search --> route_after_search{route_after_search}
    route_after_search -->|found| observe[observe]
    route_after_search -->|failed| prepare_state

    observe --> prepare_state
    calculate --> prepare_state
    ask_user --> observe_user[observe_user]
    observe_user --> prepare_state
    reflect --> prepare_state
    finish --> planner_end([END])

3. Implementation Steps

  1. Refactor agent.py functions:
  2. Extract the top half of plan_node (up to compose_ready_fields) into prepare_state_node(state: State) -> dict[str, Any].
  3. Extract the middle half (the if statements returning decisions) into evaluate_rules_node(state: State) -> dict[str, Any].
  4. Keep the bottom half (the _llm().structured(...) call) in llm_plan_node(state: State) -> dict[str, Any].

  5. Update Edge Routers:

  6. Create def route_after_rules(state: State) -> str: which checks if state["decision"] is populated. If it is, return decision.action. Otherwise, return "llm_plan".
  7. Rename the existing route_after_plan to route_after_llm since it just blindly returns state["decision"].action.

  8. Rebuild the Graph (_build_state_graph):

  9. Replace builder.add_node("plan", plan_node) with the three new nodes.
  10. Adjust the loop edges. Nodes like observe, calculate, observe_user, and reflect should now point back to prepare_state instead of plan.

4. Summary of Benefits

  • True Graph Visibility: Standard business logic (like enforcing region/currency or checking calculator blocks) becomes structurally visible to visualization tools (LangSmith/Mermaid).
  • Reduced Latency/Cost Risk: The LLM node becomes completely isolated. There is zero risk of unintentionally invoking the LLM when a deterministic rule should have fired.
  • Cleaner Unit Tests: You can now test schema composition (prepare_state) and business rules (evaluate_rules) natively as pure python functions without mocking out _llm().