Skip to main content

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}