Conditional edges ignore PathMap: router return value is used directly as a node name
Version: juncture / juncture-core 0.3.1 · Target: native (x86_64-unknown-linux-gnu) — not wasm-specific · rustc 1.96
Summary
add_conditional_edges accepts a PathMap, but at runtime the router's return value is
used directly as a node name. path_map is never consulted to translate a branch label
into a target. A graph therefore routes correctly only when the PathMap happens to be the
identity ("plan" => "plan"); any indirection — the documented shape, e.g.
{"continue": "plan", "done": END} — silently stops the graph after one superstep.
It fails silently: no error, no warning, invoke_async returns Ok. An agent that
should loop just does one pass and reports success.
Reproduction
Both cases below are the same graph and the same control flow. Only the PathMap
labelling differs.
use juncture::{config::RunnableConfig, edge::PathMap, graph::StateGraph, node::NodeFnUpdate, END, START};
use juncture_derive::State;
#[derive(Clone, Default, Debug, State, serde::Serialize, serde::Deserialize)]
struct S { count: i32 }
fn build(paths: PathMap, router: fn(&S) -> &str) -> Result<String, String> {
let mut g: StateGraph<S> = StateGraph::new();
g.add_node_simple("plan", NodeFnUpdate(|s: &S| {
let c = s.count + 1;
Box::pin(async move { Ok(SUpdate { count: Some(c) }) })
})).unwrap();
g.set_entry_point("plan");
g.add_edge(START, "plan");
g.add_conditional_edges("plan", std::sync::Arc::new(router), paths);
let app = g.compile().map_err(|e| format!("{e:?}"))?;
let mut cfg = RunnableConfig::default();
cfg.recursion_limit = 25;
futures::executor::block_on(app.invoke_async(S::default(), &cfg))
.map(|r| format!("count={}", r.value.count)).map_err(|e| format!("{e:?}"))
}
fn main() {
// A — identity PathMap: router returns the node name itself.
let mut a = PathMap::new();
a.insert("plan", "plan");
a.insert(END, END);
fn ra<'a>(s: &'a S) -> &'a str { if s.count >= 3 { END } else { "plan" } }
println!("A identity : {:?}", build(a, ra));
// B — indirection: router returns a LABEL, PathMap maps label -> node.
let mut b = PathMap::new();
b.insert("continue", "plan");
b.insert("done", END);
fn rb<'a>(s: &'a S) -> &'a str { if s.count >= 3 { "done" } else { "continue" } }
println!("B indirect : {:?}", build(b, rb));
}
Expected: both count=3.
Actual:
A identity : Ok("count=3")
B indirect : Ok("count=1") <-- loop never runs; no error
Root cause
src/pregel/scheduler.rs, both runtime sites destructure path_map away with .. and use
the router's raw output as a node name:
// should_process_edge — line ~473
CompiledEdge::Conditional { router, .. } => {
let route_result = router.route(state).await?;
Ok(route_result.as_target().is_some_and(|t| triggered_nodes.contains(t)))
// ^ `t` is the branch label, not a node
}
// process_edge — line ~501
CompiledEdge::Conditional { router, .. } => {
let route_result = router.route(state).await?;
let target = route_result.as_target()...;
// `target` is pushed as a PendingTask node name directly
path_map is used only at compile time (graph/topology.rs, graph/builder.rs) to derive
the static edge set for cycle detection and trigger tables. At runtime it is dropped, so
label "continue" is looked up as a node, matches nothing, and the superstep schedules no
work — which the loop treats as normal termination.
Consistent with this, edge/compiled.rs:86 documents the field as "Path mapping for
validation" — but edge/types.rs:197 and graph/builder.rs:1493 both show indirection
(("approve", "publish"), ("reject", "archive")) as the intended usage, and LangGraph's
path_map is a translation map. The docs and the engine disagree.
Suggested fix: in both arms, resolve through the map before use —
let target = path_map.get(raw).unwrap_or(raw); — and make a label that is neither a key
in path_map nor a known node an error rather than a silent stop.
Secondary: the router is invoked twice per superstep
should_process_edge and process_edge each call router.route(state). Adding a print to
the router in case A above gives:
router(count=1)
router(count=1)
router(count=2)
router(count=2)
router(count=3)
router(count=3)
For a pure function this is only wasted work, but routers are commonly async and
LLM-backed — this doubles those calls, and any router with side effects observes each
superstep twice. LangGraph invokes a branch function once per superstep.
Why this went unnoticed
The test suite covers conditional edges and covers cycles, but as far as I can tell never a
conditional edge whose branch closes a cycle through a non-identity PathMap — which is
the ReAct shape and the most common agent topology. Case B above would make a good
regression test.
Conditional edges ignore
PathMap: router return value is used directly as a node nameVersion: juncture / juncture-core 0.3.1 · Target: native (
x86_64-unknown-linux-gnu) — not wasm-specific · rustc 1.96Summary
add_conditional_edgesaccepts aPathMap, but at runtime the router's return value isused directly as a node name.
path_mapis never consulted to translate a branch labelinto a target. A graph therefore routes correctly only when the
PathMaphappens to be theidentity (
"plan" => "plan"); any indirection — the documented shape, e.g.{"continue": "plan", "done": END}— silently stops the graph after one superstep.It fails silently: no error, no warning,
invoke_asyncreturnsOk. An agent thatshould loop just does one pass and reports success.
Reproduction
Both cases below are the same graph and the same control flow. Only the
PathMaplabelling differs.
Expected: both
count=3.Actual:
Root cause
src/pregel/scheduler.rs, both runtime sites destructurepath_mapaway with..and usethe router's raw output as a node name:
path_mapis used only at compile time (graph/topology.rs,graph/builder.rs) to derivethe static edge set for cycle detection and trigger tables. At runtime it is dropped, so
label
"continue"is looked up as a node, matches nothing, and the superstep schedules nowork — which the loop treats as normal termination.
Consistent with this,
edge/compiled.rs:86documents the field as "Path mapping forvalidation" — but
edge/types.rs:197andgraph/builder.rs:1493both show indirection(
("approve", "publish"),("reject", "archive")) as the intended usage, and LangGraph'spath_mapis a translation map. The docs and the engine disagree.Suggested fix: in both arms, resolve through the map before use —
let target = path_map.get(raw).unwrap_or(raw);— and make a label that is neither a keyin
path_mapnor a known node an error rather than a silent stop.Secondary: the router is invoked twice per superstep
should_process_edgeandprocess_edgeeach callrouter.route(state). Adding a print tothe router in case A above gives:
For a pure function this is only wasted work, but routers are commonly
asyncandLLM-backed — this doubles those calls, and any router with side effects observes each
superstep twice. LangGraph invokes a branch function once per superstep.
Why this went unnoticed
The test suite covers conditional edges and covers cycles, but as far as I can tell never a
conditional edge whose branch closes a cycle through a non-identity
PathMap— which isthe ReAct shape and the most common agent topology. Case B above would make a good
regression test.