close
Skip to content

Commit 1263ed6

Browse files
DeanChensjcopybara-github
authored andcommitted
feat: Implement Workflow as Tool core feature
Introduce NodeTool, allowing individual Nodes and entire Workflows to be wrapped and executed as standard ADK Tools. This PR implements the core, single-turn execution capability: - Support auto-wrapping of BaseNode (Workflows) directly in Agent.tools. - Wrapping synchronous and asynchronous function nodes as NodeTools. - Providing a complete sample workflow demonstrating how to run a workflow as a tool. Resumption and multi-turn nested HITL support are skipped in this PR and will be fully enabled in the later PR. Co-authored-by: Shangjie Chen <deanchen@google.com> PiperOrigin-RevId: 943499058
1 parent c14258d commit 1263ed6

7 files changed

Lines changed: 1526 additions & 4 deletions

File tree

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
# Node as Tool
2+
3+
## Overview
4+
5+
Demonstrates wrapping both a regular ADK `Node` (using the `@node` decorator) and a `Workflow` as tools that can be automatically called by a parent `Agent`.
6+
7+
In this sample:
8+
9+
1. The parent agent receives an inquiry about a customer's discount.
10+
1. It invokes `customer_lookup_workflow` (a `Workflow` wrapped as a tool) to retrieve customer status.
11+
1. It then invokes `calculate_discount` (a regular `Node` wrapped as a tool) using the retrieved status.
12+
13+
## Sample Inputs
14+
15+
- `What discount does customer c123 get?`
16+
17+
*The parent agent first invokes `customer_lookup_workflow` to verify status, then invokes `calculate_discount` to determine the discount percentage, and summarizes the results.*
18+
19+
## Agent Topology Graph
20+
21+
```mermaid
22+
graph TD
23+
customer_service_agent[customer_service_agent] -->|calls| customer_lookup_workflow(customer_lookup_workflow)
24+
customer_service_agent -->|calls| calculate_discount(calculate_discount)
25+
```
26+
27+
## How To
28+
29+
To expose an existing `Node` or `Workflow` as a tool callable by an `Agent`:
30+
31+
1. Define your `Node` (or `@node`) or `Workflow` and assign both an `input_schema` and a `description`.
32+
1. Pass the node/workflow directly into your parent agent's `tools` list: `Agent(..., tools=[my_node, my_workflow])`.
33+
34+
## Related Guides
35+
36+
- [Workflows](../../../../docs/guides/workflows/workflows.md) - Explains building complex multi-step graphs.
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
# Copyright 2026 Google LLC
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# http://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
15+
from __future__ import annotations
16+
17+
from typing import Generator
18+
19+
from google.adk import Agent
20+
from google.adk import Event
21+
from google.adk import Workflow
22+
from google.adk.workflow import node
23+
from pydantic import BaseModel
24+
from pydantic import Field
25+
26+
27+
# 1. Define schemas
28+
class CustomerLookupArgs(BaseModel):
29+
user_id: str = Field(description="The customer's unique identifier.")
30+
31+
32+
# 2. Define a regular Node using the @node decorator.
33+
# This Node is wrapped as a NodeTool automatically by the Agent.
34+
# As a NodeTool, it has the ability to yield intermediate Events during execution.
35+
@node
36+
def calculate_discount(tier: str, ctx) -> Generator[Event | str, None, None]:
37+
"""Calculates the discount percentage based on customer tier.
38+
39+
Args:
40+
tier: The customer's membership tier (e.g., VIP, Standard).
41+
"""
42+
yield Event(message=f"Checking discount rules for tier '{tier}'...")
43+
discount = "20% off" if "VIP" in tier else "5% off"
44+
yield discount
45+
46+
47+
# 3. Define a Workflow.
48+
# This Workflow is wrapped as a NodeTool automatically by the Agent.
49+
def lookup_customer_data(node_input: CustomerLookupArgs, ctx) -> dict[str, str]:
50+
return {"user_id": node_input.user_id, "tier": "Verified VIP Member"}
51+
52+
53+
customer_lookup_workflow = Workflow(
54+
name="customer_lookup_workflow",
55+
description="Looks up customer status and tier by user_id.",
56+
input_schema=CustomerLookupArgs,
57+
edges=[
58+
("START", lookup_customer_data),
59+
],
60+
)
61+
62+
63+
# 4. Define the Agent that uses both Node and Workflow as tools.
64+
root_agent = Agent(
65+
name="customer_service_agent",
66+
instruction="""
67+
You are a customer service assistant.
68+
1. First, call `customer_lookup_workflow` using the user_id to get their membership tier.
69+
2. Then, call `calculate_discount` node with that tier to find out what discount they get.
70+
Summarize these details for the customer.
71+
""",
72+
tools=[customer_lookup_workflow, calculate_discount],
73+
)
Lines changed: 182 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,182 @@
1+
{
2+
"appName": "node_as_tool",
3+
"events": [
4+
{
5+
"author": "user",
6+
"content": {
7+
"parts": [
8+
{
9+
"text": "What discount does customer c123 get?"
10+
}
11+
],
12+
"role": "user"
13+
},
14+
"id": "e-1",
15+
"invocationId": "i-1",
16+
"nodeInfo": {
17+
"path": ""
18+
}
19+
},
20+
{
21+
"author": "customer_service_agent",
22+
"content": {
23+
"parts": [
24+
{
25+
"functionCall": {
26+
"args": {
27+
"user_id": "c123"
28+
},
29+
"id": "fc-1",
30+
"name": "customer_lookup_workflow"
31+
}
32+
}
33+
],
34+
"role": "model"
35+
},
36+
"id": "e-2",
37+
"invocationId": "i-1",
38+
"longRunningToolIds": [
39+
"fc-1"
40+
],
41+
"nodeInfo": {
42+
"path": "customer_service_agent@1"
43+
}
44+
},
45+
{
46+
"author": "customer_lookup_workflow",
47+
"branch": "customer_lookup_workflow@fc-1",
48+
"id": "e-3",
49+
"invocationId": "i-1",
50+
"nodeInfo": {
51+
"outputFor": [
52+
"customer_lookup_workflow@1/lookup_customer_data@1",
53+
"customer_lookup_workflow@1"
54+
],
55+
"path": "customer_lookup_workflow@1/lookup_customer_data@1"
56+
},
57+
"output": {
58+
"tier": "Verified VIP Member",
59+
"user_id": "c123"
60+
}
61+
},
62+
{
63+
"author": "customer_service_agent",
64+
"content": {
65+
"parts": [
66+
{
67+
"functionResponse": {
68+
"id": "fc-1",
69+
"name": "customer_lookup_workflow",
70+
"response": {
71+
"tier": "Verified VIP Member",
72+
"user_id": "c123"
73+
}
74+
}
75+
}
76+
],
77+
"role": "user"
78+
},
79+
"id": "e-4",
80+
"invocationId": "i-1",
81+
"nodeInfo": {
82+
"path": "customer_service_agent@1"
83+
}
84+
},
85+
{
86+
"author": "customer_service_agent",
87+
"content": {
88+
"parts": [
89+
{
90+
"functionCall": {
91+
"args": {
92+
"tier": "Verified VIP Member"
93+
},
94+
"id": "fc-2",
95+
"name": "calculate_discount"
96+
}
97+
}
98+
],
99+
"role": "model"
100+
},
101+
"id": "e-5",
102+
"invocationId": "i-1",
103+
"longRunningToolIds": [
104+
"fc-2"
105+
],
106+
"nodeInfo": {
107+
"path": "customer_service_agent@1"
108+
}
109+
},
110+
111+
{
112+
"author": "calculate_discount",
113+
"branch": "calculate_discount@fc-2",
114+
"content": {
115+
"parts": [
116+
{
117+
"text": "Checking discount rules for tier 'Verified VIP Member'..."
118+
}
119+
],
120+
"role": "user"
121+
},
122+
"id": "e-6",
123+
"invocationId": "i-1",
124+
"nodeInfo": {
125+
"path": "calculate_discount@1"
126+
}
127+
},
128+
{
129+
"author": "calculate_discount",
130+
"branch": "calculate_discount@fc-2",
131+
"id": "e-7",
132+
"invocationId": "i-1",
133+
"nodeInfo": {
134+
"outputFor": [
135+
"calculate_discount@1"
136+
],
137+
"path": "calculate_discount@1"
138+
},
139+
"output": "20% off"
140+
},
141+
{
142+
"author": "customer_service_agent",
143+
"content": {
144+
"parts": [
145+
{
146+
"functionResponse": {
147+
"id": "fc-2",
148+
"name": "calculate_discount",
149+
"response": {
150+
"result": "20% off"
151+
}
152+
}
153+
}
154+
],
155+
"role": "user"
156+
},
157+
"id": "e-8",
158+
"invocationId": "i-1",
159+
"nodeInfo": {
160+
"path": "customer_service_agent@1"
161+
}
162+
},
163+
{
164+
"author": "customer_service_agent",
165+
"content": {
166+
"parts": [
167+
{
168+
"text": "Customer c123 is a Verified VIP Member and gets a 20% discount."
169+
}
170+
],
171+
"role": "model"
172+
},
173+
"id": "e-9",
174+
"invocationId": "i-1",
175+
"nodeInfo": {
176+
"path": "customer_service_agent@1"
177+
}
178+
}
179+
],
180+
"id": "12345678-1234-1234-1234-123456789abc",
181+
"userId": "user"
182+
}

‎src/google/adk/agents/llm_agent.py‎

Lines changed: 54 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -133,7 +133,6 @@
133133
InstructionProvider: TypeAlias = Callable[
134134
[ReadonlyContext], Union[str, Awaitable[str]]
135135
]
136-
137136
ToolUnion: TypeAlias = Union[Callable, BaseTool, BaseToolset]
138137

139138

@@ -175,6 +174,32 @@ async def _convert_tool_union_to_tools(
175174
max_results=vais_tool.max_results,
176175
)
177176
]
177+
from ..workflow._base_node import BaseNode
178+
179+
if isinstance(tool_union, BaseNode):
180+
from ..tools._node_tool import NodeTool
181+
from .base_agent import BaseAgent
182+
183+
if isinstance(tool_union, BaseAgent):
184+
raise ValueError(
185+
f"Agent '{tool_union.name}' cannot be wrapped as a NodeTool. Agents"
186+
' should be invoked as sub-agents.'
187+
)
188+
189+
description = tool_union.description
190+
if not description:
191+
raise ValueError(
192+
f"Workflow/Node '{tool_union.name}' must have a description to be"
193+
' wrapped as a tool.'
194+
)
195+
196+
return [
197+
NodeTool(
198+
node=tool_union,
199+
name=tool_union.name,
200+
description=description,
201+
)
202+
]
178203

179204
if isinstance(tool_union, BaseTool):
180205
return [tool_union]
@@ -1011,6 +1036,34 @@ def __maybe_accumulate_streaming_output(
10111036
event.actions.state_delta[self.output_key] = accumulator
10121037
return accumulator
10131038

1039+
@model_validator(mode='before')
1040+
@classmethod
1041+
def _pre_validate_tools(cls, data: Any) -> Any:
1042+
if isinstance(data, dict) and 'tools' in data and data['tools']:
1043+
from google.adk.agents.base_agent import BaseAgent
1044+
from google.adk.tools._node_tool import NodeTool
1045+
from google.adk.workflow._base_node import BaseNode
1046+
1047+
new_tools = []
1048+
for t in data['tools']:
1049+
if isinstance(t, BaseAgent):
1050+
raise ValueError(
1051+
f"Agent '{t.name}' cannot be wrapped as a NodeTool. Agents should"
1052+
' be invoked as sub-agents.'
1053+
)
1054+
elif isinstance(t, BaseNode):
1055+
description = t.description
1056+
if not description:
1057+
raise ValueError(
1058+
f"Workflow/Node '{t.name}' must have a description to be"
1059+
' wrapped as a tool.'
1060+
)
1061+
new_tools.append(NodeTool(node=t, description=description))
1062+
else:
1063+
new_tools.append(t)
1064+
data['tools'] = new_tools
1065+
return data
1066+
10141067
@model_validator(mode='after')
10151068
def __model_validator_after(self) -> LlmAgent:
10161069
return self

0 commit comments

Comments
 (0)