Skip to main content

lc_langgraph/compiled/
parallel.rs

1// crates/lc-langgraph/src/compiled/parallel.rs
2//! CompiledGraph invoke_parallel, invoke_dynamic, and merge_parallel_states methods
3
4use super::graph::CompiledGraph;
5use super::types::{
6    DynamicInjection, DynamicPlanner, ExecutionStep, GraphInvocation, ParallelBranch,
7    ParallelInvocation,
8};
9use crate::errors::{GraphError, GraphResult};
10use crate::node::NodeConfig;
11use crate::state::StateSchema;
12use crate::END;
13use std::collections::HashMap;
14
15impl<S: StateSchema> CompiledGraph<S> {
16    /// Run the graph while capturing parallel fan-out branches into a [`ParallelInvocation`].
17    pub async fn invoke_parallel(&self, input: S) -> GraphResult<ParallelInvocation<S>> {
18        let mut state = input;
19        let mut current_node = self.entry_point.clone();
20        let mut steps: Vec<ExecutionStep> = Vec::new();
21        let mut recursion_count = 0;
22        let mut parallel_branches: Vec<ParallelBranch<S>> = Vec::new();
23
24        if let Some(ref checkpointer) = self.checkpointer {
25            let checkpoint_id = checkpointer.lock().await.save(&state, 0).await?;
26            steps.push(ExecutionStep::checkpoint(
27                checkpoint_id,
28                current_node.clone(),
29            ));
30        }
31
32        loop {
33            if current_node == END {
34                break;
35            }
36
37            // Q3: check the limit before executing a step (same guard as `invoke`).
38            if recursion_count >= self.recursion_limit {
39                return Err(GraphError::RecursionLimitReached(self.recursion_limit));
40            }
41
42            if self.interrupt_before.contains(&current_node) {
43                return Err(GraphError::ExecutionInterrupted(current_node.clone()));
44            }
45
46            // 0.22.0 C3 fix: the fan-out source node executes BEFORE branching.
47            // Previously the fan-out check ran first and the source node was
48            // silently skipped. START is virtual (never executed).
49            if current_node != crate::START {
50                recursion_count += 1;
51
52                let node = self.get_node(&current_node).await?;
53
54                let config = NodeConfig {
55                    recursion_limit: self.recursion_limit,
56                    debug: false,
57                    metadata: HashMap::new(),
58                };
59
60                let update = node.execute(&state, Some(config)).await?;
61
62                if let Some(new_state) = update.update {
63                    state = self.default_reducer.reduce(&state, &new_state);
64                }
65
66                steps.push(ExecutionStep::node(
67                    current_node.clone(),
68                    update.metadata.clone(),
69                ));
70
71                if self.interrupt_after.contains(&current_node) {
72                    return Err(GraphError::ExecutionInterrupted(format!(
73                        "after_{}",
74                        current_node
75                    )));
76                }
77            }
78
79            // FanOut: after the source node has run, execute all branches and merge.
80            let fan_out_targets = self.find_fan_out_targets(&current_node).await;
81
82            if let Some(targets) = fan_out_targets {
83                recursion_count += 1;
84                let branch_results = self
85                    .execute_parallel_branches(&targets, &state, recursion_count)
86                    .await?;
87
88                for (name, inv) in branch_results {
89                    parallel_branches.push(ParallelBranch {
90                        name: name.clone(),
91                        final_state: inv.final_state.clone(),
92                        steps: inv.steps.clone(),
93                    });
94                    steps.push(ExecutionStep::ParallelNode {
95                        branch: name,
96                        metadata: HashMap::new(),
97                    });
98                }
99
100                let merge_target = self.find_fan_in_target(&targets).await;
101                // C3: fold on top of the pre-fan-out main-path state.
102                let base = state.clone();
103                if let Some(merge_node) = merge_target {
104                    state = self.merge_parallel_states(&parallel_branches, &base)?;
105                    current_node = merge_node;
106                } else {
107                    state = self.merge_parallel_states(&parallel_branches, &base)?;
108                    current_node = END.to_string();
109                }
110            } else {
111                current_node = self.find_next_node(&current_node, &state).await?;
112            }
113        }
114
115        Ok(ParallelInvocation {
116            final_state: state,
117            steps,
118            recursion_count,
119            parallel_branches,
120        })
121    }
122
123    /// Execute the graph with dynamic task injection support.
124    ///
125    /// After each node completes, checks the task_inbox for newly submitted tasks.
126    /// If tasks are found, uses the provided `planner` to convert them into new
127    /// nodes/edges and injects them into the runtime registries before continuing.
128    pub async fn invoke_dynamic(
129        &self,
130        input: S,
131        planner: &dyn DynamicPlanner<S>,
132    ) -> GraphResult<GraphInvocation<S>> {
133        let mut state = input;
134        let mut current_node = self.entry_point.clone();
135        let mut steps: Vec<ExecutionStep> = Vec::new();
136        let mut recursion_count = 0;
137
138        if let Some(ref checkpointer) = self.checkpointer {
139            let checkpoint_id = checkpointer.lock().await.save(&state, 0).await?;
140            steps.push(ExecutionStep::checkpoint(
141                checkpoint_id,
142                current_node.clone(),
143            ));
144        }
145
146        loop {
147            if current_node == END {
148                break;
149            }
150
151            // Q3: check the limit before executing a step (same guard as `invoke`).
152            if recursion_count >= self.recursion_limit {
153                return Err(GraphError::RecursionLimitReached(self.recursion_limit));
154            }
155
156            // Check for pending dynamically-submitted tasks
157            let pending = {
158                let mut inbox = self.task_inbox.lock().await;
159                inbox.drain(..).collect::<Vec<_>>()
160            };
161            if !pending.is_empty() {
162                let injection: DynamicInjection<S> = planner
163                    .plan(&pending, &state)
164                    .await
165                    .map_err(|e| GraphError::RuntimeError(e.to_string()))?;
166
167                {
168                    let mut rn = self.runtime_nodes.write().await;
169                    for (name, node) in injection.nodes {
170                        rn.insert(name, node);
171                    }
172                }
173                {
174                    let mut re = self.runtime_edges.write().await;
175                    re.extend(injection.edges);
176                }
177            }
178
179            if self.interrupt_before.contains(&current_node) {
180                return Err(GraphError::ExecutionInterrupted(current_node.clone()));
181            }
182
183            recursion_count += 1;
184
185            let node = self.get_node(&current_node).await?;
186            let config = NodeConfig {
187                recursion_limit: self.recursion_limit,
188                debug: false,
189                metadata: HashMap::new(),
190            };
191            let update = node.execute(&state, Some(config)).await?;
192
193            if let Some(new_state) = update.update {
194                state = self.default_reducer.reduce(&state, &new_state);
195            }
196            steps.push(ExecutionStep::node(
197                current_node.clone(),
198                update.metadata.clone(),
199            ));
200
201            if self.interrupt_after.contains(&current_node) {
202                return Err(GraphError::ExecutionInterrupted(format!(
203                    "after_{}",
204                    current_node
205                )));
206            }
207
208            let next_node = self.find_next_node(&current_node, &state).await?;
209
210            if let Some(ref checkpointer) = self.checkpointer {
211                let checkpoint_id = checkpointer
212                    .lock()
213                    .await
214                    .save(&state, recursion_count)
215                    .await?;
216                steps.push(ExecutionStep::checkpoint(checkpoint_id, next_node.clone()));
217            }
218
219            current_node = next_node;
220        }
221
222        Ok(GraphInvocation {
223            final_state: state,
224            steps,
225            recursion_count,
226        })
227    }
228
229    /// Merges parallel branch states on top of the main-path `base` state.
230    ///
231    /// 0.22.0 C3 fix: the fold used to start from `branches[0]`, so the last
232    /// branch overwrote everything **and** any state the fan-out source node
233    /// wrote before branching was dropped. Now `base` (the state at the
234    /// fan-out point) is the fold's foundation.
235    pub(super) fn merge_parallel_states(
236        &self,
237        branches: &[ParallelBranch<S>],
238        base: &S,
239    ) -> GraphResult<S> {
240        if branches.is_empty() {
241            return Err(GraphError::StateError(
242                "Cannot merge states: no parallel branches completed".to_string(),
243            ));
244        }
245
246        let mut merged = base.clone();
247        for branch in branches {
248            merged = self.default_reducer.reduce(&merged, &branch.final_state);
249        }
250        Ok(merged)
251    }
252}