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:¶
preflightnode — replaces lines 852–943 (guards + region/currency + calculator checks). Returns early to a dedicated ask node or passes through todecompose.ask_region/ask_currency— dedicated interrupt nodes for pre-flight questions, withobserve_region/observe_currencyas their parse-and-merge counterparts.decomposenode — extracts dynamic decomposition generation (lines 889–904) into its own node soplanreceives ready recipes.route_finish_checknode — replaces thefinishedge's blind pass-through. Validates that finish is actually appropriate (cap checks, missing field scan) before allowing END.plannode 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)
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
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
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
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"
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)¶
- Create
preflight_node— move lines 852–887 (aborted, max_iters, region, currency checks) - Create
ask_region_node/ask_currency_nodefrom the inline decision construction - Create
observe_region_node/observe_currency_nodefrom_handle_region_answer/_handle_currency_answer - Add
route_after_preflightrouting function - Update graph construction
- Tests: existing planner tests should pass unchanged since the external API is the same
Phase 2: Extract decomposition node¶
- Create
decompose_node— move lines 889–916 - Wire between
preflightandplan - Tests: verify decomposition still happens before LLM planning
Phase 3: Extract calculator guards¶
- Move calculator cap/success checks (lines 919–943) into
preflight_node - Add
route_finish_check_nodefor the auto-finish logic (lines 993–1035) - Update
route_after_planto route toroute_finish_checkinstead offinish - Tests: verify calculator-driven finish behavior
Phase 4: Move redirects to routing function¶
- Move
_redirect_derived_direct_decision,_redirect_premature_ask_for_web_field,_adjust_calculation_decisioncalls fromplan_nodeintoroute_after_plan - Move per-field cap enforcement into
route_after_plan - Shrink
plan_nodeto LLM-only - Tests: verify all redirect behaviors still work
Phase 5: Clean up¶
- Remove dead code paths from
plan_node - Update
docs/planner-graph-ref/current-graph.mdwith new mermaid diagram - 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¶
-
Should
decomposebe a node or stay insideplan? 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 insideplan_nodeis acceptable. The proposal includes it as a node for maximum explicitness. -
Should
route_finish_checkbe a node or an edge? It needs to inspect state and potentially route back toplan. 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. -
Do we need
observe_region/observe_currencyas separate nodes? Currently_handle_region_answer/_handle_currency_answerare called fromobserve_user_nodevia target field check. Separating them makes the pre-flight loop explicit and removes the special-case branching fromobserve_user_node.