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 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 Box::new(PassthroughNode { ctx: run.clone() })
182 }
183 NodeType::Subgraph => {
184 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 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}