Skip to main content

ri_agent_graph/
graph.rs

1#![allow(deprecated)] // Constructs GraphEvent with legacy trace_id/attempt fields during migration
2
3use crate::checkpoint::Checkpoint;
4use crate::checkpoint_store::CheckpointStore;
5use crate::checkpointer::CheckpointSaver;
6use crate::command::NodeOutput;
7use crate::config::GraphConfig;
8use crate::edge::EdgeType;
9use crate::error::{AgentGraphError, Result};
10use crate::event_sink::{ChannelEventSink, EventSink, NoopEventSink};
11use crate::executor::Executor;
12use crate::interrupt::InterruptConfig;
13use crate::node::Node;
14use crate::reducer::Reducer;
15use crate::retry::RetryPolicy;
16use crate::state::AgentState;
17use crate::stream::StreamEvent;
18use serde_json::Value;
19use std::collections::HashMap;
20use std::sync::Arc;
21use tokio::sync::mpsc;
22
23// Re-export the builder so `crate::graph::AgentGraphBuilder` still works.
24pub use crate::builder::AgentGraphBuilder;
25
26/// Virtual start node name.
27pub const START: &str = "__start__";
28/// Virtual end node name.
29pub const END: &str = "__end__";
30
31/// The agent graph — an orchestrator that owns control-flow and delegates
32/// node work to the Payload layer.
33pub struct AgentGraph {
34    pub(crate) nodes: HashMap<String, Arc<dyn Node>>,
35    pub(crate) edges: HashMap<String, Vec<EdgeType>>,
36    pub(crate) max_iterations: usize,
37    pub(crate) enable_cycle_detection: bool,
38    pub(crate) retry_policies: HashMap<String, RetryPolicy>,
39    pub(crate) interrupt_config: Option<InterruptConfig>,
40    // Legacy checkpointer (superstep-level)
41    pub(crate) checkpointer: Option<Arc<dyn CheckpointSaver>>,
42    // New granular checkpoint store (per-attempt)
43    pub(crate) checkpoint_store: Option<Arc<dyn CheckpointStore>>,
44    pub(crate) reducers: Vec<(String, Arc<dyn Reducer>)>,
45    pub(crate) graph_name: Option<String>,
46    // New abstractions
47    pub(crate) event_sink: Option<Arc<dyn EventSink>>,
48    pub(crate) executor: Option<Arc<dyn Executor>>,
49}
50
51impl AgentGraph {
52    /// Create a new graph builder.
53    pub fn builder() -> AgentGraphBuilder {
54        AgentGraphBuilder::new()
55    }
56
57    // ── Internal helpers used by both graph.rs and engine.rs ──
58
59    pub(crate) async fn register_reducers_on_state(&self, state: &AgentState) {
60        let mut state_reducers = state.reducers.write().await;
61        for (key, reducer) in &self.reducers {
62            state_reducers.insert(key.clone(), reducer.clone());
63        }
64    }
65
66    /// Resolve the event sink to use for this execution.
67    pub(crate) fn resolve_event_sink(
68        &self,
69        stream_tx: Option<mpsc::Sender<StreamEvent>>,
70    ) -> Arc<dyn EventSink> {
71        if let Some(tx) = stream_tx {
72            // Streaming path: wrap the channel
73            if let Some(ref configured_sink) = self.event_sink {
74                // Both configured sink and channel: composite
75                Arc::new(crate::event_sink::CompositeEventSink::new(vec![
76                    configured_sink.clone(),
77                    Arc::new(ChannelEventSink::new(tx)),
78                ]))
79            } else {
80                Arc::new(ChannelEventSink::new(tx))
81            }
82        } else if let Some(ref sink) = self.event_sink {
83            sink.clone()
84        } else {
85            Arc::new(NoopEventSink)
86        }
87    }
88
89    /// Create a run ID from a configured checkpoint store, or locally when no store is configured.
90    ///
91    /// A configured store is a durable-execution contract: its creation failure
92    /// is returned to the caller rather than silently degrading to a UUID.
93    pub(crate) async fn create_run_id(&self) -> Result<String> {
94        if let Some(ref store) = self.checkpoint_store {
95            let name = self.graph_name.as_deref().unwrap_or("unnamed");
96            store
97                .create_run(name)
98                .await
99                .map_err(|error| AgentGraphError::CheckpointStore {
100                    operation: crate::error::CheckpointStoreOperation::CreateRun,
101                    message: error.to_string(),
102                })
103        } else {
104            Ok(stack_ids::GraphRunId::random("agent-graph").to_string())
105        }
106    }
107
108    /// Resume execution from an interrupt checkpoint.
109    ///
110    /// Validates that the graph topology hasn't changed since the checkpoint
111    /// was taken. Returns `CheckpointMismatch` if the graph hash differs.
112    /// Use [`Self::resume_force`] to skip this check.
113    pub async fn resume(
114        &self,
115        state: AgentState,
116        config: GraphConfig,
117        checkpoint: crate::interrupt::InterruptCheckpoint,
118    ) -> Result<AgentState> {
119        if let Some(ref saved_hash) = checkpoint.graph_hash {
120            let current_hash = self.compute_graph_hash();
121            if *saved_hash != current_hash {
122                return Err(AgentGraphError::CheckpointMismatch {
123                    expected: saved_hash.clone(),
124                    actual: current_hash,
125                });
126            }
127        }
128        self.execute_with_config(&checkpoint.resume_node, state, config)
129            .await
130    }
131
132    /// Resume execution from an interrupt checkpoint without validating graph topology.
133    pub async fn resume_force(
134        &self,
135        state: AgentState,
136        config: GraphConfig,
137        checkpoint: crate::interrupt::InterruptCheckpoint,
138    ) -> Result<AgentState> {
139        self.execute_with_config(&checkpoint.resume_node, state, config)
140            .await
141    }
142
143    /// Get current state from checkpointer.
144    pub async fn get_state(&self, config: &GraphConfig) -> Result<Option<AgentState>> {
145        if let (Some(checkpointer), Some(thread_id)) = (&self.checkpointer, &config.thread_id) {
146            if let Some(cp) = checkpointer.load(thread_id).await? {
147                let state = AgentState::new();
148                state.restore(&cp.state).await;
149                return Ok(Some(state));
150            }
151        }
152        Ok(None)
153    }
154
155    /// Get checkpoint history for a thread.
156    pub async fn get_state_history(&self, config: &GraphConfig) -> Result<Vec<Checkpoint>> {
157        if let (Some(checkpointer), Some(thread_id)) = (&self.checkpointer, &config.thread_id) {
158            return checkpointer.load_history(thread_id).await;
159        }
160        Ok(Vec::new())
161    }
162
163    /// Update state in the checkpointer (time travel).
164    pub async fn update_state(
165        &self,
166        config: &GraphConfig,
167        updates: HashMap<String, Value>,
168    ) -> Result<()> {
169        if let (Some(checkpointer), Some(thread_id)) = (&self.checkpointer, &config.thread_id) {
170            if let Some(mut cp) = checkpointer.load(thread_id).await? {
171                for (k, v) in updates {
172                    cp.state.data.insert(k, v);
173                }
174                checkpointer.save(&cp).await?;
175            }
176        }
177        Ok(())
178    }
179
180    /// Generate a Mermaid diagram of the graph structure.
181    pub fn to_mermaid(&self) -> String {
182        let mut lines = vec!["graph TD".to_string()];
183        lines.push(format!("    {}([START])", START));
184        lines.push(format!("    {}([END])", END));
185
186        let mut node_names: Vec<&String> = self.nodes.keys().collect();
187        node_names.sort();
188        for name in &node_names {
189            lines.push(format!("    {0}[{0}]", name));
190        }
191
192        let mut edge_sources: Vec<&String> = self.edges.keys().collect();
193        edge_sources.sort();
194        for from in edge_sources {
195            if let Some(edge_list) = self.edges.get(from) {
196                for edge in edge_list {
197                    match edge {
198                        EdgeType::Normal(to) => {
199                            lines.push(format!("    {} --> {}", from, to));
200                        }
201                        EdgeType::Conditional(_) => {
202                            lines.push(format!("    {} -.->|condition| ?", from));
203                        }
204                    }
205                }
206            }
207        }
208
209        lines.join("\n")
210    }
211
212    /// Get the graph name.
213    pub fn name(&self) -> Option<&str> {
214        self.graph_name.as_deref()
215    }
216
217    /// Get the node names.
218    pub fn node_names(&self) -> Vec<&String> {
219        self.nodes.keys().collect()
220    }
221
222    /// Get the edge map for inspection.
223    pub fn edge_map(&self) -> &HashMap<String, Vec<EdgeType>> {
224        &self.edges
225    }
226
227    /// Compute a stable hash of the graph's topology (node names + edges).
228    ///
229    /// Used to detect graph-definition drift when resuming from a checkpoint.
230    /// Two graphs with the same nodes and edges produce the same hash.
231    pub fn compute_graph_hash(&self) -> String {
232        use std::collections::BTreeMap;
233        use std::hash::{Hash, Hasher};
234
235        let mut hasher = std::hash::DefaultHasher::new();
236
237        // Sort node names for determinism
238        let mut sorted_nodes: Vec<&String> = self.nodes.keys().collect();
239        sorted_nodes.sort();
240        for name in &sorted_nodes {
241            name.hash(&mut hasher);
242        }
243
244        // Sort edges by source node for determinism
245        let sorted_edges: BTreeMap<&String, &Vec<EdgeType>> = self.edges.iter().collect();
246        for (from, edges) in &sorted_edges {
247            from.hash(&mut hasher);
248            for edge in *edges {
249                match edge {
250                    EdgeType::Normal(to) => {
251                        "normal".hash(&mut hasher);
252                        to.hash(&mut hasher);
253                    }
254                    EdgeType::Conditional(router) => {
255                        "conditional".hash(&mut hasher);
256                        from.hash(&mut hasher);
257                        router.semantic_digest().hash(&mut hasher);
258                    }
259                }
260            }
261        }
262
263        format!("{:016x}", hasher.finish())
264    }
265}
266
267// Implement Node for AgentGraph to enable subgraph support.
268#[async_trait::async_trait]
269impl Node for AgentGraph {
270    async fn execute(&self, state: &AgentState, config: &GraphConfig) -> Result<NodeOutput> {
271        let subgraph_state = state.fork().await;
272
273        let start = if self.edges.contains_key(START) {
274            START
275        } else {
276            let all_targets: std::collections::HashSet<&str> = self
277                .edges
278                .values()
279                .flat_map(|edges| {
280                    edges.iter().filter_map(|e| match e {
281                        EdgeType::Normal(to) => Some(to.as_str()),
282                        _ => None,
283                    })
284                })
285                .collect();
286            let entry = self
287                .nodes
288                .keys()
289                .find(|n| !all_targets.contains(n.as_str()))
290                .ok_or_else(|| {
291                    AgentGraphError::ExecutionError("Subgraph has no entry point".to_string())
292                })?;
293            entry.as_str()
294        };
295
296        let result = self
297            .execute_with_config(start, subgraph_state, config.clone())
298            .await?;
299
300        let result_data = result.export().await;
301        for (key, value) in result_data {
302            state.set(&key, value).await?;
303        }
304
305        Ok(NodeOutput::Done)
306    }
307
308    fn name(&self) -> Option<&str> {
309        self.graph_name.as_deref()
310    }
311}
312
313impl std::fmt::Debug for AgentGraph {
314    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
315        f.debug_struct("AgentGraph")
316            .field("nodes", &self.nodes.keys().collect::<Vec<_>>())
317            .field("edges", &format!("{} edge groups", self.edges.len()))
318            .field("max_iterations", &self.max_iterations)
319            .finish()
320    }
321}