Skip to main content

adk_graph/
executor.rs

1//! Pregel-based execution engine for graphs
2//!
3//! Executes graphs using the Pregel model with super-steps.
4
5#[cfg(feature = "node-cache")]
6use crate::cache::{NodeCache, compute_cache_key};
7use crate::deferred::FanInTracker;
8use crate::error::{GraphError, InterruptedExecution, Result};
9use crate::graph::CompiledGraph;
10use crate::interrupt::Interrupt;
11use crate::node::{ExecutionConfig, NodeContext};
12use crate::state::{Checkpoint, State};
13use crate::stream::{StreamEvent, StreamMode};
14use crate::timeout::{OnTimeout, ProgressHandle, execute_with_timeout, item_timeout_budget};
15use futures::stream::{self, StreamExt};
16use std::collections::HashMap;
17use std::sync::Arc;
18use std::time::Instant;
19
20/// Result of a super-step execution
21#[derive(Default)]
22pub struct SuperStepResult {
23    /// Nodes that were executed
24    pub executed_nodes: Vec<String>,
25    /// Interrupt if one occurred
26    pub interrupt: Option<Interrupt>,
27    /// Stream events generated
28    pub events: Vec<StreamEvent>,
29    /// Nodes that named their own successors, keyed by node name.
30    pub goto: HashMap<String, Vec<String>>,
31}
32
33/// What a completed run produced, and what it asks of its caller.
34#[derive(Debug, Clone)]
35pub struct GraphOutcome {
36    /// The final state.
37    pub state: State,
38    /// Nodes of the parent graph a node asked to run next, if any.
39    ///
40    /// Set by [`NodeOutput::with_goto_parent`](crate::node::NodeOutput::with_goto_parent).
41    /// A graph that is not a subgraph has no parent, so this is ignored.
42    pub goto_parent: Option<Vec<String>>,
43}
44
45/// Pregel-based executor for graphs
46pub struct PregelExecutor<'a> {
47    graph: &'a CompiledGraph,
48    config: ExecutionConfig,
49    state: State,
50    step: usize,
51    pending_nodes: Vec<String>,
52    /// Parent nodes a node asked to run next; see `NodeOutput::with_goto_parent`.
53    goto_parent: Option<Vec<String>>,
54    /// Tracks deferred nodes waiting for all upstream paths to complete.
55    pending_deferred: HashMap<String, FanInTracker>,
56    /// Tracks when each deferred node first entered the pending state (for fan-in timeout).
57    deferred_start_times: HashMap<String, Instant>,
58    /// Attempts already spent per node, carried through a resume so a retry
59    /// budget is not restarted.
60    attempts: HashMap<String, u32>,
61    /// Outputs of children invoked imperatively, keyed by child path. Shared with
62    /// every node's invoker so a resumed parent serves finished children from it.
63    child_ledger: Arc<std::sync::Mutex<HashMap<String, serde_json::Value>>>,
64    /// The node whose static interrupt this run has already answered.
65    ///
66    /// Restored from the checkpoint on resume and cleared once that node has
67    /// executed, so the gate re-arms for a later arrival through a cycle.
68    cleared_interrupt: Option<String>,
69    /// Per-node caches initialized from `CompiledGraph::cache_policies`.
70    #[cfg(feature = "node-cache")]
71    node_caches: HashMap<String, NodeCache>,
72}
73
74impl<'a> PregelExecutor<'a> {
75    /// Create a new executor
76    pub fn new(graph: &'a CompiledGraph, config: ExecutionConfig) -> Self {
77        #[cfg(feature = "node-cache")]
78        let node_caches = graph
79            .cache_policies
80            .iter()
81            .map(|(name, policy)| (name.clone(), NodeCache::from_policy(policy)))
82            .collect();
83
84        Self {
85            graph,
86            config,
87            state: State::new(),
88            step: 0,
89            pending_nodes: vec![],
90            goto_parent: None,
91            pending_deferred: HashMap::new(),
92            deferred_start_times: HashMap::new(),
93            attempts: HashMap::new(),
94            child_ledger: Arc::new(std::sync::Mutex::new(HashMap::new())),
95            cleared_interrupt: None,
96            #[cfg(feature = "node-cache")]
97            node_caches,
98        }
99    }
100
101    /// Attempt to resume from an existing checkpoint.
102    ///
103    /// If a checkpoint is found (either by explicit `resume_from` ID or by latest
104    /// checkpoint for the thread), restores state, pending_nodes, and step from it,
105    /// then merges the provided input on top. Returns `true` if resumed.
106    ///
107    /// If no checkpoint is found, returns `false` so the caller can proceed with
108    /// fresh-start logic.
109    async fn try_resume_from_checkpoint(&mut self, input: &State) -> Result<bool> {
110        let checkpoint = if let Some(checkpoint_id) = &self.config.resume_from {
111            // Resume from a specific checkpoint by ID
112            if let Some(cp) = self.graph.checkpointer.as_ref() {
113                cp.load_by_id(checkpoint_id).await?
114            } else {
115                None
116            }
117        } else if let Some(cp) = self.graph.checkpointer.as_ref() {
118            // Try to load the latest checkpoint for this thread
119            cp.load(&self.config.thread_id).await?
120        } else {
121            None
122        };
123
124        if let Some(checkpoint) = checkpoint {
125            // Restore state from checkpoint
126            self.state = checkpoint.state;
127            self.pending_nodes = checkpoint.pending_nodes;
128            self.step = checkpoint.step;
129            self.cleared_interrupt = checkpoint.cleared_interrupt;
130            self.attempts = checkpoint.attempts;
131            *self.child_ledger.lock().expect("child ledger") = checkpoint.child_ledger;
132
133            // Merge input on top of restored state
134            for (key, value) in input {
135                self.graph.schema.apply_update(&mut self.state, key, value.clone());
136            }
137
138            Ok(true)
139        } else {
140            Ok(false)
141        }
142    }
143
144    /// Run the graph to completion
145    pub async fn run(&mut self, input: State) -> Result<State> {
146        // Check for existing checkpoint to resume from
147        let resumed = self.try_resume_from_checkpoint(&input).await?;
148
149        if !resumed {
150            // No checkpoint found — fresh start
151            self.state = self.initialize_state(input).await?;
152            self.pending_nodes = self.graph.get_entry_nodes();
153        }
154
155        // Main execution loop
156        while !self.pending_nodes.is_empty() {
157            // Check recursion limit
158            if self.step >= self.config.recursion_limit {
159                return Err(GraphError::RecursionLimitExceeded(self.step));
160            }
161
162            // Execute super-step
163            let result = match self.execute_super_step().await {
164                Ok(result) => result,
165                Err(error) => {
166                    // Checkpoint before propagating, so a retry budget already
167                    // spent is not handed out again by the next invocation. The
168                    // frontier still holds the failed node, which is what makes
169                    // the run resumable at all.
170                    let any_retryable = self
171                        .pending_nodes
172                        .iter()
173                        .any(|node| self.graph.retry_policy_for(node).is_some());
174                    if any_retryable {
175                        let _ = self.save_checkpoint().await;
176                    }
177                    return Err(error);
178                }
179            };
180
181            // Handle interrupts
182            if let Some(interrupt) = result.interrupt {
183                // Record the gate being answered so the resumed run executes
184                // this node rather than stopping at it again.
185                if let Interrupt::Before(node) = &interrupt {
186                    self.cleared_interrupt = Some(node.clone());
187                }
188                // `After` has the opposite timing: the node ran and its updates
189                // are applied, so the resume point is its successors. Saving the
190                // executing frontier would re-run it and re-raise the gate.
191                if matches!(interrupt, Interrupt::After(_)) {
192                    let next = self.next_frontier(&result.executed_nodes, &result.goto)?;
193                    self.pending_nodes =
194                        self.filter_deferred_nodes(next, &result.executed_nodes)?;
195                }
196                // For `Before`, the frontier saved is deliberately the one that
197                // was executing: the node produced no updates, so resuming must
198                // run it, which the marker above now permits.
199                let checkpoint_id = self.save_checkpoint().await?;
200                return Err(GraphError::Interrupted(Box::new(InterruptedExecution::new(
201                    self.config.thread_id.clone(),
202                    checkpoint_id,
203                    interrupt,
204                    self.state.clone(),
205                    self.step,
206                ))));
207            }
208
209            // The gate re-arms once its node has run, so a cycle returning to
210            // the same node asks again.
211            if let Some(cleared) = &self.cleared_interrupt
212                && result.executed_nodes.iter().any(|n| n == cleared)
213            {
214                self.cleared_interrupt = None;
215            }
216
217            // Advance the frontier *before* checkpointing. A checkpoint records
218            // what still has to run, so saving while `pending_nodes` still holds
219            // the nodes that just finished would re-execute them on resume.
220            let next_candidates = self.next_frontier(&result.executed_nodes, &result.goto)?;
221            self.pending_nodes =
222                self.filter_deferred_nodes(next_candidates, &result.executed_nodes)?;
223            self.step += 1;
224
225            // An empty frontier is a terminal checkpoint: resuming it re-reads
226            // the final state instead of restarting the graph.
227            self.save_checkpoint().await?;
228
229            if self.pending_nodes.is_empty() {
230                break;
231            }
232        }
233
234        Ok(self.state.clone())
235    }
236
237    /// Run with streaming
238    pub fn run_stream(
239        mut self,
240        input: State,
241        mode: StreamMode,
242    ) -> impl futures::Stream<Item = Result<StreamEvent>> + 'a {
243        async_stream::stream! {
244            // Check for existing checkpoint to resume from
245            let resumed = match self.try_resume_from_checkpoint(&input).await {
246                Ok(r) => r,
247                Err(e) => {
248                    yield Err(e);
249                    return;
250                }
251            };
252
253            if resumed {
254                // Emit a resumed event indicating execution was restored from checkpoint
255                yield Ok(StreamEvent::resumed(self.step, self.pending_nodes.clone()));
256            } else {
257                // No checkpoint found — fresh start
258                match self.initialize_state(input).await {
259                    Ok(state) => self.state = state,
260                    Err(e) => {
261                        yield Err(e);
262                        return;
263                    }
264                }
265                self.pending_nodes = self.graph.get_entry_nodes();
266            }
267
268            // Stream initial state if requested
269            if matches!(mode, StreamMode::Values) {
270                yield Ok(StreamEvent::state(self.state.clone(), self.step));
271            }
272
273            // Main execution loop
274            while !self.pending_nodes.is_empty() {
275                // Check recursion limit
276                if self.step >= self.config.recursion_limit {
277                    yield Err(GraphError::RecursionLimitExceeded(self.step));
278                    return;
279                }
280
281                // Emit node_start events BEFORE execution (in Debug mode)
282                if matches!(mode, StreamMode::Debug | StreamMode::Custom | StreamMode::Messages) {
283                    for node_name in &self.pending_nodes {
284                        yield Ok(StreamEvent::node_start(node_name, self.step));
285                    }
286                }
287
288                // For Messages mode, stream from nodes directly
289                if matches!(mode, StreamMode::Messages) {
290                    let mut result = SuperStepResult::default();
291
292                    // The same gate `execute_super_step` applies. This loop does
293                    // not call it, so without this the mode ignored every gate.
294                    if let Some(interrupt) = self.gate_before(&self.pending_nodes) {
295                        result.interrupt = Some(interrupt);
296                    }
297
298                    for node_name in &self.pending_nodes {
299                        if result.interrupt.is_some() {
300                            break;
301                        }
302                        if let Some(node) = self.graph.nodes.get(node_name) {
303                            let mut ctx = NodeContext::new(self.state.clone(), self.config.clone(), self.step);
304                            ctx.set_parent_schema(Arc::new(self.graph.schema.clone()));
305                            ctx.set_child_invoker(Arc::new(crate::child::ChildInvoker::new(
306                                self.graph.nodes.clone(),
307                                Arc::clone(&self.child_ledger),
308                                node_name.clone(),
309                            )));
310
311                            // Attach progress handle if idle timeout is configured
312                            let policy = self.graph.timeout_policy_for(node_name).cloned();
313                            if let Some(ref p) = policy
314                                && p.idle_timeout.is_some() {
315                                    ctx.set_progress_handle(ProgressHandle::new());
316                                }
317
318                            let start = std::time::Instant::now();
319
320                            // The timeout policy now applies to the streamed
321                            // execution itself. For a stream, "idle" means no
322                            // event was produced within the idle timeout.
323                            let max_attempts = match policy.as_ref().map(|p| &p.on_timeout) {
324                                Some(OnTimeout::Retry { max_attempts }) => (*max_attempts).max(1),
325                                _ => 1,
326                            };
327                            let mut collected_events = Vec::new();
328                            let mut streamed_updates = Vec::new();
329                            let mut streamed_goto: Option<(String, Vec<String>)> = None;
330                            let mut streamed_interrupt: Option<Interrupt> = None;
331                            let mut timed_out_after;
332                            let mut attempt = 0;
333
334                            loop {
335                                attempt += 1;
336                                collected_events.clear();
337                                streamed_updates.clear();
338                                timed_out_after = None;
339                                let attempt_start = std::time::Instant::now();
340                                let mut node_stream = node.execute_stream(&ctx);
341                                let mut failure = None;
342
343                                loop {
344                                    let budget = policy
345                                        .as_ref()
346                                        .and_then(|p| item_timeout_budget(p, attempt_start.elapsed()));
347                                    let item = match budget {
348                                        Some(budget) => {
349                                            match tokio::time::timeout(budget, node_stream.next()).await {
350                                                Ok(item) => item,
351                                                Err(_) => {
352                                                    timed_out_after = Some(attempt_start.elapsed());
353                                                    break;
354                                                }
355                                            }
356                                        }
357                                        None => node_stream.next().await,
358                                    };
359
360                                    match item {
361                                        Some(Ok(event)) => {
362                                            // Yield Message events immediately
363                                            if matches!(event, StreamEvent::Message { .. }) {
364                                                yield Ok(event.clone());
365                                            }
366                                            // The node reports its state updates on the
367                                            // stream, so they are taken from the single
368                                            // execution that produced these events.
369                                            if let StreamEvent::Updates { ref updates, .. } = event {
370                                                streamed_updates.push(updates.clone());
371                                            }
372                                            // A node that routed itself reports it here.
373                                            if let StreamEvent::RouteDispatched {
374                                                ref source,
375                                                ref targets,
376                                            } = event
377                                            {
378                                                streamed_goto =
379                                                    Some((source.clone(), targets.clone()));
380                                            }
381                                            // As does a node asking to pause.
382                                            if let StreamEvent::NodeInterrupt {
383                                                ref message,
384                                                ref data,
385                                                ..
386                                            } = event
387                                            {
388                                                streamed_interrupt =
389                                                    Some(Interrupt::Dynamic {
390                                                        message: message.clone(),
391                                                        data: data.clone(),
392                                                    });
393                                            }
394                                            collected_events.push(event);
395                                        }
396                                        Some(Err(e)) => {
397                                            failure = Some(e);
398                                            break;
399                                        }
400                                        None => break,
401                                    }
402                                }
403                                drop(node_stream);
404
405                                if let Some(e) = failure {
406                                    yield Err(e);
407                                    return;
408                                }
409                                if timed_out_after.is_none() || attempt >= max_attempts {
410                                    break;
411                                }
412                            }
413
414                            if let Some(elapsed) = timed_out_after {
415                                let on_timeout =
416                                    policy.as_ref().map(|p| p.on_timeout.clone()).unwrap_or_default();
417                                match on_timeout {
418                                    OnTimeout::Skip => {
419                                        tracing::warn!(
420                                            node = %node_name,
421                                            elapsed = ?elapsed,
422                                            "node timed out while streaming, skipping"
423                                        );
424                                        streamed_updates.clear();
425                                    }
426                                    OnTimeout::Fail | OnTimeout::Retry { .. } => {
427                                        yield Err(GraphError::NodeTimedOut {
428                                            node: node_name.clone(),
429                                            elapsed,
430                                        });
431                                        return;
432                                    }
433                                }
434                            }
435
436                            let duration_ms = start.elapsed().as_millis() as u64;
437                            result.executed_nodes.push(node_name.clone());
438                            result.events.push(StreamEvent::node_end(node_name, self.step, duration_ms));
439                            result.events.extend(collected_events);
440
441                            if let Some((source, targets)) = streamed_goto {
442                                result.goto.insert(source, targets);
443                            }
444                            if let Some(interrupt) = streamed_interrupt {
445                                result.interrupt = Some(interrupt);
446                            }
447
448                            for updates in streamed_updates {
449                                self.ensure_channels_declared(
450                                    node_name,
451                                    updates.keys().map(String::as_str),
452                                )?;
453                                for (key, value) in updates {
454                                    self.graph.schema.apply_update(&mut self.state, &key, value);
455                                }
456                            }
457                        }
458                    }
459
460                    // Yield node_end events
461                    for event in &result.events {
462                        if matches!(event, StreamEvent::NodeEnd { .. }) {
463                            yield Ok(event.clone());
464                        }
465                    }
466
467                    // A node that arms a gate on completion stops the run here, unless
468                    // it already asked to pause itself.
469                    if result.interrupt.is_none()
470                        && let Some(interrupt) = self.gate_after(&result.executed_nodes)
471                    {
472                        result.interrupt = Some(interrupt);
473                    }
474
475                    // This branch returns rather than falling through to the shared
476                    // handling below, so the pause is reported here.
477                    if let Some(interrupt) = result.interrupt {
478                        if let Interrupt::Before(node) = &interrupt {
479                            self.cleared_interrupt = Some(node.clone());
480                        }
481                        // `After` resumes at the successors, because that node has
482                        // already applied its updates; see `run`.
483                        if matches!(interrupt, Interrupt::After(_)) {
484                            let next =
485                                self.next_frontier(&result.executed_nodes, &result.goto)?;
486                            match self.filter_deferred_nodes(next, &result.executed_nodes) {
487                                Ok(frontier) => self.pending_nodes = frontier,
488                                Err(error) => {
489                                    yield Err(error);
490                                    return;
491                                }
492                            }
493                        }
494                        // Persist before reporting: without this the pause cannot be
495                        // resumed and the work already done is lost.
496                        if let Err(error) = self.save_checkpoint().await {
497                            yield Err(error);
498                            return;
499                        }
500                        yield Ok(StreamEvent::interrupted(
501                            result.executed_nodes.first().map(|s| s.as_str()).unwrap_or("unknown"),
502                            &interrupt.to_string(),
503                        ));
504                        return;
505                    }
506
507                    // The gate re-arms once its node has run; see `run`.
508                    if let Some(cleared) = &self.cleared_interrupt
509                        && result.executed_nodes.iter().any(|n| n == cleared)
510                    {
511                        self.cleared_interrupt = None;
512                    }
513
514                    self.pending_nodes = {
515                        let next_candidates = self.next_frontier(&result.executed_nodes, &result.goto)?;
516                        match self.filter_deferred_nodes(next_candidates, &result.executed_nodes) {
517                            Ok(nodes) => nodes,
518                            Err(e) => {
519                                yield Err(e);
520                                return;
521                            }
522                        }
523                    };
524                    self.step += 1;
525
526                    // The other path checkpoints every super-step. This one did not,
527                    // so a run in this mode left no state to resume from and
528                    // `get_state` reported nothing.
529                    if let Err(e) = self.save_checkpoint().await {
530                        yield Err(e);
531                        return;
532                    }
533                    continue;
534                }
535
536                // Execute super-step (non-streaming)
537                let result = match self.execute_super_step().await {
538                    Ok(r) => r,
539                    Err(e) => {
540                        yield Err(e);
541                        return;
542                    }
543                };
544
545                // Yield events based on mode (node_end and custom events)
546                for event in &result.events {
547                    match (&mode, &event) {
548                        // Skip node_start since we already emitted it above
549                        (StreamMode::Custom | StreamMode::Debug, StreamEvent::NodeStart { .. }) => {}
550                        (StreamMode::Custom, _) => yield Ok(event.clone()),
551                        (StreamMode::Debug, _) => yield Ok(event.clone()),
552                        _ => {}
553                    }
554                }
555
556                // Yield state/updates
557                match mode {
558                    StreamMode::Values => {
559                        yield Ok(StreamEvent::state(self.state.clone(), self.step));
560                    }
561                    StreamMode::Updates => {
562                        yield Ok(StreamEvent::step_complete(
563                            self.step,
564                            result.executed_nodes.clone(),
565                        ));
566                    }
567                    _ => {}
568                }
569
570                // Handle interrupts
571                if let Some(interrupt) = result.interrupt {
572                    // Record the gate being answered; see `run`.
573                    if let Interrupt::Before(node) = &interrupt {
574                        self.cleared_interrupt = Some(node.clone());
575                    }
576                    // `After` resumes at the successors; see `run`.
577                    if matches!(interrupt, Interrupt::After(_)) {
578                        let next =
579                            self.next_frontier(&result.executed_nodes, &result.goto)?;
580                        match self.filter_deferred_nodes(next, &result.executed_nodes) {
581                            Ok(frontier) => self.pending_nodes = frontier,
582                            Err(error) => {
583                                yield Err(error);
584                                return;
585                            }
586                        }
587                    }
588                    // Persist before reporting: without this the interrupt is
589                    // unresumable, because resuming loads the checkpoint for the
590                    // thread. The frontier saved is the one that was executing,
591                    // since an interrupted node still owes its updates.
592                    if let Err(e) = self.save_checkpoint().await {
593                        yield Err(e);
594                        return;
595                    }
596                    yield Ok(StreamEvent::interrupted(
597                        result.executed_nodes.first().map(|s| s.as_str()).unwrap_or("unknown"),
598                        &interrupt.to_string(),
599                    ));
600                    return;
601                }
602
603                // The gate re-arms once its node has run; see `run`.
604                if let Some(cleared) = &self.cleared_interrupt
605                    && result.executed_nodes.iter().any(|n| n == cleared)
606                {
607                    self.cleared_interrupt = None;
608                }
609
610                // Advance the frontier before checkpointing, so the checkpoint
611                // records what still has to run rather than what just finished.
612                //
613                // Reported only on the debug stream, because building it evaluates
614                // each router a second time.
615                if matches!(mode, StreamMode::Debug) {
616                    match self.graph.route_dispatches(&result.executed_nodes, &self.state) {
617                        Ok(dispatches) => {
618                            for (source, targets) in dispatches {
619                                yield Ok(StreamEvent::route_dispatched(&source, targets));
620                            }
621                        }
622                        Err(error) => {
623                            yield Err(error);
624                            return;
625                        }
626                    }
627                }
628
629                self.pending_nodes = {
630                    let next_candidates = self.next_frontier(&result.executed_nodes, &result.goto)?;
631                    match self.filter_deferred_nodes(next_candidates, &result.executed_nodes) {
632                        Ok(nodes) => nodes,
633                        Err(e) => {
634                            yield Err(e);
635                            return;
636                        }
637                    }
638                };
639                self.step += 1;
640
641                if let Err(e) = self.save_checkpoint().await {
642                    yield Err(e);
643                    return;
644                }
645            }
646
647            yield Ok(StreamEvent::done(self.state.clone(), self.step + 1));
648        }
649    }
650
651    /// Filter deferred nodes from the next candidates.
652    ///
653    /// For each candidate node that is configured as deferred, check whether all
654    /// upstream paths have completed. If not, hold the node in `pending_deferred`
655    /// and record the outputs from the just-executed nodes. If all upstream paths
656    /// have completed, inject the merged output into state and allow the node to
657    /// proceed.
658    ///
659    /// If a deferred node has a `fan_in_timeout` configured and the timeout has
660    /// elapsed:
661    /// - If at least one upstream path has completed, proceed with partial results.
662    /// - If zero upstream paths have completed, return `GraphError::FanInTimedOut`.
663    fn filter_deferred_nodes(
664        &mut self,
665        candidates: Vec<String>,
666        executed_nodes: &[String],
667    ) -> Result<Vec<String>> {
668        let mut ready_nodes = Vec::new();
669
670        for candidate in candidates {
671            if let Some(config) = self.graph.deferred_configs.get(&candidate) {
672                // This is a deferred node — check if all upstream paths are done
673                let upstream = self.graph.get_upstream_nodes(&candidate);
674
675                // Get or create the tracker for this deferred node
676                let tracker = self.pending_deferred.entry(candidate.clone()).or_insert_with(|| {
677                    let sources: Vec<&str> = upstream.iter().map(|s| s.as_str()).collect();
678                    FanInTracker::new(sources)
679                });
680
681                // Record the start time if this is the first time we see this deferred node
682                self.deferred_start_times.entry(candidate.clone()).or_insert_with(Instant::now);
683
684                // Record outputs from the just-executed nodes that are upstream of this deferred node
685                for executed in executed_nodes {
686                    if upstream.contains(executed) {
687                        // Use the current state as the output representation for this upstream node.
688                        // We capture a snapshot of the state that this upstream node contributed to.
689                        let output = self.state.get(executed).cloned().unwrap_or_else(|| {
690                            // If no state key matches the node name, capture the full state
691                            serde_json::Value::Object(
692                                self.state.iter().map(|(k, v)| (k.clone(), v.clone())).collect(),
693                            )
694                        });
695                        tracker.record(executed, output);
696                    }
697                }
698
699                if tracker.is_ready() {
700                    // All upstream paths have completed — merge and inject into state
701                    let merged = tracker.merge(&config.merge_strategy);
702                    let fan_in_key = format!("{candidate}_fan_in");
703                    self.graph.schema.apply_update(&mut self.state, &fan_in_key, merged);
704
705                    // Remove from pending_deferred and start times since it's now ready
706                    self.pending_deferred.remove(&candidate);
707                    self.deferred_start_times.remove(&candidate);
708                    ready_nodes.push(candidate);
709                } else if let Some(timeout_duration) = config.fan_in_timeout {
710                    // Check if the fan-in timeout has elapsed
711                    let start_time = self.deferred_start_times[&candidate];
712                    if start_time.elapsed() >= timeout_duration {
713                        let received = tracker.received_count();
714                        let expected = tracker.expected_count();
715
716                        // `min_predecessors` decides how many arrivals are enough
717                        // to release the node once the timeout expires.
718                        let required = config.min_predecessors.unwrap_or(1).max(1);
719                        if received >= required {
720                            // Proceed with partial results
721                            tracing::warn!(
722                                node = %candidate,
723                                received,
724                                expected,
725                                "fan-in timeout expired, proceeding with partial results"
726                            );
727                            let merged = tracker.merge(&config.merge_strategy);
728                            let fan_in_key = format!("{candidate}_fan_in");
729                            self.graph.schema.apply_update(&mut self.state, &fan_in_key, merged);
730
731                            // Clean up tracking state
732                            self.pending_deferred.remove(&candidate);
733                            self.deferred_start_times.remove(&candidate);
734                            ready_nodes.push(candidate);
735                        } else {
736                            // Too few arrived to release the node.
737                            self.pending_deferred.remove(&candidate);
738                            self.deferred_start_times.remove(&candidate);
739                            return Err(GraphError::FanInTimedOut {
740                                node: candidate,
741                                received,
742                                expected,
743                            });
744                        }
745                    }
746                }
747                // If not ready and no timeout (or timeout not yet elapsed), the node stays
748                // in pending_deferred and is NOT added to ready_nodes
749            } else {
750                // Not a deferred node — schedule normally
751                ready_nodes.push(candidate);
752            }
753        }
754
755        Ok(ready_nodes)
756    }
757
758    /// Initialize state from input and/or checkpoint
759    async fn initialize_state(&self, input: State) -> Result<State> {
760        // Start with schema defaults
761        let mut state = self.graph.schema.initialize_state();
762
763        // If resuming from checkpoint, load it
764        if let Some(checkpoint_id) = &self.config.resume_from {
765            if let Some(cp) = self.graph.checkpointer.as_ref()
766                && let Some(checkpoint) = cp.load_by_id(checkpoint_id).await?
767            {
768                state = checkpoint.state;
769            }
770        } else if let Some(cp) = self.graph.checkpointer.as_ref() {
771            // Try to load latest checkpoint for thread
772            if let Some(checkpoint) = cp.load(&self.config.thread_id).await? {
773                state = checkpoint.state;
774            }
775        }
776
777        // Merge input into state
778        for (key, value) in input {
779            self.graph.schema.apply_update(&mut state, &key, value);
780        }
781
782        Ok(state)
783    }
784
785    /// Execute one super-step (plan -> execute -> update)
786    async fn execute_super_step(&mut self) -> Result<SuperStepResult> {
787        let mut result = SuperStepResult::default();
788
789        if let Some(interrupt) = self.gate_before(&self.pending_nodes) {
790            return Ok(SuperStepResult { interrupt: Some(interrupt), ..Default::default() });
791        }
792
793        // --- Node cache: check for cache hits before executing ---
794        #[cfg(feature = "node-cache")]
795        let mut cached_results: HashMap<String, serde_json::Value> = HashMap::new();
796        #[cfg(feature = "node-cache")]
797        let mut nodes_to_execute: Vec<String> = Vec::new();
798
799        #[cfg(feature = "node-cache")]
800        {
801            for node_name in &self.pending_nodes {
802                if let Some(cache) = self.node_caches.get(node_name) {
803                    let cache_key = compute_cache_key(node_name, &self.state);
804                    let cached_value = cache.get(&cache_key).await;
805                    tracing::debug!(
806                        node = %node_name,
807                        cache_hit = cached_value.is_some(),
808                        cache_key = %cache_key,
809                        "node cache lookup"
810                    );
811                    if let Some(value) = cached_value {
812                        // Cache hit — store the cached result for later application
813                        cached_results.insert(node_name.clone(), value);
814                    } else {
815                        // Cache miss — node needs execution
816                        nodes_to_execute.push(node_name.clone());
817                    }
818                } else {
819                    // No cache configured — node needs execution
820                    nodes_to_execute.push(node_name.clone());
821                }
822            }
823        }
824
825        // Apply cached results immediately
826        #[cfg(feature = "node-cache")]
827        {
828            for (node_name, cached_value) in &cached_results {
829                result.executed_nodes.push(node_name.clone());
830                result.events.push(StreamEvent::node_end(node_name, self.step, 0));
831
832                // Reconstruct updates from the cached JSON value (a map of key -> value)
833                if let Some(updates_map) = cached_value.as_object() {
834                    self.ensure_channels_declared(
835                        node_name,
836                        updates_map.keys().map(String::as_str),
837                    )?;
838                    for (key, value) in updates_map {
839                        self.graph.schema.apply_update(&mut self.state, key, value.clone());
840                    }
841                }
842            }
843        }
844
845        // Determine which nodes to execute (all if cache feature is disabled).
846        //
847        // Sorted so that a bounded dispatch admits nodes in a fixed order rather
848        // than whatever order the frontier happened to be built in.
849        #[cfg(feature = "node-cache")]
850        let pending_for_execution = {
851            nodes_to_execute.sort();
852            &nodes_to_execute
853        };
854        #[cfg(not(feature = "node-cache"))]
855        let pending_for_execution = {
856            self.pending_nodes.sort();
857            &self.pending_nodes
858        };
859
860        // Execute all pending nodes in parallel
861        let nodes: Vec<_> = pending_for_execution
862            .iter()
863            .filter_map(|name| self.graph.nodes.get(name).map(|n| (name.clone(), n.clone())))
864            .collect();
865
866        // Look up timeout and retry policies for each node before spawning futures
867        let timeout_policies: Vec<_> =
868            nodes.iter().map(|(name, _)| self.graph.timeout_policy_for(name).cloned()).collect();
869        let retry_policies: Vec<_> =
870            nodes.iter().map(|(name, _)| self.graph.retry_policy_for(name).cloned()).collect();
871        // Attempts already spent, so a resumed run continues its budget rather
872        // than starting again. adk-python does not persist this.
873        let prior_attempts: Vec<u32> =
874            nodes.iter().map(|(name, _)| self.attempts.get(name).copied().unwrap_or(0)).collect();
875
876        let futures: Vec<_> = nodes
877            .into_iter()
878            .zip(timeout_policies)
879            .zip(retry_policies)
880            .zip(prior_attempts)
881            .map(|((((name, node), policy), retry), spent)| {
882                let mut ctx = NodeContext::new(self.state.clone(), self.config.clone(), self.step);
883                // A node body may invoke other nodes. The invoker carries the
884                // graph's nodes and the shared ledger, so a resumed parent serves
885                // children that already finished. These invocations are awaited
886                // inline by the parent and are deliberately outside the
887                // concurrency budget: counting them could deadlock, because the
888                // parent holds its own slot while waiting.
889                ctx.set_parent_schema(Arc::new(self.graph.schema.clone()));
890                ctx.set_child_invoker(Arc::new(crate::child::ChildInvoker::new(
891                    self.graph.nodes.clone(),
892                    Arc::clone(&self.child_ledger),
893                    name.clone(),
894                )));
895
896                // Attach a ProgressHandle when idle timeout is configured
897                if let Some(ref p) = policy
898                    && p.idle_timeout.is_some()
899                {
900                    ctx.set_progress_handle(ProgressHandle::new());
901                }
902
903                let step = self.step;
904                async move {
905                    let start = Instant::now();
906                    let mut attempts = spent;
907                    let output = loop {
908                        let result = match policy {
909                            Some(ref timeout_policy) => {
910                                execute_with_timeout(node.as_ref(), &ctx, timeout_policy).await
911                            }
912                            None => node.execute(&ctx).await,
913                        };
914                        attempts += 1;
915
916                        let Err(ref error) = result else { break result };
917                        let Some(ref retry) = retry else { break result };
918                        if !retry.allows_another_attempt(attempts)
919                            || !retry.retry_on.should_retry(error)
920                        {
921                            break result;
922                        }
923
924                        let delay = retry.delay_for_attempt(attempts);
925                        tracing::warn!(
926                            node = %name,
927                            attempt = attempts,
928                            max_attempts = retry.max_attempts,
929                            delay_ms = delay.as_millis(),
930                            error = %error,
931                            "node failed, retrying after backoff"
932                        );
933                        tokio::time::sleep(delay).await;
934                    };
935                    let duration_ms = start.elapsed().as_millis() as u64;
936                    (name, output, duration_ms, step, attempts)
937                }
938            })
939            .collect();
940
941        // Bound the dispatch. `buffer_unordered` polls futures in the order they
942        // are produced, and the frontier is sorted above, so admission order does
943        // not depend on which node finished first.
944        let concurrency = self
945            .graph
946            .max_concurrency
947            .map_or(pending_for_execution.len(), |limit| limit.min(pending_for_execution.len()))
948            .max(1);
949        let outputs: Vec<_> = stream::iter(futures).buffer_unordered(concurrency).collect().await;
950
951        // Collect all updates and check for errors/interrupts
952        let mut all_updates = Vec::new();
953
954        for (node_name, output_result, duration_ms, step, attempts) in outputs {
955            // Record the budget spent, so a resumed run does not restart it. A
956            // node that finally succeeded keeps no entry: its budget is spent
957            // only while it is failing.
958            if output_result.is_err() {
959                self.attempts.insert(node_name.clone(), attempts);
960            } else {
961                self.attempts.remove(&node_name);
962            }
963            result.executed_nodes.push(node_name.clone());
964            result.events.push(StreamEvent::node_end(&node_name, step, duration_ms));
965
966            match output_result {
967                Ok(output) => {
968                    // Check for dynamic interrupt
969                    if let Some(interrupt) = output.interrupt {
970                        return Ok(SuperStepResult {
971                            interrupt: Some(interrupt),
972                            executed_nodes: result.executed_nodes,
973                            events: result.events,
974                            goto: result.goto,
975                        });
976                    }
977
978                    // Collect custom events
979                    result.events.extend(output.events);
980
981                    // Store result in cache on miss
982                    #[cfg(feature = "node-cache")]
983                    {
984                        if let Some(cache) = self.node_caches.get(&node_name) {
985                            let cache_key = compute_cache_key(&node_name, &self.state);
986                            let updates_value = serde_json::to_value(&output.updates)
987                                .unwrap_or(serde_json::Value::Object(serde_json::Map::new()));
988                            let ttl = self.graph.cache_policies.get(&node_name).and_then(|p| p.ttl);
989                            cache.set(&cache_key, updates_value, ttl).await;
990                        }
991                    }
992
993                    // A node that named its successors overrides its declared edges.
994                    if let Some(targets) = output.goto {
995                        result.goto.insert(node_name.clone(), targets);
996                    }
997                    // A node handing control to the graph that holds this one. The
998                    // run finishes normally; the caller reads this from the outcome.
999                    if let Some(targets) = output.goto_parent {
1000                        self.goto_parent = Some(targets);
1001                    }
1002
1003                    // Collect updates with their node, so application order can be
1004                    // made independent of which future resolved first.
1005                    all_updates.push((node_name.clone(), output.updates));
1006                }
1007                Err(e) => {
1008                    // The retry budget is spent. A handler may record what
1009                    // happened and name a recovery node instead of ending the run.
1010                    // An interrupt never reaches here as a failure.
1011                    match self.graph.error_handler_for(&node_name) {
1012                        Some(handler) if !matches!(e, GraphError::Interrupted(_)) => {
1013                            let recovery = handler(&node_name, &e, &self.state)?;
1014                            if let Some(targets) = recovery.goto {
1015                                result.goto.insert(node_name.clone(), targets);
1016                            }
1017                            result.executed_nodes.push(node_name.clone());
1018                            all_updates.push((node_name, recovery.updates));
1019                        }
1020                        _ => {
1021                            return Err(GraphError::NodeExecutionFailed {
1022                                node: node_name,
1023                                message: e.to_string(),
1024                            });
1025                        }
1026                    }
1027                }
1028            }
1029        }
1030
1031        // Apply all updates atomically using reducers.
1032        //
1033        // `buffer_unordered` yields futures as they resolve, so the collected
1034        // order follows timing. A non-commutative reducer — `Append` builds an
1035        // array, so order is the result — would then give a different state for
1036        // the same input depending on which node finished first. Sorting by
1037        // (node, channel) makes the order total and timing-independent: node
1038        // names are unique within a graph, and a node's own updates are held in a
1039        // map whose iteration order is itself unspecified.
1040        all_updates.sort_by(|(left, _), (right, _)| left.cmp(right));
1041        for (node, updates) in all_updates {
1042            let mut keys: Vec<_> = updates.keys().cloned().collect();
1043            keys.sort();
1044            self.ensure_channels_declared(&node, keys.iter().map(String::as_str))?;
1045            for key in keys {
1046                if let Some(value) = updates.get(&key) {
1047                    self.graph.schema.apply_update(&mut self.state, &key, value.clone());
1048                }
1049            }
1050        }
1051
1052        if let Some(interrupt) = self.gate_after(&result.executed_nodes) {
1053            return Ok(SuperStepResult { interrupt: Some(interrupt), ..result });
1054        }
1055
1056        Ok(result)
1057    }
1058
1059    /// Save a checkpoint
1060    /// Returns the gate a pending node arms, if any.
1061    ///
1062    /// A node whose gate this run has already answered runs instead of
1063    /// interrupting again; without that a resume reaches the same conclusion and
1064    /// the node never executes.
1065    ///
1066    /// Shared by both execution paths. `StreamMode::Messages` runs nodes in its
1067    /// own loop, and when this check lived only in `execute_super_step` that mode
1068    /// ignored every gate.
1069    fn gate_before(&self, pending: &[String]) -> Option<Interrupt> {
1070        pending
1071            .iter()
1072            .find(|node| {
1073                self.graph.interrupt_before.contains(*node)
1074                    && self.cleared_interrupt.as_deref() != Some(node.as_str())
1075            })
1076            .map(|node| Interrupt::Before(node.clone()))
1077    }
1078
1079    /// Returns the gate an executed node arms, if any.
1080    fn gate_after(&self, executed: &[String]) -> Option<Interrupt> {
1081        executed
1082            .iter()
1083            .find(|node| self.graph.interrupt_after.contains(*node))
1084            .map(|node| Interrupt::After(node.clone()))
1085    }
1086
1087    /// Computes the next frontier, letting a node's `goto` stand in for its edges.
1088    ///
1089    /// A node that named successors has its declared edges skipped, so a `goto`
1090    /// replaces an edge rather than adding to one. `END` is accepted and
1091    /// contributes no successor, which is how a branch stops.
1092    ///
1093    /// # Errors
1094    ///
1095    /// Returns [`GraphError::UnknownRouteTarget`] when a `goto` names a node the
1096    /// graph does not hold.
1097    fn next_frontier(
1098        &self,
1099        executed: &[String],
1100        goto: &HashMap<String, Vec<String>>,
1101    ) -> Result<Vec<String>> {
1102        // A node that routed itself does not also follow its declared edges.
1103        let followed_edges: Vec<String> =
1104            executed.iter().filter(|node| !goto.contains_key(*node)).cloned().collect();
1105        let mut next = self.graph.get_next_nodes(&followed_edges, &self.state)?;
1106
1107        // Sorted, so a multi-target goto admits its nodes in a fixed order.
1108        let mut routed: Vec<(&String, &Vec<String>)> = goto.iter().collect();
1109        routed.sort_by_key(|(node, _)| node.as_str());
1110
1111        for (node, targets) in routed {
1112            for target in targets {
1113                if target == crate::edge::END {
1114                    continue;
1115                }
1116                if self.graph.node(target).is_none() {
1117                    return Err(GraphError::UnknownRouteTarget(format!(
1118                        "node '{node}' routed to '{target}', which is not a node in this graph"
1119                    )));
1120                }
1121                if !next.contains(target) {
1122                    next.push(target.clone());
1123                }
1124            }
1125        }
1126        Ok(next)
1127    }
1128
1129    /// Rejects an update naming a channel the schema does not declare.
1130    ///
1131    /// Inert unless the graph asked for enforcement, and inert when the schema
1132    /// declares no channels, so an existing graph is unaffected either way.
1133    fn ensure_channels_declared<'k>(
1134        &self,
1135        node: &str,
1136        keys: impl IntoIterator<Item = &'k str>,
1137    ) -> Result<()> {
1138        if !self.graph.strict_channels {
1139            return Ok(());
1140        }
1141        match self.graph.schema.first_undeclared(keys) {
1142            Some(channel) => Err(GraphError::UndeclaredChannel {
1143                node: node.to_string(),
1144                channel: channel.to_string(),
1145            }),
1146            None => Ok(()),
1147        }
1148    }
1149
1150    async fn save_checkpoint(&self) -> Result<String> {
1151        if let Some(cp) = &self.graph.checkpointer {
1152            let mut checkpoint = Checkpoint::new(
1153                &self.config.thread_id,
1154                self.state.clone(),
1155                self.step,
1156                self.pending_nodes.clone(),
1157            );
1158            checkpoint.cleared_interrupt = self.cleared_interrupt.clone();
1159            checkpoint.attempts = self.attempts.clone();
1160            checkpoint.child_ledger = self.child_ledger.lock().expect("child ledger").clone();
1161            let id = cp.save(&checkpoint).await?;
1162
1163            // Trimmed as the run proceeds, so the cost stays proportional to the run
1164            // and no external job is needed. After the save, so the newest counts.
1165            if let Some(policy) = &self.graph.retention {
1166                let removed = cp.prune(&self.config.thread_id, policy).await?;
1167                if removed > 0 {
1168                    tracing::debug!(
1169                        thread_id = %self.config.thread_id,
1170                        removed,
1171                        "pruned old checkpoints"
1172                    );
1173                }
1174            }
1175            return Ok(id);
1176        }
1177        Ok(String::new())
1178    }
1179}
1180
1181/// Convenience methods for CompiledGraph
1182impl CompiledGraph {
1183    /// Execute the graph synchronously
1184    pub async fn invoke(&self, input: State, config: ExecutionConfig) -> Result<State> {
1185        self.invoke_detailed(input, config).await.map(|outcome| outcome.state)
1186    }
1187
1188    /// Executes and reports what the run asked of its caller.
1189    ///
1190    /// Only a graph run as a [`SubgraphNode`](crate::subgraph::SubgraphNode) has
1191    /// anything to report beyond its state, so [`Self::invoke`] is the usual
1192    /// entry point.
1193    pub async fn invoke_detailed(
1194        &self,
1195        input: State,
1196        config: ExecutionConfig,
1197    ) -> Result<GraphOutcome> {
1198        let mut executor = PregelExecutor::new(self, config);
1199        let state = executor.run(input).await?;
1200        Ok(GraphOutcome { state, goto_parent: executor.goto_parent })
1201    }
1202
1203    /// Execute with streaming
1204    pub fn stream(
1205        &self,
1206        input: State,
1207        config: ExecutionConfig,
1208        mode: StreamMode,
1209    ) -> impl futures::Stream<Item = Result<StreamEvent>> + '_ {
1210        tracing::debug!("CompiledGraph::stream called with mode {:?}", mode);
1211        let executor = PregelExecutor::new(self, config);
1212        executor.run_stream(input, mode)
1213    }
1214
1215    /// Get current state for a thread
1216    pub async fn get_state(&self, thread_id: &str) -> Result<Option<State>> {
1217        if let Some(cp) = &self.checkpointer {
1218            Ok(cp.load(thread_id).await?.map(|c| c.state))
1219        } else {
1220            Ok(None)
1221        }
1222    }
1223
1224    /// Update state for a thread (for human-in-the-loop)
1225    pub async fn update_state(
1226        &self,
1227        thread_id: &str,
1228        updates: impl IntoIterator<Item = (String, serde_json::Value)>,
1229    ) -> Result<()> {
1230        if let Some(cp) = &self.checkpointer
1231            && let Some(checkpoint) = cp.load(thread_id).await?
1232        {
1233            let mut state = checkpoint.state;
1234            for (key, value) in updates {
1235                self.schema.apply_update(&mut state, &key, value);
1236            }
1237            let new_checkpoint =
1238                Checkpoint::new(thread_id, state, checkpoint.step, checkpoint.pending_nodes);
1239            cp.save(&new_checkpoint).await?;
1240        }
1241        Ok(())
1242    }
1243}
1244
1245#[cfg(test)]
1246mod tests {
1247    use super::*;
1248    use crate::edge::{END, START};
1249    use crate::graph::StateGraph;
1250    use crate::node::NodeOutput;
1251    use serde_json::json;
1252
1253    #[tokio::test]
1254    async fn test_simple_execution() {
1255        let graph = StateGraph::with_channels(&["value"])
1256            .add_node_fn("set_value", |_ctx| async {
1257                Ok(NodeOutput::new().with_update("value", json!(42)))
1258            })
1259            .add_edge(START, "set_value")
1260            .add_edge("set_value", END)
1261            .compile()
1262            .unwrap();
1263
1264        let result = graph.invoke(State::new(), ExecutionConfig::new("test")).await.unwrap();
1265
1266        assert_eq!(result.get("value"), Some(&json!(42)));
1267    }
1268
1269    #[tokio::test]
1270    async fn test_sequential_execution() {
1271        let graph = StateGraph::with_channels(&["value"])
1272            .add_node_fn("step1", |_ctx| async {
1273                Ok(NodeOutput::new().with_update("value", json!(1)))
1274            })
1275            .add_node_fn("step2", |ctx| async move {
1276                let current = ctx.get("value").and_then(|v| v.as_i64()).unwrap_or(0);
1277                Ok(NodeOutput::new().with_update("value", json!(current + 10)))
1278            })
1279            .add_edge(START, "step1")
1280            .add_edge("step1", "step2")
1281            .add_edge("step2", END)
1282            .compile()
1283            .unwrap();
1284
1285        let result = graph.invoke(State::new(), ExecutionConfig::new("test")).await.unwrap();
1286
1287        assert_eq!(result.get("value"), Some(&json!(11)));
1288    }
1289
1290    #[tokio::test]
1291    async fn test_conditional_routing() {
1292        let graph = StateGraph::with_channels(&["path", "result"])
1293            .add_node_fn("router", |ctx| async move {
1294                let path = ctx.get("path").and_then(|v| v.as_str()).unwrap_or("a");
1295                Ok(NodeOutput::new().with_update("route", json!(path)))
1296            })
1297            .add_node_fn("path_a", |_ctx| async {
1298                Ok(NodeOutput::new().with_update("result", json!("went to A")))
1299            })
1300            .add_node_fn("path_b", |_ctx| async {
1301                Ok(NodeOutput::new().with_update("result", json!("went to B")))
1302            })
1303            .add_edge(START, "router")
1304            .add_conditional_edges(
1305                "router",
1306                |state| state.get("route").and_then(|v| v.as_str()).unwrap_or(END).to_string(),
1307                [("a", "path_a"), ("b", "path_b"), (END, END)],
1308            )
1309            .add_edge("path_a", END)
1310            .add_edge("path_b", END)
1311            .compile()
1312            .unwrap();
1313
1314        // Test path A
1315        let mut input = State::new();
1316        input.insert("path".to_string(), json!("a"));
1317        let result = graph.invoke(input, ExecutionConfig::new("test")).await.unwrap();
1318        assert_eq!(result.get("result"), Some(&json!("went to A")));
1319
1320        // Test path B
1321        let mut input = State::new();
1322        input.insert("path".to_string(), json!("b"));
1323        let result = graph.invoke(input, ExecutionConfig::new("test")).await.unwrap();
1324        assert_eq!(result.get("result"), Some(&json!("went to B")));
1325    }
1326
1327    #[tokio::test]
1328    async fn test_cycle_with_limit() {
1329        let graph = StateGraph::with_channels(&["count"])
1330            .add_node_fn("increment", |ctx| async move {
1331                let count = ctx.get("count").and_then(|v| v.as_i64()).unwrap_or(0);
1332                Ok(NodeOutput::new().with_update("count", json!(count + 1)))
1333            })
1334            .add_edge(START, "increment")
1335            .add_conditional_edges(
1336                "increment",
1337                |state| {
1338                    let count = state.get("count").and_then(|v| v.as_i64()).unwrap_or(0);
1339                    if count < 5 { "increment".to_string() } else { END.to_string() }
1340                },
1341                [("increment", "increment"), (END, END)],
1342            )
1343            .compile()
1344            .unwrap();
1345
1346        let result = graph.invoke(State::new(), ExecutionConfig::new("test")).await.unwrap();
1347
1348        assert_eq!(result.get("count"), Some(&json!(5)));
1349    }
1350
1351    #[tokio::test]
1352    async fn test_recursion_limit() {
1353        let graph = StateGraph::with_channels(&["count"])
1354            .add_node_fn("loop", |ctx| async move {
1355                let count = ctx.get("count").and_then(|v| v.as_i64()).unwrap_or(0);
1356                Ok(NodeOutput::new().with_update("count", json!(count + 1)))
1357            })
1358            .add_edge(START, "loop")
1359            .add_edge("loop", "loop") // Infinite loop
1360            .compile()
1361            .unwrap()
1362            .with_recursion_limit(10);
1363
1364        let result = graph.invoke(State::new(), ExecutionConfig::new("test")).await;
1365
1366        // The recursion limit check happens when step >= limit, so it will exceed at step 10
1367        assert!(
1368            matches!(result, Err(GraphError::RecursionLimitExceeded(_))),
1369            "Expected RecursionLimitExceeded error, got: {:?}",
1370            result
1371        );
1372    }
1373}