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:
prepare_state(State Mutation Node)- Role: Pure state transformation.
- Logic: Increments
iterations. Calls_proactive_decompositionsandcompose_ready_fields. Updates theschemaanddynamic_decompositionsin the state. -
Output: Returns the updated schema and iteration count. No routing decisions.
-
evaluate_rules(Deterministic Routing Node) - Role: Evaluates business rules that do not require an LLM.
- Logic:
- Checks
max_iters/aborted. - Checks region/currency requirements.
- Checks calculator status/caps.
- Checks
next_acquisition_taskfor blocked states. - Checks
auto_finish_ok.
- Checks
-
Output: If a rule triggers, it returns a
PlannerDecision(e.g.,action="finish",action="ask_user", oraction="calculate"). If no rules trigger, it returnsNone(or aneeds_llmflag). -
llm_plan(Heuristic Routing Node) - Role: Calls Gemini when rules are exhausted.
- Logic: Calls
planner_promptand the LLM structured output. - Output: Returns a
PlannerDecisionbased 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¶
- Refactor
agent.pyfunctions: - Extract the top half of
plan_node(up tocompose_ready_fields) intoprepare_state_node(state: State) -> dict[str, Any]. - Extract the middle half (the
ifstatements returning decisions) intoevaluate_rules_node(state: State) -> dict[str, Any]. -
Keep the bottom half (the
_llm().structured(...)call) inllm_plan_node(state: State) -> dict[str, Any]. -
Update Edge Routers:
- Create
def route_after_rules(state: State) -> str:which checks ifstate["decision"]is populated. If it is, returndecision.action. Otherwise, return"llm_plan". -
Rename the existing
route_after_plantoroute_after_llmsince it just blindly returnsstate["decision"].action. -
Rebuild the Graph (
_build_state_graph): - Replace
builder.add_node("plan", plan_node)with the three new nodes. - Adjust the loop edges. Nodes like
observe,calculate,observe_user, andreflectshould now point back toprepare_stateinstead ofplan.
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().