Skip to main content

agent_graph_mcp/
compiler.rs

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