ri_agent_graph/payload.rs
1//! Payload trait and PayloadNode for integrating external work units.
2//!
3//! The [`Payload`] trait is the boundary between the graph orchestrator and
4//! external execution logic (e.g., LLM calls from `llm-pipeline`).
5//! The graph runtime never implements payload logic — it only orchestrates execution.
6
7use crate::command::NodeOutput;
8use crate::config::GraphConfig;
9use crate::error::AgentGraphError;
10use crate::node::Node;
11use crate::state::AgentState;
12use serde::{Deserialize, Serialize};
13use serde_json::Value;
14use std::collections::HashMap;
15use std::future::Future;
16use std::pin::Pin;
17use std::sync::Arc;
18
19/// Error type for payload invocations.
20pub type PayloadError = Box<dyn std::error::Error + Send + Sync>;
21
22/// Token callback type for streaming.
23pub type TokenCallback = Arc<dyn Fn(&str) + Send + Sync>;
24
25/// Function that extracts payload input from graph state.
26pub type InputSelector = Box<dyn Fn(&Value) -> Value + Send + Sync>;
27
28/// Function that maps payload output back into graph state.
29pub type OutputMapper = Box<dyn Fn(&Value, &PayloadOutput) -> Value + Send + Sync>;
30
31/// Output from a payload execution.
32#[derive(Debug, Clone, Serialize, Deserialize)]
33pub struct PayloadOutput {
34 /// The primary output value.
35 pub value: Value,
36 /// Additional metadata from the execution (e.g., token counts, latency).
37 #[serde(default)]
38 pub meta: HashMap<String, Value>,
39}
40
41/// Context passed to payloads during execution.
42///
43/// Provides a token sink for streaming and metadata about the current execution.
44pub struct PayloadContext {
45 /// Callback for streaming tokens. Called by the payload during execution.
46 /// The runtime sets this up to forward tokens to the [`EventSink`](crate::event_sink::EventSink).
47 pub on_token: Option<TokenCallback>,
48 /// The current run ID.
49 pub run_id: String,
50 /// The current node ID.
51 pub node_id: String,
52}
53
54/// A unit of work that can be executed by the graph runtime.
55///
56/// This is the canonical interface between the orchestrator and external
57/// payload implementations (e.g., `llm-pipeline`). Implementors provide
58/// the actual computation; the graph runtime handles scheduling, checkpointing,
59/// and event emission.
60///
61/// Uses boxed futures instead of async-trait.
62pub trait Payload: Send + Sync {
63 /// Execute this payload with the given input.
64 ///
65 /// - `input`: extracted from graph state via `input_selector`.
66 /// - `ctx`: provides token streaming callback and execution metadata.
67 fn invoke(
68 &self,
69 input: Value,
70 ctx: &PayloadContext,
71 ) -> Pin<Box<dyn Future<Output = std::result::Result<PayloadOutput, PayloadError>> + Send + '_>>;
72}
73
74/// A node that wraps a [`Payload`] for execution within the graph.
75///
76/// `input_selector` extracts what to pass to the payload from graph state.
77/// `output_mapper` folds the payload output back into graph state.
78///
79/// Defaults: pass entire state as input; replace entire state with `output.value`.
80pub struct PayloadNode {
81 name: Option<String>,
82 payload: Box<dyn Payload>,
83 /// Extracts node input from the current state (as JSON object).
84 /// `fn(state_value) -> payload_input`
85 /// Default: passes the entire state.
86 input_selector: Option<InputSelector>,
87 /// Maps the payload output back into state.
88 /// `fn(current_state, payload_output) -> new_state`
89 /// Default: deep-merge output.value into state.
90 output_mapper: Option<OutputMapper>,
91}
92
93impl PayloadNode {
94 /// Create a new PayloadNode wrapping a payload.
95 pub fn new(payload: Box<dyn Payload>) -> Self {
96 Self {
97 name: None,
98 payload,
99 input_selector: None,
100 output_mapper: None,
101 }
102 }
103
104 /// Set a name for this node (for debugging and events).
105 pub fn with_name(mut self, name: impl Into<String>) -> Self {
106 self.name = Some(name.into());
107 self
108 }
109
110 /// Set the input selector function.
111 ///
112 /// Receives the entire state as a JSON value and returns the value
113 /// to pass to the payload's `invoke` method.
114 pub fn with_input_selector(
115 mut self,
116 selector: impl Fn(&Value) -> Value + Send + Sync + 'static,
117 ) -> Self {
118 self.input_selector = Some(Box::new(selector));
119 self
120 }
121
122 /// Set the output mapper function.
123 ///
124 /// Receives the current state and the payload output, returns the new state.
125 pub fn with_output_mapper(
126 mut self,
127 mapper: impl Fn(&Value, &PayloadOutput) -> Value + Send + Sync + 'static,
128 ) -> Self {
129 self.output_mapper = Some(Box::new(mapper));
130 self
131 }
132}
133
134#[async_trait::async_trait]
135impl Node for PayloadNode {
136 async fn execute(
137 &self,
138 state: &AgentState,
139 _config: &GraphConfig,
140 ) -> crate::Result<NodeOutput> {
141 // Build state value
142 let state_data = state.export().await;
143 let state_value = serde_json::to_value(&state_data)
144 .map_err(|e| AgentGraphError::StateError(e.to_string()))?;
145
146 // Select input
147 let input = match &self.input_selector {
148 Some(selector) => selector(&state_value),
149 None => state_value.clone(),
150 };
151
152 // Build payload context (no token sink in basic execution path;
153 // the GraphExecutor sets this up when EventSink is configured)
154 let ctx = PayloadContext {
155 on_token: None,
156 run_id: String::new(),
157 node_id: self.name.clone().unwrap_or_default(),
158 };
159
160 // Invoke payload
161 let output = self
162 .payload
163 .invoke(input, &ctx)
164 .await
165 .map_err(|e| AgentGraphError::PayloadError(e.to_string()))?;
166
167 // Map output back to state
168 let new_state_value = match &self.output_mapper {
169 Some(mapper) => mapper(&state_value, &output),
170 None => {
171 // Default: merge output.value into state
172 merge_value(&state_value, &output.value)
173 }
174 };
175
176 // Apply the new state
177 if let Value::Object(map) = new_state_value {
178 for (key, value) in map {
179 state.set_raw(&key, value).await?;
180 }
181 }
182
183 Ok(NodeOutput::Done)
184 }
185
186 fn name(&self) -> Option<&str> {
187 self.name.as_deref()
188 }
189}
190
191/// Default merge: if output is an object, merge keys into state.
192/// Otherwise, replace the entire state with the output.
193fn merge_value(state: &Value, output: &Value) -> Value {
194 match (state, output) {
195 (Value::Object(s), Value::Object(o)) => {
196 let mut result = s.clone();
197 for (k, v) in o {
198 result.insert(k.clone(), v.clone());
199 }
200 Value::Object(result)
201 }
202 _ => output.clone(),
203 }
204}
205
206impl std::fmt::Debug for PayloadNode {
207 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
208 f.debug_struct("PayloadNode")
209 .field("name", &self.name)
210 .finish()
211 }
212}