Skip to main content

adk_graph/
subgraph.rs

1//! Running one graph as a node of another.
2//!
3//! A subgraph keeps its own channels, its own edges and its own interrupt gates,
4//! and exchanges named channels with its parent. Nesting through
5//! [`AgentNode`](crate::node::AgentNode) instead would force the state through the
6//! `Agent` boundary as `Content`, and a pause inside would arrive as an event the
7//! parent reports rather than a pause the parent honours.
8//!
9//! # Channels are checked when the parent compiles
10//!
11//! Both schemas are known before anything runs, so a mapping that names a channel
12//! neither side declares is a [`compile`](crate::graph::StateGraph::compile) error
13//! naming the channel and the side. A mismatch cannot reach a run and surface as
14//! an absent value.
15//!
16//! # Example
17//!
18//! ```
19//! use adk_graph::edge::{END, START};
20//! use adk_graph::graph::StateGraph;
21//! use adk_graph::node::NodeOutput;
22//! use adk_graph::subgraph::SubgraphNode;
23//! use serde_json::json;
24//! use std::sync::Arc;
25//!
26//! // The inner graph knows nothing about its parent.
27//! let inner = StateGraph::with_channels(&["text", "length"])
28//!     .add_node_fn("measure", |ctx| async move {
29//!         let text = ctx.get("text").and_then(|v| v.as_str()).unwrap_or("");
30//!         Ok(NodeOutput::new().with_update("length", json!(text.len())))
31//!     })
32//!     .add_edge(START, "measure")
33//!     .add_edge("measure", END)
34//!     .compile()?;
35//!
36//! let outer = StateGraph::with_channels(&["document", "size"])
37//!     .add_node(
38//!         SubgraphNode::new("measure_doc", Arc::new(inner))
39//!             .with_input("document", "text")
40//!             .with_output("length", "size"),
41//!     )
42//!     .add_edge(START, "measure_doc")
43//!     .add_edge("measure_doc", END)
44//!     .compile()?;
45//! # let _ = outer;
46//! # Ok::<(), adk_graph::error::GraphError>(())
47//! ```
48
49use std::collections::HashMap;
50use std::sync::Arc;
51
52use async_trait::async_trait;
53use serde_json::Value;
54
55use crate::error::{GraphError, Result};
56use crate::graph::CompiledGraph;
57use crate::interrupt::Interrupt;
58use crate::node::{ExecutionConfig, Node, NodeContext, NodeOutput};
59use crate::state::{State, StateSchema};
60
61/// Which side of a subgraph mapping a channel name belongs to.
62#[derive(Debug, Clone, Copy, PartialEq, Eq)]
63pub enum ChannelSide {
64    /// A channel of the graph that holds the subgraph.
65    Parent,
66    /// A channel of the subgraph itself.
67    Child,
68}
69
70impl std::fmt::Display for ChannelSide {
71    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
72        match self {
73            Self::Parent => write!(f, "parent"),
74            Self::Child => write!(f, "subgraph"),
75        }
76    }
77}
78
79/// One graph running as a node of another.
80///
81/// See the [module documentation](self) for the channel rules.
82pub struct SubgraphNode {
83    name: String,
84    graph: Arc<CompiledGraph>,
85    /// Parent channel to subgraph channel, applied before the subgraph runs.
86    inputs: Vec<(String, String)>,
87    /// Subgraph channel to parent channel, applied after it finishes.
88    outputs: Vec<(String, String)>,
89    /// Whether channels the two schemas share pass through without being named.
90    share_by_name: bool,
91}
92
93impl SubgraphNode {
94    /// Wraps a compiled graph as a node.
95    ///
96    /// Channels the two schemas declare under the same name pass through in both
97    /// directions. Add [`Self::with_input`] or [`Self::with_output`] for channels
98    /// whose names differ, or call [`Self::isolated`] to pass nothing implicitly.
99    pub fn new(name: impl Into<String>, graph: Arc<CompiledGraph>) -> Self {
100        Self {
101            name: name.into(),
102            graph,
103            inputs: Vec::new(),
104            outputs: Vec::new(),
105            share_by_name: true,
106        }
107    }
108
109    /// Feeds a parent channel into a subgraph channel under a different name.
110    pub fn with_input(mut self, parent: impl Into<String>, child: impl Into<String>) -> Self {
111        self.inputs.push((parent.into(), child.into()));
112        self
113    }
114
115    /// Writes a subgraph channel back to a parent channel under a different name.
116    pub fn with_output(mut self, child: impl Into<String>, parent: impl Into<String>) -> Self {
117        self.outputs.push((child.into(), parent.into()));
118        self
119    }
120
121    /// Exchanges only the channels named by `with_input` and `with_output`.
122    ///
123    /// Without this, a channel both schemas declare under the same name passes
124    /// through. Isolating is worth the extra naming when the two graphs are
125    /// maintained apart, because then adding a channel to one cannot silently
126    /// start feeding the other.
127    pub fn isolated(mut self) -> Self {
128        self.share_by_name = false;
129        self
130    }
131
132    /// The graph this node runs.
133    pub fn graph(&self) -> &Arc<CompiledGraph> {
134        &self.graph
135    }
136
137    /// Channels shared by name, when that is enabled.
138    fn shared_with(&self, parent: &StateSchema) -> Vec<String> {
139        if !self.share_by_name {
140            return Vec::new();
141        }
142        let mut names: Vec<String> = self
143            .graph
144            .schema
145            .channels
146            .keys()
147            .filter(|name| parent.channels.contains_key(*name))
148            .cloned()
149            .collect();
150        names.sort();
151        names
152    }
153
154    /// Builds the subgraph's input state from the parent's.
155    fn project_in(&self, parent_state: &State, parent_schema: &StateSchema) -> State {
156        let mut input = State::new();
157        for name in self.shared_with(parent_schema) {
158            if let Some(value) = parent_state.get(&name) {
159                input.insert(name, value.clone());
160            }
161        }
162        for (parent_name, child_name) in &self.inputs {
163            if let Some(value) = parent_state.get(parent_name) {
164                input.insert(child_name.clone(), value.clone());
165            }
166        }
167        input
168    }
169
170    /// Builds the parent's updates from the subgraph's final state.
171    fn project_out(
172        &self,
173        child_state: &State,
174        parent_schema: &StateSchema,
175    ) -> HashMap<String, Value> {
176        let mut updates = HashMap::new();
177        for name in self.shared_with(parent_schema) {
178            if let Some(value) = child_state.get(&name) {
179                updates.insert(name, value.clone());
180            }
181        }
182        for (child_name, parent_name) in &self.outputs {
183            if let Some(value) = child_state.get(child_name) {
184                updates.insert(parent_name.clone(), value.clone());
185            }
186        }
187        updates
188    }
189
190    /// The thread the subgraph runs on, derived from the parent's.
191    ///
192    /// Namespacing by node name keeps two subgraphs of one parent apart, and keeps
193    /// a subgraph's checkpoints from colliding with its parent's.
194    fn child_thread(&self, parent_thread: &str) -> String {
195        format!("{parent_thread}/{}", self.name)
196    }
197}
198
199#[async_trait]
200impl Node for SubgraphNode {
201    fn name(&self) -> &str {
202        &self.name
203    }
204
205    /// Rejects a mapping that names a channel the relevant side does not declare.
206    ///
207    /// This runs when the parent compiles, so a mismatch never reaches a run.
208    fn validate_against(&self, parent: &StateSchema) -> Result<()> {
209        let child = &self.graph.schema;
210        let mismatch = |channel: &str, side: ChannelSide| {
211            Err(GraphError::SubgraphChannelMismatch {
212                subgraph: self.name.clone(),
213                channel: channel.to_string(),
214                side: side.to_string(),
215            })
216        };
217
218        for (parent_name, child_name) in &self.inputs {
219            if !parent.channels.contains_key(parent_name) {
220                return mismatch(parent_name, ChannelSide::Parent);
221            }
222            if !child.channels.contains_key(child_name) {
223                return mismatch(child_name, ChannelSide::Child);
224            }
225        }
226        for (child_name, parent_name) in &self.outputs {
227            if !child.channels.contains_key(child_name) {
228                return mismatch(child_name, ChannelSide::Child);
229            }
230            if !parent.channels.contains_key(parent_name) {
231                return mismatch(parent_name, ChannelSide::Parent);
232            }
233        }
234
235        // A subgraph that can pause but keeps no checkpoints cannot resume: the
236        // parent re-enters it and it starts from its first node, repeating whatever
237        // it had already done. Both facts are known now, so this is a compile
238        // error rather than work silently paid for twice.
239        if self.graph.can_pause() && !self.graph.has_checkpointer() {
240            return Err(GraphError::InvalidGraph(format!(
241                "subgraph '{}' has interrupt gates but no checkpointer, so a pause \
242                 inside it could not be resumed and its finished work would run \
243                 again. Add one with with_checkpointer",
244                self.name
245            )));
246        }
247
248        // A subgraph that exchanges nothing cannot affect its parent, which is
249        // almost always a naming mistake rather than an intention.
250        if self.inputs.is_empty() && self.outputs.is_empty() && self.shared_with(parent).is_empty()
251        {
252            return Err(GraphError::InvalidGraph(format!(
253                "subgraph '{}' exchanges no channels with its parent. Name them with \
254                 with_input and with_output, or share a channel name",
255                self.name
256            )));
257        }
258        Ok(())
259    }
260
261    async fn execute(&self, ctx: &NodeContext) -> Result<NodeOutput> {
262        let parent_schema = ctx.parent_schema().ok_or_else(|| {
263            GraphError::InvalidGraph(format!(
264                "subgraph '{}' ran without its parent's schema. This is an executor \
265                 defect, not a configuration error",
266                self.name
267            ))
268        })?;
269
270        let input = self.project_in(&ctx.state, &parent_schema);
271        let thread = self.child_thread(&ctx.config.thread_id);
272        let config = ExecutionConfig::new(&thread);
273
274        match self.graph.invoke_detailed(input, config).await {
275            Ok(outcome) => {
276                let mut output = NodeOutput::new()
277                    .with_updates(self.project_out(&outcome.state, &parent_schema));
278                // A node inside asked for a node of this graph's parent. Becoming
279                // this node's own goto is what makes it happen, and it also means
280                // the parent validates the target, as it does for any goto.
281                if let Some(targets) = outcome.goto_parent {
282                    output = output.with_goto(targets);
283                }
284                Ok(output)
285            }
286            // A pause inside is a pause of the whole run. Reported with the
287            // subgraph's name in front of the inner node, so a deep pause says
288            // where it happened, and the subgraph's own thread holds the state to
289            // resume from.
290            Err(GraphError::Interrupted(inner)) => {
291                let message = match &inner.interrupt {
292                    Interrupt::Dynamic { message, .. } => message.clone(),
293                    other => other.to_string(),
294                };
295                let data = match &inner.interrupt {
296                    Interrupt::Dynamic { data, .. } => data.clone(),
297                    _ => None,
298                };
299                let mut payload = serde_json::Map::new();
300                payload.insert("subgraph".to_string(), Value::String(self.name.clone()));
301                payload.insert("thread".to_string(), Value::String(thread));
302                if let Some(data) = data {
303                    payload.insert("data".to_string(), data);
304                }
305                Ok(NodeOutput::interrupt_with_data(
306                    &format!("{}: {message}", self.name),
307                    Value::Object(payload),
308                ))
309            }
310            Err(error) => Err(error),
311        }
312    }
313}