adk_graph/tool.rs
1//! Exposing a node or a whole graph as a tool an LLM can call.
2//!
3//! A graph is often the deterministic part of a system: fixed steps, checked
4//! state, a durable checkpoint. An `LlmAgent` is the part that decides. Handing
5//! the graph to the model as a tool lets the model choose *when* the
6//! deterministic part runs without deciding *how* it runs.
7//!
8//! adk-python exposes this as `NodeTool`; adk-go routes agent-as-tool through its
9//! dynamic sub-scheduler.
10//!
11//! # Schemas
12//!
13//! [`Node`](crate::node::Node) declares no input schema, so a tool over one node
14//! accepts any object unless a schema is supplied with
15//! [`with_parameters_schema`](NodeTool::with_parameters_schema). A tool over a
16//! whole graph derives its parameters from the graph's declared state channels,
17//! which are known.
18//!
19//! # Example
20//!
21//! ```rust,no_run
22//! use adk_graph::edge::{END, START};
23//! use adk_graph::graph::StateGraph;
24//! use adk_graph::node::NodeOutput;
25//! use adk_graph::tool::NodeTool;
26//! use serde_json::json;
27//! use std::sync::Arc;
28//!
29//! # fn build() -> Result<(), Box<dyn std::error::Error>> {
30//! let graph = Arc::new(
31//! StateGraph::with_channels(&["city", "forecast"])
32//! .add_node_fn("lookup", |ctx| async move {
33//! let city = ctx.get("city").and_then(|v| v.as_str()).unwrap_or("");
34//! Ok(NodeOutput::new().with_update("forecast", json!(format!("sunny in {city}"))))
35//! })
36//! .add_edge(START, "lookup")
37//! .add_edge("lookup", END)
38//! .compile()?,
39//! );
40//!
41//! let tool = NodeTool::for_graph(graph).with_description("Looks up a forecast.");
42//! // builder.tool(Arc::new(tool))
43//! # Ok(())
44//! # }
45//! ```
46
47use std::sync::Arc;
48
49use adk_core::{AdkError, Tool, ToolContext};
50use async_trait::async_trait;
51use serde_json::{Value, json};
52
53use crate::error::GraphError;
54use crate::graph::CompiledGraph;
55use crate::interrupt::GraphInterruptPayload;
56use crate::node::ExecutionConfig;
57
58/// What the tool invokes.
59enum Target {
60 /// One node, executed on its own.
61 Node(String),
62 /// The whole graph, from its entry points.
63 Graph,
64}
65
66/// A [`Tool`] that runs a graph node, or a whole graph.
67pub struct NodeTool {
68 graph: Arc<CompiledGraph>,
69 target: Target,
70 name: String,
71 description: String,
72 parameters_schema: Option<Value>,
73}
74
75impl NodeTool {
76 /// A tool that executes one node.
77 ///
78 /// The node runs alone: no edges are followed and no checkpoint is written.
79 pub fn for_node(graph: Arc<CompiledGraph>, node: impl Into<String>) -> Self {
80 let node = node.into();
81 Self {
82 graph,
83 name: node.clone(),
84 description: format!("Runs the '{node}' graph node."),
85 target: Target::Node(node),
86 parameters_schema: None,
87 }
88 }
89
90 /// A tool that executes the whole graph.
91 pub fn for_graph(graph: Arc<CompiledGraph>) -> Self {
92 Self {
93 graph,
94 target: Target::Graph,
95 name: "run_graph".to_string(),
96 description: "Runs a graph workflow to completion.".to_string(),
97 parameters_schema: None,
98 }
99 }
100
101 /// Set the name the model sees.
102 pub fn with_name(mut self, name: impl Into<String>) -> Self {
103 self.name = name.into();
104 self
105 }
106
107 /// Set the description the model sees.
108 ///
109 /// The default names the node or graph, which tells a model nothing about
110 /// when to call it. A real deployment should set this.
111 pub fn with_description(mut self, description: impl Into<String>) -> Self {
112 self.description = description.into();
113 self
114 }
115
116 /// Set the parameter schema explicitly.
117 pub fn with_parameters_schema(mut self, schema: Value) -> Self {
118 self.parameters_schema = Some(schema);
119 self
120 }
121
122 /// Parameters derived from the graph's declared state channels.
123 ///
124 /// Every channel is optional and untyped: a channel declares a reducer, not a
125 /// type, so nothing stronger is available.
126 fn derived_schema(&self) -> Value {
127 let mut properties = serde_json::Map::new();
128 for channel in self.graph.state_channels() {
129 properties.insert(channel, json!({ "description": "A graph state channel." }));
130 }
131 json!({ "type": "object", "properties": properties })
132 }
133}
134
135#[async_trait]
136impl Tool for NodeTool {
137 fn name(&self) -> &str {
138 &self.name
139 }
140
141 fn description(&self) -> &str {
142 &self.description
143 }
144
145 fn parameters_schema(&self) -> Option<Value> {
146 if let Some(schema) = &self.parameters_schema {
147 return Some(schema.clone());
148 }
149 match self.target {
150 // A node declares no schema, so anything is accepted.
151 Target::Node(_) => Some(json!({ "type": "object" })),
152 Target::Graph => Some(self.derived_schema()),
153 }
154 }
155
156 /// A graph may pause for approval, and a pause is not a value.
157 ///
158 /// Reporting the tool as long-running routes that pause through the existing
159 /// tool-confirmation path in `adk-agent` rather than inventing a second
160 /// mechanism for it.
161 fn is_long_running(&self) -> bool {
162 true
163 }
164
165 async fn execute(&self, ctx: Arc<dyn ToolContext>, args: Value) -> adk_core::Result<Value> {
166 let mut input = crate::state::State::new();
167 if let Value::Object(map) = args {
168 for (key, value) in map {
169 input.insert(key, value);
170 }
171 }
172
173 let config = ExecutionConfig::new(ctx.session_id());
174
175 match &self.target {
176 Target::Node(node) => {
177 let node_impl = self
178 .graph
179 .node(node)
180 .ok_or_else(|| AdkError::tool(format!("no graph node named '{node}'")))?;
181 let node_ctx = crate::node::NodeContext::new(input, config, 0);
182 let output = node_impl
183 .execute(&node_ctx)
184 .await
185 .map_err(|error| AdkError::tool(error.to_string()))?;
186 if let Some(interrupt) = output.interrupt {
187 return Ok(interrupt_value(&interrupt, ctx.session_id(), ""));
188 }
189 Ok(Value::Object(output.updates.into_iter().collect()))
190 }
191 Target::Graph => match self.graph.invoke(input, config).await {
192 Ok(state) => Ok(Value::Object(state.into_iter().collect())),
193 Err(GraphError::Interrupted(interrupted)) => Ok(interrupt_value(
194 &interrupted.interrupt,
195 &interrupted.thread_id,
196 &interrupted.checkpoint_id,
197 )),
198 Err(error) => Err(AdkError::tool(error.to_string())),
199 },
200 }
201 }
202}
203
204/// The value returned when the wrapped node or graph pauses.
205fn interrupt_value(
206 interrupt: &crate::interrupt::Interrupt,
207 thread_id: &str,
208 checkpoint_id: &str,
209) -> Value {
210 let payload = GraphInterruptPayload::new(interrupt, thread_id, checkpoint_id);
211 json!({
212 "status": "interrupted",
213 "interrupt": serde_json::to_value(&payload).unwrap_or(Value::Null),
214 })
215}