Skip to main content

agent_graph_mcp/
nodes.rs

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    /// Shared pre-invocation attempt counter. Incremented atomically for every
28    /// provider attempt, including attempts that later fail or time out.
29    pub llm_calls: Arc<AtomicU64>,
30    /// Run-scoped cap on provider attempts; None means unlimited.
31    pub max_llm_calls: Option<u64>,
32    /// Shared typed ledger of observed invocations for the terminal receipt.
33    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    /// Atomically reserve one provider-attempt slot BEFORE any provider call,
46    /// so failed and timed-out attempts still count and concurrent nodes cannot
47    /// race past the budget. A denied attempt leaves the counter unchanged.
48    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            // fetch_update returns the PREVIOUS value; the stored counter was
59            // already advanced to used+1, so the attempt number is used+1.
60            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    /// Provider API key for http(s) llm-pipeline calls (Bearer header).
93    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        // Expand the input placeholder. Council specs reference the input key
115        // by name (e.g. {brief}, {plan}, {input}) — the daemon previously only
116        // expanded {input}, so prompts using {<input_key>} sent the literal
117        // placeholder to the model and analysts never received the coordinator
118        // output (observed 2026-08-03: analysts returned empty content).
119        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        // Reserve the provider attempt before any invocation; the limit cannot
125        // be bypassed by parallel nodes and failed calls still count.
126        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            // Remote OpenAI-compatible endpoints (http/https) must use the
160            // OpenAI backend (/v1/chat/completions). Without this, the
161            // llm-pipeline default is OllamaBackend, which sends an Ollama
162            // body to /api/generate and is rejected by OpenAI-compatible
163            // providers (observed 2026-08-03: maas returned HTTP 400 "Role
164            // must be in [user, assistant, ...]"). codex-app-server:// is
165            // handled in the branch above; local Ollama keeps the default.
166            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                // Attach the provider key as a Bearer header on the http(s)
174                // llm-pipeline path only. The key is never logged or echoed.
175                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        // A node may write only its declared output key. `__input__` is reserved
214        // for ingress and explicit legacy/router nodes; mirroring every LLM
215        // result there made parallel branches race for the graph's final state.
216        state.set_raw(&self.output_key, output).await?;
217        Ok(NodeOutput::Done)
218    }
219}
220
221impl LlmNode {
222    /// Append one typed invocation record for the terminal receipt. The record
223    /// is derived from the observed attempt, never from graph metadata alone.
224    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
473// ── HumanApprovalNode ──────────────────────────────────────────────────
474
475/// A node that signals an approval gate: writes the approval request to state
476/// and returns an error that the caller can handle.
477pub 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        // Check if a prior decision was already injected (e.g., via resume)
508        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        // Signal interrupt by returning a recognizable error.
515        // The graph engine's execute_with_interrupt will catch this.
516        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
525// ── ToolNode ───────────────────────────────────────────────────────────
526
527/// A graph node that spawns the Hermes tools MCP broker as a child process
528/// and invokes tools through MCP JSON-RPC over stdio.
529pub 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        // Write lease to a temp file the broker can read.
545        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        // Build the read-only tool call from graph state.
556        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        // Spawn the Hermes MCP broker.
567        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        // MCP initialize handshake.
590        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        // Send initialized notification.
615        let notified = serde_json::json!({
616            "jsonrpc": "2.0",
617            "method": "notifications/initialized"
618        });
619        writeln!(
620            stdin,
621            "{}",
622            serde_json::to_string(&notified)
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        // Make the tool call.
631        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 to signal EOF.
654        drop(stdin);
655
656        // Wait for child with timeout.
657        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        // Capture stderr for diagnostics.
668        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        // Parse MCP response.
677        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        // Check for errors.
693        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        // Read receipt ledger for verification.
705        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}