Proposal: Hoist plan_node Policy Into Graph Edges¶
Author: Sisyphus (opus-4.7)
Scope: src/venturescope/planner/agent.py — plan_node decomposition
Status: Draft proposal, not implemented
1. Problem¶
plan_node (agent.py:846-1192) is ~350 lines and carries the entire planner policy:
| # | Concern | Lines | Kind |
|---|---|---|---|
| 1 | Iteration increment + abort short-circuit | 848–862 | guard |
| 2 | Region question (must be first) | 864–875 | deterministic ask |
| 3 | Currency question (must be second) | 877–887 | deterministic ask |
| 4 | Proactive decomposition + recipe rebuild + schema composition | 889–917 | enrichment |
| 5 | Calculator cap → abort | 919–931 | guard |
| 6 | Calculator success → finish | 933–943 | guard |
| 7 | Blocked-calc acquisition task selection + dynamic decomposition fallback | 945–988 | deterministic acquire |
| 8 | Generic acquisition task selection | 1006–1024 | deterministic acquire |
| 9 | Auto-finish (no actionable missing, no open tasks) | 993–1035 | guard |
| 10 | LLM planner call | 1037–1068 | actual planning |
| 11 | Post-LLM: generate decomposition for composite target | 1072–1088 | corrector |
| 12 | Post-LLM: redirect derived-direct | 1090 | corrector |
| 13 | Post-LLM: redirect premature ask for web field | 1091 | corrector |
| 14 | Per-field search cap → ask_user / reflect | 1093–1137 | corrector + guard |
| 15 | Per-field ask_user cap → finish | 1139–1173 | corrector + guard |
| 16 | Calculation decision adjustment (rewrites finish to calculate) |
1178 | corrector |
| 17 | Decision logging + event emit | 1179–1186 | side-effect |
The node is a hand-rolled policy engine. The graph diagram lies: it shows five outgoing edges from plan, but the real routing logic lives inside the function, hidden from the Mermaid diagram, hidden from route_after_plan (which just reads decision.action).
Consequences¶
- Diagram has no explanatory power. Reading
current-graph.mddoes not tell you that region is asked before currency, or that calculator-success short-circuits the LLM call. - Each concern is hard to test in isolation. A unit test for "calculator cap forces abort" must construct the full
Stateand walk through every preceding gate. - Cross-concern bugs are easy to introduce. E.g. correctors at lines 11–16 each independently mutate
decision; ordering changes are silent. schema_changed,dynamic_decomps,llm_failedbookkeeping is duplicated across every early-return.- Adding a new gate (e.g. "ask for industry before region") requires editing a megafunction rather than declaring a node.
2. Design Principle¶
If a piece of
plan_nodeis a deterministic policy that decides "do X now and skip LLM", it belongs on the graph as a node + edge — not insideplan_nodeas anifblock.
plan_node should shrink to one job: call the LLM and emit a PlannerDecision. Everything else becomes graph topology.
LangGraph already supports this cleanly via:
- Multiple
add_nodedeclarations with single-responsibility node functions. add_conditional_edgeswith predicate functions that read state and return the next node name.- Shared "dispatch" patterns where a corrector chain converges to a single decision-executor router.
3. Proposed Graph¶
flowchart TD
START([START]) --> g_iter[g_iter_cap<br/>increment + max_iters]
g_iter -->|aborted/cap| dispatch
g_iter -->|ok| g_region
g_region[g_region<br/>core.region present?]
g_region -->|missing| emit_region[emit ask_user region] --> dispatch
g_region -->|present| g_currency
g_currency[g_currency<br/>core.currency present?]
g_currency -->|missing| emit_currency[emit ask_user currency] --> dispatch
g_currency -->|present| enrich
enrich[enrich_schema<br/>proactive decomp + compose ready fields]
enrich --> g_calc_caps
g_calc_caps[g_calc_caps<br/>calc cap? calc success?]
g_calc_caps -->|cap reached| emit_calc_abort[emit finish/aborted] --> dispatch
g_calc_caps -->|success| emit_calc_done[emit finish] --> dispatch
g_calc_caps -->|continue| acquire
acquire[acquire_task<br/>blocked-calc + generic acquisition]
acquire -->|task found| emit_acq[emit decision from task] --> correct_calc_adjust
acquire -->|no task, auto-finish ok| emit_auto_finish[emit finish] --> correct_calc_adjust
acquire -->|no task, work remains| plan_llm
plan_llm[plan_llm<br/>LLM structured PlannerDecision]
plan_llm --> correct_target_decompose
correct_target_decompose[c_target_decompose<br/>generate decomp if composite target]
correct_target_decompose --> correct_redirect_derived
correct_redirect_derived[c_redirect_derived<br/>direct→component routing]
correct_redirect_derived --> correct_redirect_web
correct_redirect_web[c_redirect_web<br/>premature ask→search for web fields]
correct_redirect_web --> correct_search_cap
correct_search_cap[c_search_cap<br/>per-field search cap → ask_user / reflect]
correct_search_cap --> correct_ask_cap
correct_ask_cap[c_ask_cap<br/>per-field ask_user cap → finish]
correct_ask_cap --> correct_calc_adjust
correct_calc_adjust[c_calc_adjust<br/>rewrite finish→calculate if blocked]
correct_calc_adjust --> dispatch
dispatch{dispatch<br/>route by decision.action}
dispatch -->|search| search
dispatch -->|ask_user| ask_user
dispatch -->|calculate| calculate
dispatch -->|reflect| reflect
dispatch -->|finish| finish
search -->|hits| observe --> START
search -->|no hits| START
ask_user --> observe_user --> START
calculate --> START
reflect --> START
finish --> END([END])
Naming¶
g_*= gate. Deterministic; may emit a final decision and short-circuit towarddispatch.enrich= pure state enrichment (no decision).acquire= deterministic acquisition lookup.plan_llm= the actual LLM call (single responsibility).c_*= corrector. Always sequential. Mutates the existingdecisionrather than emits a fresh one.dispatch= decision-action router (replaces today'sroute_after_plan).
One invariant ties everything together¶
Every gate/corrector path eventually deposits a PlannerDecision into state["decision"]. dispatch then reads decision.action exactly like route_after_plan does today. That keeps the executor side of the graph (search, ask_user, calculate, reflect, finish) unchanged.
4. Node-by-Node Mapping¶
Old (in plan_node) |
New node | Notes |
|---|---|---|
| Lines 848–862 (iter increment, abort, max_iters) | g_iter_cap |
Always runs first; only place that writes iterations. |
Lines 864–875 (_needs_region_question) |
g_region + emit_region |
Splits "predicate" from "emit decision" so the predicate is reusable in tests. |
Lines 877–887 (_needs_currency_question) |
g_currency + emit_currency |
Same shape as region. |
| Lines 889–917 (proactive decomp + composition) | enrich_schema |
Pure: writes schema, dynamic_decompositions. No decision. |
| Lines 919–931 (calc cap) | g_calc_caps branch → emit_calc_abort |
Status becomes aborted. |
| Lines 933–943 (calc success) | g_calc_caps branch → emit_calc_done |
Status stays running. |
| Lines 945–988 (blocked-calc acquire + dyn decomp) | acquire (sub-step) |
Internal to acquire node; or further split into acquire_blocked → acquire_generic. |
| Lines 993–1035 (auto-finish + generic acquire) | acquire (sub-step) → emit_acq / emit_auto_finish |
The auto_finish_ok predicate becomes an internal helper. |
| Lines 1037–1068 (LLM call) | plan_llm |
Sole responsibility: LLM call + llm_failed flag in state. |
| Lines 1072–1088 (post-LLM decomp) | c_target_decompose |
|
Line 1090 (_redirect_derived_direct_decision) |
c_redirect_derived |
|
Line 1091 (_redirect_premature_ask_for_web_field) |
c_redirect_web |
|
| Lines 1093–1137 (search cap) | c_search_cap |
|
| Lines 1139–1173 (ask_user cap) | c_ask_cap |
|
Line 1178 (_adjust_calculation_decision) |
c_calc_adjust |
Skipped when llm_failed, exactly as today. |
| Lines 1179–1186 (logging + event) | dispatch (entry side-effect) |
One canonical place to log the final decision. |
5. State Surface Changes¶
Minimal additions to State:
llm_failed: bool(transient flag; written byplan_llm, read byc_calc_adjust, cleared on next iteration entry).
Everything else is already in state. The bookkeeping (schema_changed, dirty dynamic_decompositions) that today's plan_node duplicates across early returns becomes implicit — each node writes only the fields it owns:
| Node | Writes |
|---|---|
g_iter_cap |
iterations, possibly status, decision |
enrich_schema |
schema, dynamic_decompositions |
g_calc_caps |
decision, possibly status |
acquire |
decision, possibly dynamic_decompositions, possibly status |
plan_llm |
decision, llm_failed |
c_* |
decision only |
dispatch |
(read-only; logs + routes) |
This eliminates the 8 separate if schema_changed: out["schema"] = schema_dict blocks scattered through plan_node.
6. Routing Rules¶
Use add_conditional_edges with named predicates. Concrete sketch:
def route_after_iter(state: State) -> str:
if state["decision"] is not None:
return "dispatch" # cap/abort already emitted a decision
return "g_region"
def route_after_region(state: State) -> str:
return "emit_region" if _needs_region_question(state) else "g_currency"
def route_after_currency(state: State) -> str:
return "emit_currency" if _needs_currency_question(state) else "enrich_schema"
def route_after_calc_caps(state: State) -> str:
if state["decision"] is not None:
return "dispatch" # calc cap or calc success already decided
return "acquire"
def route_after_acquire(state: State) -> str:
if state["decision"] is None:
return "plan_llm" # nothing deterministic; ask the LLM
return "c_calc_adjust" # acquire decisions still need calc-adjust pass
def route_after_dispatch(state: State) -> str:
return state["decision"].action # "search" | "ask_user" | "calculate" | "reflect" | "finish"
c_* correctors form a straight add_edge chain — no predicates, no branching — because they are sequential corrections to a single decision.
7. Migration Strategy¶
Three-step, each step ships green tests.
Step 1 — Extract helpers (no graph change)¶
Pull each if-block in plan_node into a named pure helper that takes State and returns either None (no decision) or (PlannerDecision, status, extra_state). The current plan_node becomes a 30-line sequence calling those helpers. No graph topology change yet.
Risk: very low. This is mechanical refactoring; all existing tests in tests/planner/test_planner_agent.py keep passing without modification.
Step 2 — Hoist gates into graph¶
Split plan_node into the g_iter_cap, g_region, g_currency, enrich_schema, g_calc_caps, acquire nodes. Keep plan_llm + all c_* correctors fused as a single plan_llm node initially.
Risk: medium. Edge predicates need to handle the "decision already set by earlier gate" path. Add a focused test per gate: state in, expected next node + state-out.
Step 3 — Split correctors¶
Promote each c_* corrector to its own node. dispatch becomes the canonical decision-log + route point.
Risk: low. Correctors are already small pure functions today (_redirect_derived_direct_decision, _redirect_premature_ask_for_web_field, _adjust_calculation_decision). They become 1:1 nodes.
8. Trade-Offs¶
Wins¶
- Mermaid diagram becomes truthful. Reading
current-graph.md(or its regenerated equivalent) will actually describe the policy. - Test isolation. Each gate/corrector tests as
node_fn(state) -> partial_state— no need to walk the rest ofplan_node. - Adding policy = adding a node, not editing a megafunction. New gates (e.g. ask for industry before region) become a node + edge.
- Logging consolidates into
dispatch, removing 3 distinctlog.bind(...).info(...)sites in today'splan_node. - Checkpoint footprint shrinks at each step boundary — partial states are smaller because every node writes only the fields it owns.
Costs¶
- More node-to-node hops per iteration. Today: 1 node call. After: up to 10 (gates + correctors). Each hop is a checkpointer write under the postgres
PostgresSaver. Likely measurable but small (gates are deterministic and cheap). Mitigation: allow LangGraph to batch by usingadd_edge(notadd_conditional_edges) wherever the next node is statically known; this is the case for the entirec_*corrector chain. - Slight indirection for readers used to
plan_node. Mitigated by the Mermaid diagram in §3 staying canonical. - Multi-step migration discipline required. Each step must ship green or the planner ships broken —
plan_nodeis in the hot path of every conversation turn.
Non-goals¶
- This proposal does not change planner semantics, LLM prompt, or executor nodes (
search,observe,calculate,ask_user,observe_user,reflect,finish). - This proposal does not change the outer conversation graph or
run_planner_step()integration. - This proposal does not touch the planner state schema beyond adding the transient
llm_failedflag.
9. Open Questions¶
- Should
enrich_schemaalways run, even when a preceding gate already emitted a decision? Currently yes (region/currency gates run before enrichment inplan_node, but the calc-cap/success/acquire gates run after). The proposed graph preserves that order. A more aggressive refactor would always enrich first; needs measurement on whether decomposition LLM calls for soon-to-be-aborted plans are wasteful. acquireas one node or two? Today's logic ties blocked-calc acquisition (lines 945–988) to a different code path than generic acquisition (lines 1006–1024). Splitting them intoacquire_blocked→acquire_genericmakes the conditional clearer but adds another hop; recommended as a follow-up after Step 2 lands.- Should
c_search_capandc_ask_capbe merged? They share the_attempts_forlookup pattern but emit different fallbacks (search → ask_user/reflect; ask_user → finish). Keeping them split mirrors the cap they enforce; merging hides intent.
10. Recommendation¶
Adopt the three-step migration. Land Step 1 (helper extraction) in a standalone commit — it is risk-free and immediately makes plan_node reviewable. Treat Steps 2 and 3 as separate PRs gated on planner test coverage being explicit about each gate's predicate.