1use std::collections::HashMap;
2use std::io::{BufRead, BufReader, Write};
3use std::process::{Command as StdCommand, Stdio};
4use std::sync::{
5 atomic::{AtomicBool, AtomicU64, Ordering},
6 Arc, Mutex,
7};
8
9use async_trait::async_trait;
10use llm_pipeline::payload::Payload;
11use llm_pipeline::{ExecCtx, LlmCall, LlmConfig};
12use ri_agent_graph::command::{Command, Navigation, NodeOutput};
13use ri_agent_graph::config::GraphConfig;
14use ri_agent_graph::error::{AgentGraphError, Result};
15use ri_agent_graph::node::Node;
16use ri_agent_graph::state::AgentState;
17use serde::Deserialize;
18use serde_json::{Map, Value};
19use tokio::sync::Notify;
20
21use crate::evidence::validate_research_evidence;
22
23#[derive(Clone)]
24pub struct RunContext {
25 pub cancelled: Arc<AtomicBool>,
26 pub cancellation: Arc<Notify>,
27 pub llm_calls: Arc<AtomicU64>,
30 pub max_llm_calls: Option<u64>,
32 pub llm_invocations: Arc<Mutex<Vec<Value>>>,
34}
35
36impl RunContext {
37 fn check(&self) -> Result<()> {
38 if self.cancelled.load(Ordering::SeqCst) {
39 Err(AgentGraphError::Cancelled)
40 } else {
41 Ok(())
42 }
43 }
44
45 pub fn reserve_llm_attempt(&self) -> Result<u64> {
49 match self
50 .llm_calls
51 .fetch_update(Ordering::SeqCst, Ordering::SeqCst, |used| {
52 if self.max_llm_calls.is_some_and(|limit| used >= limit) {
53 None
54 } else {
55 Some(used + 1)
56 }
57 }) {
58 Ok(used) => Ok(used.saturating_add(1)),
61 Err(_) => Err(AgentGraphError::PayloadError("BUDGET_EXHAUSTED".to_owned())),
62 }
63 }
64}
65
66async fn cancellation_requested(
67 cancelled: Arc<AtomicBool>,
68 cancellation: Arc<Notify>,
69) -> Result<()> {
70 if cancelled.load(Ordering::SeqCst) {
71 return Err(AgentGraphError::Cancelled);
72 }
73 cancellation.notified().await;
74 Err(AgentGraphError::Cancelled)
75}
76
77pub struct PassthroughNode {
78 pub ctx: RunContext,
79}
80#[async_trait]
81impl Node for PassthroughNode {
82 async fn execute(&self, _: &AgentState, _: &GraphConfig) -> Result<NodeOutput> {
83 self.ctx.check()?;
84 Ok(NodeOutput::Done)
85 }
86}
87
88pub struct LlmNode {
89 pub id: String,
90 pub base_url: String,
91 pub default_model: String,
92 pub api_key: Option<String>,
94 pub prompt: String,
95 pub model: Option<String>,
96 pub json_mode: bool,
97 pub evidence_required: bool,
98 pub max_tokens: Option<usize>,
99 pub timeout_ms: u64,
100 pub input_key: String,
101 pub output_key: String,
102 pub ctx: RunContext,
103}
104
105#[async_trait]
106impl Node for LlmNode {
107 async fn execute(&self, state: &AgentState, _: &GraphConfig) -> Result<NodeOutput> {
108 self.ctx.check()?;
109 let input = state
110 .get_opt::<Value>(&self.input_key)
111 .await?
112 .unwrap_or(Value::Null);
113 let input_json = serde_json::to_string(&input)?;
114 let mut rendered = self.prompt.replace("{input}", &input_json);
120 if !self.input_key.is_empty() {
121 rendered = rendered.replace(&format!("{{{}}}", self.input_key), &input_json);
122 }
123 let model = self.model.as_deref().unwrap_or(&self.default_model);
124 let attempt = self.ctx.reserve_llm_attempt()?;
127 let mut config = LlmConfig::default().with_json_mode(self.json_mode);
128 if let Some(tokens) = self.max_tokens {
129 config = config.with_max_tokens(tokens as u32);
130 }
131 let result: std::result::Result<Value, AgentGraphError> = if self.base_url
132 == "codex-app-server://"
133 {
134 let model = model.to_owned();
135 let prompt = rendered.clone();
136 let timeout = std::time::Duration::from_millis(self.timeout_ms);
137 let cwd = std::env::current_dir().map_err(|e| {
138 AgentGraphError::PayloadError(format!("codex working directory unavailable: {e}"))
139 })?;
140 tokio::select! {
141 result = tokio::task::spawn_blocking(move || {
142 crate::codex_app_server::run_turn("codex", &model, &cwd, &prompt, timeout)
143 }) => {
144 let text = result
145 .map_err(|e| AgentGraphError::PayloadError(format!("codex app-server task failed: {e}")))?
146 .map_err(AgentGraphError::PayloadError)?;
147 Ok(Value::String(text))
148 }
149 _ = cancellation_requested(self.ctx.cancelled.clone(), self.ctx.cancellation.clone()) => {
150 Err(AgentGraphError::Cancelled)
151 }
152 }
153 } else {
154 let call = LlmCall::new(&self.id, rendered)
155 .with_model(model)
156 .with_timeout(std::time::Duration::from_millis(self.timeout_ms))
157 .with_config(config);
158 let mut exec_builder = ExecCtx::builder(&self.base_url);
159 if self.base_url.starts_with("http://") || self.base_url.starts_with("https://") {
167 exec_builder = match self.api_key.as_deref() {
168 Some(key) => exec_builder.openai_with_key(key),
169 None => exec_builder.openai(),
170 };
171 }
172 if let Some(key) = self.api_key.as_deref() {
173 let mut headers = reqwest::header::HeaderMap::new();
176 let value = reqwest::header::HeaderValue::from_str(&format!("Bearer {key}"))
177 .map_err(|e| {
178 AgentGraphError::PayloadError(format!("invalid api key header: {e}"))
179 })?;
180 headers.insert(reqwest::header::AUTHORIZATION, value);
181 let client = reqwest::Client::builder()
182 .default_headers(headers)
183 .build()
184 .map_err(|e| {
185 AgentGraphError::PayloadError(format!("http client build failed: {e}"))
186 })?;
187 exec_builder = exec_builder.client(client);
188 }
189 let exec_ctx = exec_builder.build();
190 tokio::select! {
191 result = call.invoke(&exec_ctx, input) => result
192 .map_err(|e| AgentGraphError::PayloadError(e.to_string()))
193 .map(|payload| payload.value),
194 _ = cancellation_requested(self.ctx.cancelled.clone(), self.ctx.cancellation.clone()) => {
195 Err(AgentGraphError::Cancelled)
196 }
197 }
198 };
199 let output = match result {
200 Ok(output) => {
201 self.record_invocation(attempt, model, "succeeded");
202 output
203 }
204 Err(error) => {
205 self.record_invocation(attempt, model, "failed");
206 return Err(error);
207 }
208 };
209 self.ctx.check()?;
210 if self.evidence_required {
211 validate_research_evidence(&output).map_err(AgentGraphError::PayloadError)?;
212 }
213 state.set_raw(&self.output_key, output).await?;
217 Ok(NodeOutput::Done)
218 }
219}
220
221impl LlmNode {
222 fn record_invocation(&self, attempt: u64, model: &str, outcome: &str) {
225 if let Ok(mut invocations) = self.ctx.llm_invocations.lock() {
226 invocations.push(serde_json::json!({
227 "attempt": attempt,
228 "node_id": self.id,
229 "configured_model": model,
230 "outcome": outcome,
231 }));
232 }
233 }
234}
235
236#[cfg(test)]
237mod tests {
238 use super::cancellation_requested;
239 use std::sync::{atomic::AtomicBool, Arc};
240 use tokio::sync::Notify;
241
242 #[tokio::test]
243 async fn cancellation_primitive_wakes_a_pending_wait() {
244 let cancelled = Arc::new(AtomicBool::new(false));
245 let cancellation = Arc::new(Notify::new());
246 let waiter = tokio::spawn(cancellation_requested(cancelled, cancellation.clone()));
247 tokio::task::yield_now().await;
248 cancellation.notify_waiters();
249 assert!(waiter
250 .await
251 .expect("cancellation waiter completed")
252 .is_err());
253 }
254}
255
256#[derive(Debug, Clone, Deserialize)]
257pub struct TransformConfig {
258 pub operations: Vec<TransformOp>,
259}
260
261#[derive(Debug, Clone, Deserialize)]
262pub struct TransformOp {
263 pub op: String,
264 pub path: String,
265 #[serde(default)]
266 pub from: Option<String>,
267 #[serde(default)]
268 pub value: Value,
269 #[serde(default)]
270 pub values: Vec<String>,
271 #[serde(default)]
272 pub template: Option<String>,
273}
274
275pub struct TransformNode {
276 pub config: TransformConfig,
277 pub ctx: RunContext,
278}
279
280#[async_trait]
281impl Node for TransformNode {
282 async fn execute(&self, state: &AgentState, _: &GraphConfig) -> Result<NodeOutput> {
283 self.ctx.check()?;
284 for op in &self.config.operations {
285 apply_transform(state, op).await?;
286 }
287 Ok(NodeOutput::Done)
288 }
289}
290
291async fn apply_transform(state: &AgentState, op: &TransformOp) -> Result<()> {
292 let current = state
293 .get_opt::<Value>(&op.path)
294 .await?
295 .unwrap_or(Value::Null);
296 match op.op.as_str() {
297 "set" => state.set_raw(&op.path, op.value.clone()).await?,
298 "copy" => {
299 let from = op
300 .from
301 .as_deref()
302 .ok_or_else(|| AgentGraphError::StateError("copy requires from".into()))?;
303 let v = state.get_opt::<Value>(from).await?.unwrap_or(Value::Null);
304 state.set_raw(&op.path, v).await?;
305 }
306 "delete" => {
307 state.remove(&op.path).await;
308 }
309 "increment" => {
310 let a = current.as_f64().unwrap_or(0.0);
311 let b = op.value.as_f64().unwrap_or(1.0);
312 state.set_raw(&op.path, serde_json::json!(a + b)).await?;
313 }
314 "append" => {
315 let mut out = match current {
316 Value::Array(v) => v,
317 Value::Null => vec![],
318 v => vec![v],
319 };
320 out.push(op.value.clone());
321 state.set_raw(&op.path, Value::Array(out)).await?;
322 }
323 "merge" | "merge_object" => {
324 let mut out = current.as_object().cloned().unwrap_or_default();
325 let add = op
326 .value
327 .as_object()
328 .ok_or_else(|| AgentGraphError::StateError("merge value must be object".into()))?;
329 out.extend(add.clone());
330 state.set_raw(&op.path, Value::Object(out)).await?;
331 }
332 "select" => {
333 let mut out = Map::new();
334 for key in &op.values {
335 if let Some(v) = state.get_opt::<Value>(key).await? {
336 out.insert(key.clone(), v);
337 }
338 }
339 state.set_raw(&op.path, Value::Object(out)).await?;
340 }
341 "compare" => {
342 state
343 .set_raw(&op.path, Value::Bool(current == op.value))
344 .await?
345 }
346 "format" => {
347 let mut text = op.template.clone().unwrap_or_default();
348 for key in &op.values {
349 let v = state.get_opt::<Value>(key).await?.unwrap_or(Value::Null);
350 text = text.replace(&format!("{{{key}}}"), value_text(&v).as_str());
351 }
352 state.set_raw(&op.path, Value::String(text)).await?;
353 }
354 other => {
355 return Err(AgentGraphError::StateError(format!(
356 "unsupported transform operation '{other}'"
357 )))
358 }
359 }
360 Ok(())
361}
362
363fn value_text(value: &Value) -> String {
364 value
365 .as_str()
366 .map(str::to_owned)
367 .unwrap_or_else(|| value.to_string())
368}
369
370#[derive(Debug, Clone, Deserialize)]
371pub struct RouterConfig {
372 pub rules: Vec<Rule>,
373 pub default: Vec<String>,
374}
375#[derive(Debug, Clone, Deserialize)]
376pub struct Rule {
377 pub path: String,
378 pub op: String,
379 #[serde(default)]
380 pub value: Value,
381 pub targets: Vec<String>,
382}
383
384pub struct RouterNode {
385 pub config: RouterConfig,
386 pub ctx: RunContext,
387}
388
389#[async_trait]
390impl Node for RouterNode {
391 async fn execute(&self, state: &AgentState, _: &GraphConfig) -> Result<NodeOutput> {
392 self.ctx.check()?;
393 let mut targets = None;
394 for rule in &self.config.rules {
395 if predicate(state, rule).await? {
396 targets = Some(rule.targets.clone());
397 break;
398 }
399 }
400 let targets = targets.unwrap_or_else(|| self.config.default.clone());
401 let goto = if targets.is_empty() || targets == ["END"] {
402 Navigation::End
403 } else if targets.len() == 1 {
404 Navigation::Node(targets[0].clone())
405 } else {
406 Navigation::Nodes(targets)
407 };
408 let mut update = HashMap::new();
409 update.insert(
410 "__route__".into(),
411 serde_json::to_value(goto_label(&goto)).unwrap_or(Value::Null),
412 );
413 Ok(NodeOutput::Command(Command {
414 update: Some(update),
415 goto,
416 }))
417 }
418}
419
420fn goto_label(goto: &Navigation) -> Value {
421 match goto {
422 Navigation::End => Value::String("END".into()),
423 Navigation::Node(v) => Value::String(v.clone()),
424 Navigation::Nodes(v) => serde_json::json!(v),
425 _ => Value::Null,
426 }
427}
428
429async fn predicate(state: &AgentState, rule: &Rule) -> Result<bool> {
430 let value = state
431 .get_opt::<Value>(&rule.path)
432 .await?
433 .unwrap_or(Value::Null);
434 Ok(match rule.op.as_str() {
435 "equals" | "eq" => value == rule.value,
436 "exists" => !value.is_null(),
437 "contains" => value_text(&value).contains(&value_text(&rule.value)),
438 "lt" => value
439 .as_f64()
440 .zip(rule.value.as_f64())
441 .is_some_and(|(a, b)| a < b),
442 "lte" => value
443 .as_f64()
444 .zip(rule.value.as_f64())
445 .is_some_and(|(a, b)| a <= b),
446 "gt" => value
447 .as_f64()
448 .zip(rule.value.as_f64())
449 .is_some_and(|(a, b)| a > b),
450 "gte" => value
451 .as_f64()
452 .zip(rule.value.as_f64())
453 .is_some_and(|(a, b)| a >= b),
454 _ => false,
455 })
456}
457
458pub fn legacy_router(routes: &std::collections::BTreeMap<String, String>) -> RouterConfig {
459 RouterConfig {
460 rules: routes
461 .iter()
462 .map(|(pattern, target)| Rule {
463 path: "__input__".into(),
464 op: "contains".into(),
465 value: Value::String(pattern.clone()),
466 targets: vec![target.clone()],
467 })
468 .collect(),
469 default: vec!["END".into()],
470 }
471}
472
473pub struct HumanApprovalNode {
478 pub prompt_key: String,
479 pub output_key: String,
480 pub audience: Vec<String>,
481 pub allowed_decisions: Vec<String>,
482 pub expiry_ms: u64,
483 pub ctx: RunContext,
484}
485
486#[async_trait]
487impl Node for HumanApprovalNode {
488 async fn execute(&self, state: &AgentState, _: &GraphConfig) -> Result<NodeOutput> {
489 self.ctx.check()?;
490 let prompt = state
491 .get_opt::<Value>(&self.prompt_key)
492 .await?
493 .unwrap_or(Value::Null);
494
495 let approval_request = serde_json::json!({
496 "prompt": prompt,
497 "audience": self.audience,
498 "allowed_decisions": self.allowed_decisions,
499 "expiry_ms": self.expiry_ms,
500 "issued_at": chrono::Utc::now().to_rfc3339(),
501 "status": "pending"
502 });
503 state
504 .set_raw("__approval_request__", approval_request)
505 .await?;
506
507 if let Some(decision) = state.get_opt::<Value>(&self.output_key).await? {
509 if !decision.is_null() {
510 return Ok(NodeOutput::Done);
511 }
512 }
513
514 Err(AgentGraphError::InterruptError {
517 node: "human_approval".into(),
518 value: Some(
519 serde_json::json!({"approval_required": true, "prompt_key": self.prompt_key}),
520 ),
521 })
522 }
523}
524
525pub struct ToolNode {
530 pub id: String,
531 pub python: String,
532 pub hermes_source: String,
533 pub lease: Value,
534 pub receipt_dir: String,
535 pub timeout_ms: u64,
536 pub ctx: RunContext,
537}
538
539#[async_trait]
540impl Node for ToolNode {
541 async fn execute(&self, state: &AgentState, _: &GraphConfig) -> Result<NodeOutput> {
542 self.ctx.check()?;
543
544 let lease_path = format!("{}/lease.json", self.receipt_dir);
546 std::fs::create_dir_all(&self.receipt_dir)
547 .map_err(|e| AgentGraphError::PayloadError(format!("receipt dir: {e}")))?;
548 std::fs::write(
549 &lease_path,
550 serde_json::to_string(&self.lease)
551 .map_err(|e| AgentGraphError::PayloadError(format!("lease serialize: {e}")))?,
552 )
553 .map_err(|e| AgentGraphError::PayloadError(format!("lease write: {e}")))?;
554
555 let tool_name = state
557 .get_opt::<Value>("__tool_name__")
558 .await?
559 .and_then(|v| v.as_str().map(str::to_owned))
560 .unwrap_or_else(|| "read_file".to_owned());
561 let tool_args: Value = state
562 .get_opt::<Value>("__tool_args__")
563 .await?
564 .unwrap_or(Value::Null);
565
566 let mut child = StdCommand::new(&self.python)
568 .args(["-m", "agent.transports.hermes_tools_mcp_server"])
569 .env("AGENT_GRAPH_LINEAGE", "1")
570 .env("AGENT_GRAPH_LINEAGE_LEASE_PATH", &lease_path)
571 .env("AGENT_GRAPH_LINEAGE_RECEIPT_DIR", &self.receipt_dir)
572 .env("PYTHONPATH", &self.hermes_source)
573 .stdin(Stdio::piped())
574 .stdout(Stdio::piped())
575 .stderr(Stdio::piped())
576 .spawn()
577 .map_err(|e| AgentGraphError::PayloadError(format!("broker spawn: {e}")))?;
578
579 let mut stdin = child
580 .stdin
581 .take()
582 .ok_or_else(|| AgentGraphError::PayloadError("no stdin".into()))?;
583 let stdout = child
584 .stdout
585 .take()
586 .ok_or_else(|| AgentGraphError::PayloadError("no stdout".into()))?;
587 let stderr = child.stderr.take();
588
589 let init_req = serde_json::json!({
591 "jsonrpc": "2.0",
592 "id": 1,
593 "method": "initialize",
594 "params": {
595 "protocolVersion": "2024-11-05",
596 "capabilities": {},
597 "clientInfo": {"name": "agent-graph-tool-node", "version": "0.1"}
598 }
599 });
600 let init_line = serde_json::to_string(&init_req)
601 .map_err(|e| AgentGraphError::PayloadError(format!("init: {e}")))?;
602 writeln!(stdin, "{init_line}")
603 .map_err(|e| AgentGraphError::PayloadError(format!("write init: {e}")))?;
604 stdin
605 .flush()
606 .map_err(|e| AgentGraphError::PayloadError(format!("flush init: {e}")))?;
607
608 let mut reader = BufReader::new(stdout);
609 let mut response = String::new();
610 reader
611 .read_line(&mut response)
612 .map_err(|e| AgentGraphError::PayloadError(format!("read init: {e}")))?;
613
614 let notified = serde_json::json!({
616 "jsonrpc": "2.0",
617 "method": "notifications/initialized"
618 });
619 writeln!(
620 stdin,
621 "{}",
622 serde_json::to_string(¬ified)
623 .map_err(|e| AgentGraphError::PayloadError(format!("notify: {e}")))?
624 )
625 .map_err(|e| AgentGraphError::PayloadError(format!("write notify: {e}")))?;
626 stdin
627 .flush()
628 .map_err(|e| AgentGraphError::PayloadError(format!("flush notify: {e}")))?;
629
630 let call_req = serde_json::json!({
632 "jsonrpc": "2.0",
633 "id": 2,
634 "method": "tools/call",
635 "params": {
636 "name": tool_name,
637 "arguments": tool_args
638 }
639 });
640 let call_line = serde_json::to_string(&call_req)
641 .map_err(|e| AgentGraphError::PayloadError(format!("call: {e}")))?;
642 writeln!(stdin, "{call_line}")
643 .map_err(|e| AgentGraphError::PayloadError(format!("write call: {e}")))?;
644 stdin
645 .flush()
646 .map_err(|e| AgentGraphError::PayloadError(format!("flush call: {e}")))?;
647
648 response.clear();
649 reader
650 .read_line(&mut response)
651 .map_err(|e| AgentGraphError::PayloadError(format!("read result: {e}")))?;
652
653 drop(stdin);
655
656 let timeout_dur = std::time::Duration::from_millis(self.timeout_ms);
658 let status = tokio::time::timeout(
659 timeout_dur,
660 tokio::task::spawn_blocking(move || child.wait()),
661 )
662 .await
663 .map_err(|_| AgentGraphError::PayloadError("broker timed out".into()))?
664 .map_err(|e| AgentGraphError::PayloadError(format!("join: {e}")))?
665 .map_err(|e| AgentGraphError::PayloadError(format!("wait: {e}")))?;
666
667 let stderr_output = if let Some(stderr) = stderr {
669 let mut buf = String::new();
670 let _ = BufReader::new(stderr).read_line(&mut buf);
671 buf
672 } else {
673 String::new()
674 };
675
676 let result: Value = serde_json::from_str(&response).map_err(|e| {
678 AgentGraphError::PayloadError(format!(
679 "parse result (exit={status:?}, stderr={stderr_output:?}): {e}"
680 ))
681 })?;
682
683 let tool_output = result
684 .get("result")
685 .and_then(|r| r.get("content"))
686 .and_then(|c| c.as_array())
687 .and_then(|arr| arr.first())
688 .and_then(|item| item.get("text"))
689 .cloned()
690 .unwrap_or(Value::Null);
691
692 if let Some(err) = result.get("error") {
694 return Err(AgentGraphError::PayloadError(format!(
695 "tool '{tool_name}' failed: {err}"
696 )));
697 }
698 if !status.success() {
699 return Err(AgentGraphError::PayloadError(format!(
700 "broker exited {status}: {stderr_output}"
701 )));
702 }
703
704 let ledger_path = format!("{}/ledger.jsonl", self.receipt_dir);
706 let receipt_evidence = if let Ok(contents) = std::fs::read_to_string(&ledger_path) {
707 let receipts: Vec<Value> = contents
708 .lines()
709 .filter_map(|line| serde_json::from_str(line).ok())
710 .collect();
711 receipts.into()
712 } else {
713 Value::Null
714 };
715
716 state
717 .set_raw("__tool_result__", tool_output.clone())
718 .await?;
719 state.set_raw("__tool_receipts__", receipt_evidence).await?;
720 state.set_raw("__tool_success__", Value::Bool(true)).await?;
721
722 Ok(NodeOutput::Done)
723 }
724}