Skip to content

Proposal: Hoist plan_node Policy Into Graph Edges

Author: Sisyphus (opus-4.7) Scope: src/venturescope/planner/agent.pyplan_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.md does 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 State and 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_failed bookkeeping 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_node is a deterministic policy that decides "do X now and skip LLM", it belongs on the graph as a node + edge — not inside plan_node as an if block.

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_node declarations with single-responsibility node functions.
  • add_conditional_edges with 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 toward dispatch.
  • enrich = pure state enrichment (no decision).
  • acquire = deterministic acquisition lookup.
  • plan_llm = the actual LLM call (single responsibility).
  • c_* = corrector. Always sequential. Mutates the existing decision rather than emits a fresh one.
  • dispatch = decision-action router (replaces today's route_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_blockedacquire_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 by plan_llm, read by c_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 of plan_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 distinct log.bind(...).info(...) sites in today's plan_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 using add_edge (not add_conditional_edges) wherever the next node is statically known; this is the case for the entire c_* 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_node is 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_failed flag.

9. Open Questions

  1. Should enrich_schema always run, even when a preceding gate already emitted a decision? Currently yes (region/currency gates run before enrichment in plan_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.
  2. acquire as 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 into acquire_blockedacquire_generic makes the conditional clearer but adds another hop; recommended as a follow-up after Step 2 lands.
  3. Should c_search_cap and c_ask_cap be merged? They share the _attempts_for lookup 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.