Skip to content

Conditional edges ignore PathMap: router return value is used directly as a node name #15

Description

@sbmandava

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.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

No labels
No labels

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions