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}