Skip to main content

lc_langgraph/
edge.rs

1// crates/lc-langgraph/src/edge.rs
2//! Edge definition for LangGraph
3//!
4//! Edges define transitions between nodes. They can be fixed (always go to
5//! the same target) or conditional (route based on state).
6
7use crate::errors::GraphError;
8use crate::state::StateSchema;
9use std::collections::HashMap;
10use std::marker::PhantomData;
11
12/// Edge target specification
13#[derive(Debug, Clone, PartialEq)]
14pub enum EdgeTarget {
15    /// Fixed target node
16    Fixed(String),
17
18    /// Conditional routing (target determined by routing function name)
19    Conditional(String),
20}
21
22impl EdgeTarget {
23    /// Create a fixed edge target
24    pub fn to(node: impl Into<String>) -> Self {
25        Self::Fixed(node.into())
26    }
27
28    /// Create a conditional edge target
29    pub fn conditional(router: impl Into<String>) -> Self {
30        Self::Conditional(router.into())
31    }
32}
33
34/// Graph Edge enum
35///
36/// Represents a transition in the graph. Can be:
37/// - Fixed: Always transitions to a specific node
38/// - Conditional: Routes based on state via a routing function
39#[derive(Debug, Clone)]
40pub enum GraphEdge {
41    /// Fixed edge: always transitions to a specific target node.
42    Fixed {
43        /// Source node name.
44        source: String,
45        /// Target node name.
46        target: String,
47    },
48
49    /// Conditional edge: routes to a target based on state via a routing function.
50    Conditional {
51        /// Source node name.
52        source: String,
53        /// Name of the routing function used to select a target.
54        router_name: String,
55        /// Route key → target node name mapping.
56        targets: HashMap<String, String>,
57        /// Target used when no route key matches.
58        default_target: Option<String>,
59    },
60
61    /// FanOut edge: one source → multiple targets (parallel execution)
62    FanOut {
63        /// Source node name.
64        source: String,
65        /// Target nodes to execute in parallel.
66        targets: Vec<String>,
67    },
68
69    /// FanIn edge: multiple sources → one target (join point)
70    FanIn {
71        /// Source nodes that join at this point.
72        sources: Vec<String>,
73        /// Single target node after the join.
74        target: String,
75    },
76}
77
78impl GraphEdge {
79    /// Create a fixed edge from source to target.
80    pub fn fixed(source: impl Into<String>, target: impl Into<String>) -> Self {
81        Self::Fixed {
82            source: source.into(),
83            target: target.into(),
84        }
85    }
86
87    /// Create a conditional edge routed by a named routing function.
88    pub fn conditional<R, T>(
89        source: impl Into<String>,
90        router_name: impl Into<String>,
91        targets: HashMap<R, T>,
92        default: Option<T>,
93    ) -> Self
94    where
95        R: Into<String>,
96        T: Into<String>,
97    {
98        Self::Conditional {
99            source: source.into(),
100            router_name: router_name.into(),
101            targets: targets
102                .into_iter()
103                .map(|(k, v)| (k.into(), v.into()))
104                .collect(),
105            default_target: default.map(|d| d.into()),
106        }
107    }
108
109    /// Create a FanOut edge with multiple parallel targets.
110    pub fn fan_out(source: impl Into<String>, targets: Vec<String>) -> Self {
111        Self::FanOut {
112            source: source.into(),
113            targets,
114        }
115    }
116
117    /// Create a FanIn edge joining multiple sources into one target.
118    pub fn fan_in(sources: Vec<String>, target: impl Into<String>) -> Self {
119        Self::FanIn {
120            sources,
121            target: target.into(),
122        }
123    }
124
125    /// Return the source node name (for FanIn returns `"__fanin__"`).
126    pub fn source(&self) -> &str {
127        match self {
128            Self::Fixed { source, .. } => source,
129            Self::Conditional { source, .. } => source,
130            Self::FanOut { source, .. } => source,
131            Self::FanIn { .. } => "__fanin__", // FanIn has multiple sources
132        }
133    }
134
135    /// Return the fixed target node, if this edge has one.
136    pub fn fixed_target(&self) -> Option<&str> {
137        match self {
138            Self::Fixed { target, .. } => Some(target),
139            Self::Conditional { .. } => None,
140            Self::FanOut { .. } => None,
141            Self::FanIn { target, .. } => Some(target),
142        }
143    }
144
145    /// Return the FanOut targets, if this is a FanOut edge.
146    pub fn fan_out_targets(&self) -> Option<&Vec<String>> {
147        match self {
148            Self::FanOut { targets, .. } => Some(targets),
149            _ => None,
150        }
151    }
152
153    /// Return the FanIn sources, if this is a FanIn edge.
154    pub fn fan_in_sources(&self) -> Option<&Vec<String>> {
155        match self {
156            Self::FanIn { sources, .. } => Some(sources),
157            _ => None,
158        }
159    }
160}
161
162/// Conditional routing function trait
163///
164/// Routing functions examine the state and return a string key
165/// that maps to the next node via the edge's target map.
166#[async_trait::async_trait]
167pub trait ConditionalEdge<S: StateSchema>: Send + Sync {
168    /// Route to next node based on state
169    ///
170    /// Returns a string key that matches entries in the edge's targets map.
171    async fn route(&self, state: &S) -> Result<String, GraphError>;
172}
173
174/// Function-based conditional router
175pub struct FunctionRouter<S: StateSchema, F> {
176    func: F,
177    _marker: PhantomData<S>,
178}
179
180impl<S: StateSchema, F> FunctionRouter<S, F>
181where
182    F: Fn(&S) -> String + Send + Sync,
183{
184    /// Create a function-based conditional router.
185    pub fn new(func: F) -> Self {
186        Self {
187            func,
188            _marker: PhantomData,
189        }
190    }
191}
192
193#[async_trait::async_trait]
194impl<S: StateSchema, F> ConditionalEdge<S> for FunctionRouter<S, F>
195where
196    F: Fn(&S) -> String + Send + Sync,
197{
198    async fn route(&self, state: &S) -> Result<String, GraphError> {
199        Ok((self.func)(state))
200    }
201}
202
203/// Async function-based conditional router
204pub struct AsyncFunctionRouter<S: StateSchema, F> {
205    func: F,
206    _marker: PhantomData<S>,
207}
208
209impl<S: StateSchema, F, Fut> AsyncFunctionRouter<S, F>
210where
211    F: Fn(&S) -> Fut + Send + Sync,
212    Fut: std::future::Future<Output = Result<String, GraphError>> + Send,
213{
214    /// Create an async function-based conditional router.
215    pub fn new(func: F) -> Self {
216        Self {
217            func,
218            _marker: PhantomData,
219        }
220    }
221}
222
223#[async_trait::async_trait]
224impl<S: StateSchema, F, Fut> ConditionalEdge<S> for AsyncFunctionRouter<S, F>
225where
226    F: Fn(&S) -> Fut + Send + Sync,
227    Fut: std::future::Future<Output = Result<String, GraphError>> + Send,
228{
229    async fn route(&self, state: &S) -> Result<String, GraphError> {
230        (self.func)(state).await
231    }
232}
233
234/// Common routing keys
235pub const ROUTE_CONTINUE: &str = "continue";
236/// Routing key meaning "end execution".
237pub const ROUTE_END: &str = "end";
238/// Routing key meaning "error occurred".
239pub const ROUTE_ERROR: &str = "error";
240
241#[cfg(test)]
242mod tests {
243    use super::*;
244    use crate::state::AgentState;
245
246    #[test]
247    fn test_fixed_edge() {
248        let edge = GraphEdge::fixed("start", "process");
249        assert_eq!(edge.source(), "start");
250        assert_eq!(edge.fixed_target(), Some("process"));
251    }
252
253    #[test]
254    fn test_conditional_edge() {
255        let targets = HashMap::from([("continue", "next_node"), ("end", "__end__")]);
256        let edge = GraphEdge::conditional("decision", "router", targets, None);
257        assert_eq!(edge.source(), "decision");
258        assert!(edge.fixed_target().is_none());
259    }
260
261    #[tokio::test]
262    async fn test_function_router() {
263        let router = FunctionRouter::new(|state: &AgentState| {
264            if state.output.is_some() {
265                ROUTE_END.to_string()
266            } else {
267                ROUTE_CONTINUE.to_string()
268            }
269        });
270
271        let state = AgentState::new("test".to_string());
272        let route = router.route(&state).await.unwrap();
273        assert_eq!(route, ROUTE_CONTINUE);
274    }
275}