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 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 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 Box::new(PassthroughNode { ctx: run.clone() })
195 }
196 NodeType::Subgraph => {
197 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 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}