Skip to main content

agent_graph_mcp/
nodes.rs

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                // Dropping the boxed future stops waiting for this call. The
101                // provider may still observe a request already sent on the wire.
102                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        // A node may write only its declared output key. `__input__` is reserved
110        // for ingress and explicit legacy/router nodes; mirroring every LLM
111        // result there made parallel branches race for the graph's final state.
112        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
354// ── HumanApprovalNode ──────────────────────────────────────────────────
355
356/// A node that signals an approval gate: writes the approval request to state
357/// and returns an error that the caller can handle.
358pub 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        // Check if a prior decision was already injected (e.g., via resume)
389        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        // Signal interrupt by returning a recognizable error.
396        // The graph engine's execute_with_interrupt will catch this.
397        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}