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 {
42        source: String,
43        target: String,
44    },
45
46    Conditional {
47        source: String,
48        router_name: String,
49        targets: HashMap<String, String>,
50        default_target: Option<String>,
51    },
52
53    /// FanOut edge: one source → multiple targets (parallel execution)
54    FanOut {
55        source: String,
56        targets: Vec<String>,
57    },
58
59    /// FanIn edge: multiple sources → one target (join point)
60    FanIn {
61        sources: Vec<String>,
62        target: String,
63    },
64}
65
66impl GraphEdge {
67    pub fn fixed(source: impl Into<String>, target: impl Into<String>) -> Self {
68        Self::Fixed {
69            source: source.into(),
70            target: target.into(),
71        }
72    }
73
74    pub fn conditional<R, T>(
75        source: impl Into<String>,
76        router_name: impl Into<String>,
77        targets: HashMap<R, T>,
78        default: Option<T>,
79    ) -> Self
80    where
81        R: Into<String>,
82        T: Into<String>,
83    {
84        Self::Conditional {
85            source: source.into(),
86            router_name: router_name.into(),
87            targets: targets
88                .into_iter()
89                .map(|(k, v)| (k.into(), v.into()))
90                .collect(),
91            default_target: default.map(|d| d.into()),
92        }
93    }
94
95    pub fn fan_out(source: impl Into<String>, targets: Vec<String>) -> Self {
96        Self::FanOut {
97            source: source.into(),
98            targets,
99        }
100    }
101
102    pub fn fan_in(sources: Vec<String>, target: impl Into<String>) -> Self {
103        Self::FanIn {
104            sources,
105            target: target.into(),
106        }
107    }
108
109    pub fn source(&self) -> &str {
110        match self {
111            Self::Fixed { source, .. } => source,
112            Self::Conditional { source, .. } => source,
113            Self::FanOut { source, .. } => source,
114            Self::FanIn { .. } => "__fanin__", // FanIn has multiple sources
115        }
116    }
117
118    pub fn fixed_target(&self) -> Option<&str> {
119        match self {
120            Self::Fixed { target, .. } => Some(target),
121            Self::Conditional { .. } => None,
122            Self::FanOut { .. } => None,
123            Self::FanIn { target, .. } => Some(target),
124        }
125    }
126
127    pub fn fan_out_targets(&self) -> Option<&Vec<String>> {
128        match self {
129            Self::FanOut { targets, .. } => Some(targets),
130            _ => None,
131        }
132    }
133
134    pub fn fan_in_sources(&self) -> Option<&Vec<String>> {
135        match self {
136            Self::FanIn { sources, .. } => Some(sources),
137            _ => None,
138        }
139    }
140}
141
142/// Conditional routing function trait
143///
144/// Routing functions examine the state and return a string key
145/// that maps to the next node via the edge's target map.
146#[async_trait::async_trait]
147pub trait ConditionalEdge<S: StateSchema>: Send + Sync {
148    /// Route to next node based on state
149    ///
150    /// Returns a string key that matches entries in the edge's targets map.
151    async fn route(&self, state: &S) -> Result<String, GraphError>;
152}
153
154/// Function-based conditional router
155pub struct FunctionRouter<S: StateSchema, F> {
156    func: F,
157    _marker: PhantomData<S>,
158}
159
160impl<S: StateSchema, F> FunctionRouter<S, F>
161where
162    F: Fn(&S) -> String + Send + Sync,
163{
164    pub fn new(func: F) -> Self {
165        Self {
166            func,
167            _marker: PhantomData,
168        }
169    }
170}
171
172#[async_trait::async_trait]
173impl<S: StateSchema, F> ConditionalEdge<S> for FunctionRouter<S, F>
174where
175    F: Fn(&S) -> String + Send + Sync,
176{
177    async fn route(&self, state: &S) -> Result<String, GraphError> {
178        Ok((self.func)(state))
179    }
180}
181
182/// Async function-based conditional router
183pub struct AsyncFunctionRouter<S: StateSchema, F> {
184    func: F,
185    _marker: PhantomData<S>,
186}
187
188impl<S: StateSchema, F, Fut> AsyncFunctionRouter<S, F>
189where
190    F: Fn(&S) -> Fut + Send + Sync,
191    Fut: std::future::Future<Output = Result<String, GraphError>> + Send,
192{
193    pub fn new(func: F) -> Self {
194        Self {
195            func,
196            _marker: PhantomData,
197        }
198    }
199}
200
201#[async_trait::async_trait]
202impl<S: StateSchema, F, Fut> ConditionalEdge<S> for AsyncFunctionRouter<S, F>
203where
204    F: Fn(&S) -> Fut + Send + Sync,
205    Fut: std::future::Future<Output = Result<String, GraphError>> + Send,
206{
207    async fn route(&self, state: &S) -> Result<String, GraphError> {
208        (self.func)(state).await
209    }
210}
211
212/// Common routing keys
213pub const ROUTE_CONTINUE: &str = "continue";
214pub const ROUTE_END: &str = "end";
215pub const ROUTE_ERROR: &str = "error";
216
217#[cfg(test)]
218mod tests {
219    use super::*;
220    use crate::state::AgentState;
221
222    #[test]
223    fn test_fixed_edge() {
224        let edge = GraphEdge::fixed("start", "process");
225        assert_eq!(edge.source(), "start");
226        assert_eq!(edge.fixed_target(), Some("process"));
227    }
228
229    #[test]
230    fn test_conditional_edge() {
231        let targets = HashMap::from([("continue", "next_node"), ("end", "__end__")]);
232        let edge = GraphEdge::conditional("decision", "router", targets, None);
233        assert_eq!(edge.source(), "decision");
234        assert!(edge.fixed_target().is_none());
235    }
236
237    #[tokio::test]
238    async fn test_function_router() {
239        let router = FunctionRouter::new(|state: &AgentState| {
240            if state.output.is_some() {
241                ROUTE_END.to_string()
242            } else {
243                ROUTE_CONTINUE.to_string()
244            }
245        });
246
247        let state = AgentState::new("test".to_string());
248        let route = router.route(&state).await.unwrap();
249        assert_eq!(route, ROUTE_CONTINUE);
250    }
251}