1use std::collections::HashMap;
2use std::sync::{
3 atomic::{AtomicBool, Ordering},
4 Arc,
5};
6
7use ri_agent_graph::command::{Command, Navigation, NodeOutput};
8use ri_agent_graph::config::GraphConfig;
9use ri_agent_graph::error::{AgentGraphError, Result};
10use ri_agent_graph::node::Node;
11use ri_agent_graph::state::AgentState;
12use async_trait::async_trait;
13use llm_pipeline::payload::Payload;
14use llm_pipeline::{ExecCtx, LlmCall, LlmConfig};
15use serde::Deserialize;
16use serde_json::{Map, Value};
17use tokio::sync::Notify;
18
19use crate::evidence::validate_research_evidence;
20
21#[derive(Clone)]
22pub struct RunContext {
23 pub cancelled: Arc<AtomicBool>,
24 pub cancellation: Arc<Notify>,
25}
26
27impl RunContext {
28 fn check(&self) -> Result<()> {
29 if self.cancelled.load(Ordering::SeqCst) {
30 Err(AgentGraphError::Cancelled)
31 } else {
32 Ok(())
33 }
34 }
35}
36
37async fn cancellation_requested(
38 cancelled: Arc<AtomicBool>,
39 cancellation: Arc<Notify>,
40) -> Result<()> {
41 if cancelled.load(Ordering::SeqCst) {
42 return Err(AgentGraphError::Cancelled);
43 }
44 cancellation.notified().await;
45 Err(AgentGraphError::Cancelled)
46}
47
48pub struct PassthroughNode {
49 pub ctx: RunContext,
50}
51#[async_trait]
52impl Node for PassthroughNode {
53 async fn execute(&self, _: &AgentState, _: &GraphConfig) -> Result<NodeOutput> {
54 self.ctx.check()?;
55 Ok(NodeOutput::Done)
56 }
57}
58
59pub struct LlmNode {
60 pub id: String,
61 pub base_url: String,
62 pub default_model: String,
63 pub prompt: String,
64 pub model: Option<String>,
65 pub json_mode: bool,
66 pub evidence_required: bool,
67 pub max_tokens: Option<usize>,
68 pub timeout_ms: u64,
69 pub input_key: String,
70 pub output_key: String,
71 pub ctx: RunContext,
72}
73
74#[async_trait]
75impl Node for LlmNode {
76 async fn execute(&self, state: &AgentState, _: &GraphConfig) -> Result<NodeOutput> {
77 self.ctx.check()?;
78 let input = state
79 .get_opt::<Value>(&self.input_key)
80 .await?
81 .unwrap_or(Value::Null);
82 let rendered = self
83 .prompt
84 .replace("{input}", &serde_json::to_string(&input)?);
85 let model = self.model.as_deref().unwrap_or(&self.default_model);
86 let mut config = LlmConfig::default().with_json_mode(self.json_mode);
87 if let Some(tokens) = self.max_tokens {
88 config = config.with_max_tokens(tokens as u32);
89 }
90 let call = LlmCall::new(&self.id, rendered)
91 .with_model(model)
92 .with_timeout(std::time::Duration::from_millis(self.timeout_ms))
93 .with_config(config);
94 let exec_ctx = ExecCtx::builder(&self.base_url).build();
95 let output = tokio::select! {
96 result = call.invoke(&exec_ctx, input) => result
97 .map_err(|e| AgentGraphError::PayloadError(e.to_string()))?
98 .value,
99 _ = cancellation_requested(self.ctx.cancelled.clone(), self.ctx.cancellation.clone()) => {
100 return Err(AgentGraphError::Cancelled);
103 }
104 };
105 self.ctx.check()?;
106 if self.evidence_required {
107 validate_research_evidence(&output).map_err(AgentGraphError::PayloadError)?;
108 }
109 state.set_raw(&self.output_key, output).await?;
113 Ok(NodeOutput::Done)
114 }
115}
116
117#[cfg(test)]
118mod tests {
119 use super::cancellation_requested;
120 use std::sync::{atomic::AtomicBool, Arc};
121 use tokio::sync::Notify;
122
123 #[tokio::test]
124 async fn cancellation_primitive_wakes_a_pending_wait() {
125 let cancelled = Arc::new(AtomicBool::new(false));
126 let cancellation = Arc::new(Notify::new());
127 let waiter = tokio::spawn(cancellation_requested(cancelled, cancellation.clone()));
128 tokio::task::yield_now().await;
129 cancellation.notify_waiters();
130 assert!(waiter
131 .await
132 .expect("cancellation waiter completed")
133 .is_err());
134 }
135}
136
137#[derive(Debug, Clone, Deserialize)]
138pub struct TransformConfig {
139 pub operations: Vec<TransformOp>,
140}
141
142#[derive(Debug, Clone, Deserialize)]
143pub struct TransformOp {
144 pub op: String,
145 pub path: String,
146 #[serde(default)]
147 pub from: Option<String>,
148 #[serde(default)]
149 pub value: Value,
150 #[serde(default)]
151 pub values: Vec<String>,
152 #[serde(default)]
153 pub template: Option<String>,
154}
155
156pub struct TransformNode {
157 pub config: TransformConfig,
158 pub ctx: RunContext,
159}
160
161#[async_trait]
162impl Node for TransformNode {
163 async fn execute(&self, state: &AgentState, _: &GraphConfig) -> Result<NodeOutput> {
164 self.ctx.check()?;
165 for op in &self.config.operations {
166 apply_transform(state, op).await?;
167 }
168 Ok(NodeOutput::Done)
169 }
170}
171
172async fn apply_transform(state: &AgentState, op: &TransformOp) -> Result<()> {
173 let current = state
174 .get_opt::<Value>(&op.path)
175 .await?
176 .unwrap_or(Value::Null);
177 match op.op.as_str() {
178 "set" => state.set_raw(&op.path, op.value.clone()).await?,
179 "copy" => {
180 let from = op
181 .from
182 .as_deref()
183 .ok_or_else(|| AgentGraphError::StateError("copy requires from".into()))?;
184 let v = state.get_opt::<Value>(from).await?.unwrap_or(Value::Null);
185 state.set_raw(&op.path, v).await?;
186 }
187 "delete" => {
188 state.remove(&op.path).await;
189 }
190 "increment" => {
191 let a = current.as_f64().unwrap_or(0.0);
192 let b = op.value.as_f64().unwrap_or(1.0);
193 state.set_raw(&op.path, serde_json::json!(a + b)).await?;
194 }
195 "append" => {
196 let mut out = match current {
197 Value::Array(v) => v,
198 Value::Null => vec![],
199 v => vec![v],
200 };
201 out.push(op.value.clone());
202 state.set_raw(&op.path, Value::Array(out)).await?;
203 }
204 "merge" | "merge_object" => {
205 let mut out = current.as_object().cloned().unwrap_or_default();
206 let add = op
207 .value
208 .as_object()
209 .ok_or_else(|| AgentGraphError::StateError("merge value must be object".into()))?;
210 out.extend(add.clone());
211 state.set_raw(&op.path, Value::Object(out)).await?;
212 }
213 "select" => {
214 let mut out = Map::new();
215 for key in &op.values {
216 if let Some(v) = state.get_opt::<Value>(key).await? {
217 out.insert(key.clone(), v);
218 }
219 }
220 state.set_raw(&op.path, Value::Object(out)).await?;
221 }
222 "compare" => {
223 state
224 .set_raw(&op.path, Value::Bool(current == op.value))
225 .await?
226 }
227 "format" => {
228 let mut text = op.template.clone().unwrap_or_default();
229 for key in &op.values {
230 let v = state.get_opt::<Value>(key).await?.unwrap_or(Value::Null);
231 text = text.replace(&format!("{{{key}}}"), value_text(&v).as_str());
232 }
233 state.set_raw(&op.path, Value::String(text)).await?;
234 }
235 other => {
236 return Err(AgentGraphError::StateError(format!(
237 "unsupported transform operation '{other}'"
238 )))
239 }
240 }
241 Ok(())
242}
243
244fn value_text(value: &Value) -> String {
245 value
246 .as_str()
247 .map(str::to_owned)
248 .unwrap_or_else(|| value.to_string())
249}
250
251#[derive(Debug, Clone, Deserialize)]
252pub struct RouterConfig {
253 pub rules: Vec<Rule>,
254 pub default: Vec<String>,
255}
256#[derive(Debug, Clone, Deserialize)]
257pub struct Rule {
258 pub path: String,
259 pub op: String,
260 #[serde(default)]
261 pub value: Value,
262 pub targets: Vec<String>,
263}
264
265pub struct RouterNode {
266 pub config: RouterConfig,
267 pub ctx: RunContext,
268}
269
270#[async_trait]
271impl Node for RouterNode {
272 async fn execute(&self, state: &AgentState, _: &GraphConfig) -> Result<NodeOutput> {
273 self.ctx.check()?;
274 let mut targets = None;
275 for rule in &self.config.rules {
276 if predicate(state, rule).await? {
277 targets = Some(rule.targets.clone());
278 break;
279 }
280 }
281 let targets = targets.unwrap_or_else(|| self.config.default.clone());
282 let goto = if targets.is_empty() || targets == ["END"] {
283 Navigation::End
284 } else if targets.len() == 1 {
285 Navigation::Node(targets[0].clone())
286 } else {
287 Navigation::Nodes(targets)
288 };
289 let mut update = HashMap::new();
290 update.insert(
291 "__route__".into(),
292 serde_json::to_value(goto_label(&goto)).unwrap_or(Value::Null),
293 );
294 Ok(NodeOutput::Command(Command {
295 update: Some(update),
296 goto,
297 }))
298 }
299}
300
301fn goto_label(goto: &Navigation) -> Value {
302 match goto {
303 Navigation::End => Value::String("END".into()),
304 Navigation::Node(v) => Value::String(v.clone()),
305 Navigation::Nodes(v) => serde_json::json!(v),
306 _ => Value::Null,
307 }
308}
309
310async fn predicate(state: &AgentState, rule: &Rule) -> Result<bool> {
311 let value = state
312 .get_opt::<Value>(&rule.path)
313 .await?
314 .unwrap_or(Value::Null);
315 Ok(match rule.op.as_str() {
316 "equals" | "eq" => value == rule.value,
317 "exists" => !value.is_null(),
318 "contains" => value_text(&value).contains(&value_text(&rule.value)),
319 "lt" => value
320 .as_f64()
321 .zip(rule.value.as_f64())
322 .is_some_and(|(a, b)| a < b),
323 "lte" => value
324 .as_f64()
325 .zip(rule.value.as_f64())
326 .is_some_and(|(a, b)| a <= b),
327 "gt" => value
328 .as_f64()
329 .zip(rule.value.as_f64())
330 .is_some_and(|(a, b)| a > b),
331 "gte" => value
332 .as_f64()
333 .zip(rule.value.as_f64())
334 .is_some_and(|(a, b)| a >= b),
335 _ => false,
336 })
337}
338
339pub fn legacy_router(routes: &std::collections::BTreeMap<String, String>) -> RouterConfig {
340 RouterConfig {
341 rules: routes
342 .iter()
343 .map(|(pattern, target)| Rule {
344 path: "__input__".into(),
345 op: "contains".into(),
346 value: Value::String(pattern.clone()),
347 targets: vec![target.clone()],
348 })
349 .collect(),
350 default: vec!["END".into()],
351 }
352}
353
354pub struct HumanApprovalNode {
359 pub prompt_key: String,
360 pub output_key: String,
361 pub audience: Vec<String>,
362 pub allowed_decisions: Vec<String>,
363 pub expiry_ms: u64,
364 pub ctx: RunContext,
365}
366
367#[async_trait]
368impl Node for HumanApprovalNode {
369 async fn execute(&self, state: &AgentState, _: &GraphConfig) -> Result<NodeOutput> {
370 self.ctx.check()?;
371 let prompt = state
372 .get_opt::<Value>(&self.prompt_key)
373 .await?
374 .unwrap_or(Value::Null);
375
376 let approval_request = serde_json::json!({
377 "prompt": prompt,
378 "audience": self.audience,
379 "allowed_decisions": self.allowed_decisions,
380 "expiry_ms": self.expiry_ms,
381 "issued_at": chrono::Utc::now().to_rfc3339(),
382 "status": "pending"
383 });
384 state
385 .set_raw("__approval_request__", approval_request)
386 .await?;
387
388 if let Some(decision) = state.get_opt::<Value>(&self.output_key).await? {
390 if !decision.is_null() {
391 return Ok(NodeOutput::Done);
392 }
393 }
394
395 Err(AgentGraphError::InterruptError {
398 node: "human_approval".into(),
399 value: Some(
400 serde_json::json!({"approval_required": true, "prompt_key": self.prompt_key}),
401 ),
402 })
403 }
404}