Skip to content

Proposal: Decompose plan_node into Graph-Level Routing

Author: Sisyphus (Claude Opus 4.7) Date: 2026-06-08 Status: Draft — for review Source: docs/planner-graph-ref/current-graph.md + src/venturescope/planner/agent.py

1. Problem Analysis

The plan_node() function (lines 846–1192, ~350 lines) is a "god node" that packs 10 distinct responsibilities into a single if/else chain. This makes the control flow impossible to understand from the graph diagram alone — you must read the entire function to know what happens when.

Responsibilities currently inside plan_node:

# Responsibility Lines (approx) Nature
1 Early termination (aborted, max_iters) 852–862 Guard
2 Region pre-flight question 864–875 Pre-flight
3 Currency pre-flight question 877–887 Pre-flight
4 Dynamic decomposition generation 889–904 Preparation
5 Schema composition (ready fields) 905–916 Preparation
6 Calculator cap exceeded → abort 919–931 Guard
7 Calculator success → finish 933–943 Guard
8 Blocked calculator → acquisition task 946–988 Recovery
9 Auto-finish check (all inputs collected) 993–1035 Termination
10 LLM decision generation 1037–1068 Core
11 Post-LLM redirects (derived fields, web-preferred, calculator adjustment) 1072–1178 Routing
12 Per-field cap enforcement (search, ask_user) 1093–1173 Routing

The core insight: responsibilities 1–3, 6–9 are deterministic guards and checks that don't need the LLM at all. They should be explicit graph nodes or conditional edges. Only responsibility 10 (LLM decision) is the actual "planning" work.

2. Proposed New Graph Structure

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

    preflight -->|needs region| ask_region[ask_region]
    preflight -->|needs currency| ask_currency[ask_currency]
    preflight -->|calc cap exceeded| finish[finish]
    preflight -->|calc success| finish
    preflight -->|ok| decompose[decompose]

    ask_region --> observe_region[observe_region]
    ask_currency --> observe_currency[observe_currency]
    observe_region --> preflight
    observe_currency --> preflight

    decompose --> plan[plan]

    plan -->|decision.action = search| search[search]
    plan -->|decision.action = ask_user| ask_user[ask_user / interrupt]
    plan -->|decision.action = reflect| reflect[reflect]
    plan -->|decision.action = calculate| calculate[calculate]
    plan -->|decision.action = finish| route_finish_check[route_finish_check]

    search -->|last_observation present| observe[observe]
    search -->|no hits or backend failure| plan

    observe --> plan
    calculate --> plan
    ask_user --> observe_user[observe_user]
    observe_user --> plan
    reflect --> plan

    route_finish_check -->|caps ok, all done| planner_end([END])
    route_finish_check -->|cap exceeded| finish

    finish --> planner_end

Key structural changes:

  1. preflight node — replaces lines 852–943 (guards + region/currency + calculator checks). Returns early to a dedicated ask node or passes through to decompose.
  2. ask_region / ask_currency — dedicated interrupt nodes for pre-flight questions, with observe_region / observe_currency as their parse-and-merge counterparts.
  3. decompose node — extracts dynamic decomposition generation (lines 889–904) into its own node so plan receives ready recipes.
  4. route_finish_check node — replaces the finish edge's blind pass-through. Validates that finish is actually appropriate (cap checks, missing field scan) before allowing END.
  5. plan node shrinks to ~80 lines — only LLM decision generation + minimal state preparation.

3. Node Responsibilities (Before vs After)

plan_node — Before (~350 lines)

Does everything listed in the table above.

After — Split across 6 nodes:

preflight_node (~60 lines)

def preflight_node(state: State) -> dict[str, Any]:
    # 1. Check aborted status → set status
    # 2. Check max_iters → set status
    # 3. Check needs_region → set decision=ask_region
    # 4. Check needs_currency → set decision=ask_currency
    # 5. Check calculator cap exceeded → set decision=finish
    # 6. Check calculator success → set decision=finish
    # 7. Otherwise → pass through (no decision set)
Returns: Either a decision (for early-exit paths) or nothing (pass-through to decompose).

ask_region_node / ask_currency_node (~30 lines each)

Extracted from the current inline decision construction in plan_node. Calls interrupt() with the region/currency question.

observe_region_node / observe_currency_node (~40 lines each)

Extracted from _handle_region_answer / _handle_currency_answer. Parses and merges the answer.

decompose_node (~40 lines)

def decompose_node(state: State) -> dict[str, Any]:
    # 1. Build dynamic_decompositions from static_field_acquisition
    # 2. Compose ready_fields into schema_dict
    # 3. Return updated dynamic_decompositions and schema
Pure preparation — no decisions, no LLM calls (except lazy decomposition generation which can stay as a sub-step).

plan_node — After (~80 lines)

def plan_node(state: State) -> dict[str, Any]:
    # 1. Build recipes from dynamic_decompositions + static_field_acquisition
    # 2. Build planner prompt
    # 3. Call LLM for PlannerDecision
    # 4. On LLM failure → return decision=finish
    # 5. Return decision + iterations
Removed from plan_node: all guards, all pre-flights, all calculator checks, all cap enforcement, all redirects.

route_after_plan — After (conditional edge function)

def route_after_plan(state: State) -> str:
    decision = state["decision"]
    action = decision.action

    # Post-LLM redirects that were inside plan_node:
    # 1. If action=search and field is derived without decomposition → reflect
    # 2. If action=ask_user and field is web-preferred with no prior search → search
    # 3. If action=finish and calculator not run → calculate
    # 4. If action=finish and calculator blocked → reflect
    # 5. If action=search and cap reached → ask_user
    # 6. If action=ask_user and cap reached → finish

    return resolved_action
This is the biggest win: the redirect logic becomes a pure routing function that the graph diagram can express. Currently these are hidden inside plan_node as function calls.

route_finish_check (~30 lines)

def route_finish_check(state: State) -> str:
    # 1. If status=aborted → "end"
    # 2. If iterations >= max_iters and missing fields → "end" (aborted)
    # 3. If actionable_missing or open_tasks → "plan" (not actually done)
    # 4. Otherwise → "end"
Replaces the auto-finish logic currently buried in plan_node lines 993–1035.

4. Routing Function Signatures

Current routing functions (2):

def route_after_plan(state: State) -> str       # line 2070 — returns decision.action
def route_after_search(state: State) -> str     # line 2076 — returns "observe" or "plan"

Proposed routing functions (4):

def route_after_preflight(state: State) -> str:
    """Returns: 'ask_region' | 'ask_currency' | 'finish' | 'decompose'"""

def route_after_plan(state: State) -> str:
    """Returns: 'search' | 'ask_user' | 'reflect' | 'calculate' | 'route_finish_check'

    Applies post-LLM redirects:
    - derived field without decomposition → 'reflect'
    - web-preferred field, no prior search → 'search'
    - finish + calculator not run → 'calculate'
    - finish + calculator blocked → 'reflect'
    - search cap reached → 'ask_user'
    - ask_user cap reached → 'finish'
    """

def route_after_search(state: State) -> str:
    """Unchanged: returns 'observe' | 'plan'"""

def route_after_finish_check(state: State) -> str:
    """Returns: 'end' | 'plan'"""

5. Graph Construction Changes

Current _build_state_graph():

builder.add_edge(START, "plan")
builder.add_conditional_edges("plan", route_after_plan, {...})
builder.add_conditional_edges("search", route_after_search, {...})
builder.add_edge("observe", "plan")
builder.add_edge("calculate", "plan")
builder.add_edge("ask_user", "observe_user")
builder.add_edge("observe_user", "plan")
builder.add_edge("reflect", "plan")
builder.add_edge("finish", END)

Proposed _build_state_graph():

# Pre-flight phase
builder.add_edge(START, "preflight")
builder.add_conditional_edges("preflight", route_after_preflight, {
    "ask_region": "ask_region",
    "ask_currency": "ask_currency",
    "finish": "finish",
    "decompose": "decompose",
})
builder.add_edge("ask_region", "observe_region")
builder.add_edge("ask_currency", "observe_currency")
builder.add_edge("observe_region", "preflight")
builder.add_edge("observe_currency", "preflight")

# Decomposition phase
builder.add_edge("decompose", "plan")

# Planning phase (plan node is now thin — just LLM call)
builder.add_conditional_edges("plan", route_after_plan, {
    "search": "search",
    "ask_user": "ask_user",
    "reflect": "reflect",
    "calculate": "calculate",
    "route_finish_check": "route_finish_check",
})

# Search sub-loop (unchanged)
builder.add_conditional_edges("search", route_after_search, {
    "observe": "observe",
    "plan": "plan",
})
builder.add_edge("observe", "plan")

# Calculator loop (unchanged)
builder.add_edge("calculate", "plan")

# User question loop (unchanged)
builder.add_edge("ask_user", "observe_user")
builder.add_edge("observe_user", "plan")

# Reflection (unchanged)
builder.add_edge("reflect", "plan")

# Finish validation
builder.add_conditional_edges("route_finish_check", route_after_finish_check, {
    "end": "finish",
    "plan": "plan",
})
builder.add_edge("finish", END)

6. What Changes vs What Stays

Changes:

Item Change
plan_node Shrinks from ~350 → ~80 lines; only LLM decision generation
_build_state_graph() Adds 5 new nodes, 2 new conditional edges
route_after_plan() Gains redirect logic currently inside plan_node
New nodes preflight_node, decompose_node, ask_region_node, ask_currency_node, observe_region_node, observe_currency_node, route_finish_check_node
New routing functions route_after_preflight, route_after_finish_check

Stays the same:

Item Reason
State schema No new fields needed; existing fields cover all routing conditions
PlannerDecision Still the decision record; only produced by the thin plan_node
run_planner_step() External API unchanged; still invokes/resumes the compiled graph
planner_thread_id() Namespacing unchanged
build_planner_graph() Signature unchanged; returns compiled graph
search_node, observe_node, calculate_node, reflect_node, finish_node Unchanged implementations
observe_user_node Unchanged (region/currency extraction moves to dedicated observe nodes)
All helper functions _needs_region_question, _handle_region_answer, etc. stay as-is, just called from different nodes
route_after_search Unchanged

7. Migration Approach

Phase 1: Extract pre-flight nodes (lowest risk)

  1. Create preflight_node — move lines 852–887 (aborted, max_iters, region, currency checks)
  2. Create ask_region_node / ask_currency_node from the inline decision construction
  3. Create observe_region_node / observe_currency_node from _handle_region_answer / _handle_currency_answer
  4. Add route_after_preflight routing function
  5. Update graph construction
  6. Tests: existing planner tests should pass unchanged since the external API is the same

Phase 2: Extract decomposition node

  1. Create decompose_node — move lines 889–916
  2. Wire between preflight and plan
  3. Tests: verify decomposition still happens before LLM planning

Phase 3: Extract calculator guards

  1. Move calculator cap/success checks (lines 919–943) into preflight_node
  2. Add route_finish_check_node for the auto-finish logic (lines 993–1035)
  3. Update route_after_plan to route to route_finish_check instead of finish
  4. Tests: verify calculator-driven finish behavior

Phase 4: Move redirects to routing function

  1. Move _redirect_derived_direct_decision, _redirect_premature_ask_for_web_field, _adjust_calculation_decision calls from plan_node into route_after_plan
  2. Move per-field cap enforcement into route_after_plan
  3. Shrink plan_node to LLM-only
  4. Tests: verify all redirect behaviors still work

Phase 5: Clean up

  1. Remove dead code paths from plan_node
  2. Update docs/planner-graph-ref/current-graph.md with new mermaid diagram
  3. Run full test suite + lint

8. Benefits

Benefit Impact
Graph readability The mermaid diagram becomes the source of truth for control flow
Testability Each node can be unit-tested in isolation with minimal state setup
Maintainability Adding a new pre-flight check = add one condition to preflight_node, not dig through 350 lines
Debuggability LangGraph's step-by-step visualization shows exactly which node was entered
Reduced cognitive load plan_node goes from "what does this function do?" (10 things) to "it calls the LLM" (1 thing)

9. Risks and Mitigations

Risk Mitigation
LangGraph conditional edges can't express complex logic The routing functions are pure Python — any complexity is fine. The graph just needs string return values.
State schema needs new fields Audit shows all routing conditions use existing State fields (status, iterations, attempts, schema, idea, last_calculation_status, calculation_attempts, dynamic_decompositions).
Tests break due to node name changes Tests mock the graph or test through run_planner_step. Node-internal tests would need updates but are few.
Interrupt behavior changes for region/currency ask_region/ask_currency use the same interrupt() payload structure. Resume path is identical.

10. Open Questions

  1. Should decompose be a node or stay inside plan? Decomposition is preparation, not decision-making. Making it a node makes the graph explicit about "prepare recipes → then plan". However, if decomposition is always exactly one step before planning, keeping it as the first action inside plan_node is acceptable. The proposal includes it as a node for maximum explicitness.

  2. Should route_finish_check be a node or an edge? It needs to inspect state and potentially route back to plan. A node is cleaner because it can log "finish check: N fields still missing → returning to plan". An edge function would work but has no logging opportunity.

  3. Do we need observe_region / observe_currency as separate nodes? Currently _handle_region_answer / _handle_currency_answer are called from observe_user_node via target field check. Separating them makes the pre-flight loop explicit and removes the special-case branching from observe_user_node.