Skip to main content

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}