Skip to main content

agent_graph_mcp/
compiler.rs

1use std::sync::{atomic::AtomicBool, Arc, Mutex};
2
3use ri_agent_graph::event_sink::{EventSink, GraphEvent};
4use ri_agent_graph::join::JoinNode;
5use ri_agent_graph::reducer::{AddReducer, AppendReducer, LastWriteWins, MergeReducer};
6use ri_agent_graph::retry::RetryPolicy;
7use ri_agent_graph::AgentGraph;
8use tokio::sync::Notify;
9
10use crate::nodes::{
11    legacy_router, HumanApprovalNode, LlmNode, PassthroughNode, RouterConfig, RouterNode,
12    RunContext, TransformConfig, TransformNode,
13};
14use crate::spec::{GraphSpec, NodeType, ReducerKind};
15
16pub struct CompileContext {
17    pub base_url: String,
18    pub default_model: String,
19    pub cancelled: Arc<AtomicBool>,
20    pub cancellation: Arc<Notify>,
21    pub events: Arc<Mutex<Vec<GraphEvent>>>,
22}
23
24struct Collector(Arc<Mutex<Vec<GraphEvent>>>);
25impl EventSink for Collector {
26    fn emit(&self, event: GraphEvent) {
27        if let Ok(mut events) = self.0.lock() {
28            if events.len() < 2048 {
29                events.push(event);
30            }
31        }
32    }
33}
34
35pub fn compile(spec: &GraphSpec, cx: CompileContext) -> Result<AgentGraph, String> {
36    let run = RunContext {
37        cancelled: cx.cancelled,
38        cancellation: cx.cancellation,
39    };
40    let mut builder = AgentGraph::builder()
41        .with_name(&spec.name)
42        .with_max_iterations(spec.max_iterations.unwrap_or(64))
43        .with_cycle_detection(false)
44        .with_event_sink(Arc::new(Collector(cx.events)));
45    for node in &spec.nodes {
46        GraphSpec::executable_node_type(&node.node_type)
47            .map_err(|error| format!("node '{}': {error}", node.id))?;
48        let boxed: Box<dyn ri_agent_graph::node::Node> = match node.node_type {
49            NodeType::Passthrough => Box::new(PassthroughNode { ctx: run.clone() }),
50            NodeType::Llm => {
51                let input_key = node
52                    .config
53                    .get("input_key")
54                    .and_then(|v| v.as_str())
55                    .unwrap_or("__input__")
56                    .to_owned();
57                let output_key = node
58                    .config
59                    .get("output_key")
60                    .and_then(|v| v.as_str())
61                    .unwrap_or("__input__")
62                    .to_owned();
63                Box::new(LlmNode {
64                    id: node.id.clone(),
65                    base_url: cx.base_url.clone(),
66                    default_model: cx.default_model.clone(),
67                    prompt: node
68                        .prompt
69                        .clone()
70                        .or_else(|| {
71                            node.config
72                                .get("prompt")
73                                .and_then(|v| v.as_str())
74                                .map(str::to_owned)
75                        })
76                        .unwrap_or_else(|| "{input}".into()),
77                    model: node.model.clone(),
78                    json_mode: node.json_mode,
79                    evidence_required: node.evidence_required,
80                    max_tokens: node.max_tokens,
81                    timeout_ms: node
82                        .config
83                        .get("timeout_ms")
84                        .and_then(|v| v.as_u64())
85                        .unwrap_or(120_000),
86                    input_key,
87                    output_key,
88                    ctx: run.clone(),
89                })
90            }
91            NodeType::StateTransform => Box::new(TransformNode {
92                config: serde_json::from_value::<TransformConfig>(node.config.clone())
93                    .map_err(|e| format!("node '{}': {e}", node.id))?,
94                ctx: run.clone(),
95            }),
96            NodeType::Router => {
97                let config = if let Some(routes) = &node.routes {
98                    legacy_router(routes)
99                } else {
100                    serde_json::from_value::<RouterConfig>(node.config.clone())
101                        .map_err(|e| format!("node '{}': {e}", node.id))?
102                };
103                Box::new(RouterNode {
104                    config,
105                    ctx: run.clone(),
106                })
107            }
108            NodeType::Join => {
109                let inputs = node
110                    .config
111                    .get("inputs")
112                    .and_then(|v| v.as_array())
113                    .ok_or_else(|| format!("join '{}' requires inputs", node.id))?
114                    .iter()
115                    .filter_map(|v| v.as_str().map(str::to_owned))
116                    .collect();
117                let output = node
118                    .config
119                    .get("output")
120                    .and_then(|v| v.as_str())
121                    .ok_or_else(|| format!("join '{}' requires output", node.id))?;
122                match node
123                    .config
124                    .get("mode")
125                    .and_then(|v| v.as_str())
126                    .unwrap_or("collect_array")
127                {
128                    "collect_array" => Box::new(JoinNode::collect_array(inputs, output)),
129                    "merge_objects" => Box::new(JoinNode::merge_objects(inputs, output)),
130                    "first_non_null" => Box::new(JoinNode::new(inputs, output, |values| {
131                        Ok(values
132                            .into_iter()
133                            .map(|(_, v)| v)
134                            .find(|v| !v.is_null())
135                            .unwrap_or(serde_json::Value::Null))
136                    })),
137                    "all_success" => Box::new(JoinNode::new(inputs, output, |values| {
138                        let all = values.iter().all(|(_, value)| {
139                            value.as_bool().unwrap_or_else(|| {
140                                value
141                                    .get("success")
142                                    .and_then(|v| v.as_bool())
143                                    .unwrap_or(false)
144                            })
145                        });
146                        Ok(
147                            serde_json::json!({"all_success": all, "values": values.into_iter().map(|(_, value)| value).collect::<Vec<_>>() }),
148                        )
149                    })),
150                    "quorum" => {
151                        let required = node
152                            .config
153                            .get("required")
154                            .and_then(|v| v.as_u64())
155                            .unwrap_or(1) as usize;
156                        Box::new(JoinNode::new(inputs, output, move |values| {
157                            let approvals = values
158                                .iter()
159                                .filter(|(_, value)| value.as_bool().unwrap_or(false))
160                                .count();
161                            Ok(
162                                serde_json::json!({"met": approvals >= required, "approvals": approvals, "required": required}),
163                            )
164                        }))
165                    }
166                    mode => return Err(format!("unsupported join mode '{mode}'")),
167                }
168            }
169            NodeType::Parallel => {
170                // Parallel node: compile branches as passthrough nodes that fan out.
171                // The engine handles parallel execution when multiple nodes are targets
172                // from the same source in a superstep. We create a passthrough here
173                // and rely on edge routing to fan out to individual branch entries.
174                let _branches = node
175                    .config
176                    .get("branches")
177                    .and_then(|v| v.as_array())
178                    .map(|arr| arr.len())
179                    .unwrap_or(0);
180                // Write branch metadata to state for introspection
181                Box::new(PassthroughNode { ctx: run.clone() })
182            }
183            NodeType::Subgraph => {
184                // Subgraph: the referenced graph must be registered separately.
185                // We create a passthrough that records the intent; actual subgraph
186                // embedding requires cross-graph lookup at execution time.
187                let _graph_name = node
188                    .config
189                    .get("graph_name")
190                    .and_then(|v| v.as_str())
191                    .unwrap_or("")
192                    .to_owned();
193                Box::new(PassthroughNode { ctx: run.clone() })
194            }
195            NodeType::HumanApproval => {
196                // Human approval: emit interrupt signal to state.
197                // The caller (Hermes) monitors for InterruptError and handles the
198                // approval lifecycle via graph_resume.
199                let prompt_key = node
200                    .config
201                    .get("prompt_key")
202                    .and_then(|v| v.as_str())
203                    .unwrap_or("__approval_prompt__")
204                    .to_owned();
205                let output_key = node
206                    .config
207                    .get("output_key")
208                    .and_then(|v| v.as_str())
209                    .unwrap_or("__approval_decision__")
210                    .to_owned();
211                let audience: Vec<String> = node
212                    .config
213                    .get("audience")
214                    .and_then(|v| v.as_array())
215                    .map(|arr| {
216                        arr.iter()
217                            .filter_map(|v| v.as_str().map(String::from))
218                            .collect()
219                    })
220                    .unwrap_or_default();
221                let allowed: Vec<String> = node
222                    .config
223                    .get("allowed_decisions")
224                    .and_then(|v| v.as_array())
225                    .map(|arr| {
226                        arr.iter()
227                            .filter_map(|v| v.as_str().map(String::from))
228                            .collect()
229                    })
230                    .unwrap_or_else(|| vec!["approve".into(), "reject".into()]);
231                let expiry_ms = node
232                    .config
233                    .get("expiry_ms")
234                    .and_then(|v| v.as_u64())
235                    .unwrap_or(300_000);
236
237                Box::new(HumanApprovalNode {
238                    prompt_key,
239                    output_key,
240                    audience,
241                    allowed_decisions: allowed,
242                    expiry_ms,
243                    ctx: run.clone(),
244                })
245            }
246            NodeType::External | NodeType::Tool | NodeType::Loop => {
247                return Err(format!(
248                    "node '{}' is not executable by this local runtime",
249                    node.id
250                ));
251            }
252        };
253        if let Some(retry) = node.config.get("retry") {
254            let attempts = retry
255                .get("max_attempts")
256                .and_then(|v| v.as_u64())
257                .unwrap_or(3) as usize;
258            let initial = retry
259                .get("initial_delay_ms")
260                .and_then(|v| v.as_u64())
261                .unwrap_or(250);
262            let max_delay = retry
263                .get("max_delay_ms")
264                .and_then(|v| v.as_u64())
265                .unwrap_or(5_000);
266            let policy = RetryPolicy::new()
267                .with_max_attempts(attempts)
268                .with_initial_interval(std::time::Duration::from_millis(initial))
269                .with_max_interval(std::time::Duration::from_millis(max_delay))
270                .with_backoff_factor(
271                    retry
272                        .get("backoff_factor")
273                        .and_then(|v| v.as_f64())
274                        .unwrap_or(2.0),
275                )
276                .with_jitter(
277                    retry
278                        .get("jitter")
279                        .and_then(|v| v.as_bool())
280                        .unwrap_or(false),
281                );
282            builder = builder.add_node_with_retry(&node.id, boxed, policy);
283        } else {
284            builder = builder.add_node(&node.id, boxed);
285        }
286    }
287    builder = builder.set_entry_point(&spec.entry);
288    for edge in &spec.edges {
289        let target = if edge.to == "END" {
290            ri_agent_graph::END
291        } else {
292            edge.to.as_str()
293        };
294        builder = builder.add_edge(&edge.from, target);
295    }
296    for (key, reducer) in &spec.reducers {
297        builder = match reducer {
298            ReducerKind::LastWriteWins => builder.with_reducer(key, LastWriteWins),
299            ReducerKind::Append => builder.with_reducer(key, AppendReducer),
300            ReducerKind::Add => builder.with_reducer(key, AddReducer),
301            ReducerKind::Merge => builder.with_reducer(key, MergeReducer),
302        };
303    }
304    builder.build().map_err(|e| e.to_string())
305}