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