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
This commit is contained in:
Shangjie Chen
2026-07-06 14:32:19 -07:00
committed by Copybara-Service
parent c14258dffc
commit 1263ed64e3
7 changed files with 1526 additions and 4 deletions
@@ -0,0 +1,36 @@
# Node as Tool
## Overview
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`.
In this sample:
1. The parent agent receives an inquiry about a customer's discount.
1. It invokes `customer_lookup_workflow` (a `Workflow` wrapped as a tool) to retrieve customer status.
1. It then invokes `calculate_discount` (a regular `Node` wrapped as a tool) using the retrieved status.
## Sample Inputs
- `What discount does customer c123 get?`
*The parent agent first invokes `customer_lookup_workflow` to verify status, then invokes `calculate_discount` to determine the discount percentage, and summarizes the results.*
## Agent Topology Graph
```mermaid
graph TD
customer_service_agent[customer_service_agent] -->|calls| customer_lookup_workflow(customer_lookup_workflow)
customer_service_agent -->|calls| calculate_discount(calculate_discount)
```
## How To
To expose an existing `Node` or `Workflow` as a tool callable by an `Agent`:
1. Define your `Node` (or `@node`) or `Workflow` and assign both an `input_schema` and a `description`.
1. Pass the node/workflow directly into your parent agent's `tools` list: `Agent(..., tools=[my_node, my_workflow])`.
## Related Guides
- [Workflows](../../../../docs/guides/workflows/workflows.md) - Explains building complex multi-step graphs.
@@ -0,0 +1,73 @@
# Copyright 2026 Google LLC
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
from __future__ import annotations
from typing import Generator
from google.adk import Agent
from google.adk import Event
from google.adk import Workflow
from google.adk.workflow import node
from pydantic import BaseModel
from pydantic import Field
# 1. Define schemas
class CustomerLookupArgs(BaseModel):
user_id: str = Field(description="The customer's unique identifier.")
# 2. Define a regular Node using the @node decorator.
# This Node is wrapped as a NodeTool automatically by the Agent.
# As a NodeTool, it has the ability to yield intermediate Events during execution.
@node
def calculate_discount(tier: str, ctx) -> Generator[Event | str, None, None]:
"""Calculates the discount percentage based on customer tier.
Args:
tier: The customer's membership tier (e.g., VIP, Standard).
"""
yield Event(message=f"Checking discount rules for tier '{tier}'...")
discount = "20% off" if "VIP" in tier else "5% off"
yield discount
# 3. Define a Workflow.
# This Workflow is wrapped as a NodeTool automatically by the Agent.
def lookup_customer_data(node_input: CustomerLookupArgs, ctx) -> dict[str, str]:
return {"user_id": node_input.user_id, "tier": "Verified VIP Member"}
customer_lookup_workflow = Workflow(
name="customer_lookup_workflow",
description="Looks up customer status and tier by user_id.",
input_schema=CustomerLookupArgs,
edges=[
("START", lookup_customer_data),
],
)
# 4. Define the Agent that uses both Node and Workflow as tools.
root_agent = Agent(
name="customer_service_agent",
instruction="""
You are a customer service assistant.
1. First, call `customer_lookup_workflow` using the user_id to get their membership tier.
2. Then, call `calculate_discount` node with that tier to find out what discount they get.
Summarize these details for the customer.
""",
tools=[customer_lookup_workflow, calculate_discount],
)
@@ -0,0 +1,182 @@
{
"appName": "node_as_tool",
"events": [
{
"author": "user",
"content": {
"parts": [
{
"text": "What discount does customer c123 get?"
}
],
"role": "user"
},
"id": "e-1",
"invocationId": "i-1",
"nodeInfo": {
"path": ""
}
},
{
"author": "customer_service_agent",
"content": {
"parts": [
{
"functionCall": {
"args": {
"user_id": "c123"
},
"id": "fc-1",
"name": "customer_lookup_workflow"
}
}
],
"role": "model"
},
"id": "e-2",
"invocationId": "i-1",
"longRunningToolIds": [
"fc-1"
],
"nodeInfo": {
"path": "customer_service_agent@1"
}
},
{
"author": "customer_lookup_workflow",
"branch": "customer_lookup_workflow@fc-1",
"id": "e-3",
"invocationId": "i-1",
"nodeInfo": {
"outputFor": [
"customer_lookup_workflow@1/lookup_customer_data@1",
"customer_lookup_workflow@1"
],
"path": "customer_lookup_workflow@1/lookup_customer_data@1"
},
"output": {
"tier": "Verified VIP Member",
"user_id": "c123"
}
},
{
"author": "customer_service_agent",
"content": {
"parts": [
{
"functionResponse": {
"id": "fc-1",
"name": "customer_lookup_workflow",
"response": {
"tier": "Verified VIP Member",
"user_id": "c123"
}
}
}
],
"role": "user"
},
"id": "e-4",
"invocationId": "i-1",
"nodeInfo": {
"path": "customer_service_agent@1"
}
},
{
"author": "customer_service_agent",
"content": {
"parts": [
{
"functionCall": {
"args": {
"tier": "Verified VIP Member"
},
"id": "fc-2",
"name": "calculate_discount"
}
}
],
"role": "model"
},
"id": "e-5",
"invocationId": "i-1",
"longRunningToolIds": [
"fc-2"
],
"nodeInfo": {
"path": "customer_service_agent@1"
}
},
{
"author": "calculate_discount",
"branch": "calculate_discount@fc-2",
"content": {
"parts": [
{
"text": "Checking discount rules for tier 'Verified VIP Member'..."
}
],
"role": "user"
},
"id": "e-6",
"invocationId": "i-1",
"nodeInfo": {
"path": "calculate_discount@1"
}
},
{
"author": "calculate_discount",
"branch": "calculate_discount@fc-2",
"id": "e-7",
"invocationId": "i-1",
"nodeInfo": {
"outputFor": [
"calculate_discount@1"
],
"path": "calculate_discount@1"
},
"output": "20% off"
},
{
"author": "customer_service_agent",
"content": {
"parts": [
{
"functionResponse": {
"id": "fc-2",
"name": "calculate_discount",
"response": {
"result": "20% off"
}
}
}
],
"role": "user"
},
"id": "e-8",
"invocationId": "i-1",
"nodeInfo": {
"path": "customer_service_agent@1"
}
},
{
"author": "customer_service_agent",
"content": {
"parts": [
{
"text": "Customer c123 is a Verified VIP Member and gets a 20% discount."
}
],
"role": "model"
},
"id": "e-9",
"invocationId": "i-1",
"nodeInfo": {
"path": "customer_service_agent@1"
}
}
],
"id": "12345678-1234-1234-1234-123456789abc",
"userId": "user"
}
+54 -1
View File
@@ -133,7 +133,6 @@ OnToolErrorCallback: TypeAlias = Union[
InstructionProvider: TypeAlias = Callable[
[ReadonlyContext], Union[str, Awaitable[str]]
]
ToolUnion: TypeAlias = Union[Callable, BaseTool, BaseToolset]
@@ -175,6 +174,32 @@ async def _convert_tool_union_to_tools(
max_results=vais_tool.max_results,
)
]
from ..workflow._base_node import BaseNode
if isinstance(tool_union, BaseNode):
from ..tools._node_tool import NodeTool
from .base_agent import BaseAgent
if isinstance(tool_union, BaseAgent):
raise ValueError(
f"Agent '{tool_union.name}' cannot be wrapped as a NodeTool. Agents"
' should be invoked as sub-agents.'
)
description = tool_union.description
if not description:
raise ValueError(
f"Workflow/Node '{tool_union.name}' must have a description to be"
' wrapped as a tool.'
)
return [
NodeTool(
node=tool_union,
name=tool_union.name,
description=description,
)
]
if isinstance(tool_union, BaseTool):
return [tool_union]
@@ -1011,6 +1036,34 @@ class LlmAgent(BaseAgent, abc.ABC):
event.actions.state_delta[self.output_key] = accumulator
return accumulator
@model_validator(mode='before')
@classmethod
def _pre_validate_tools(cls, data: Any) -> Any:
if isinstance(data, dict) and 'tools' in data and data['tools']:
from google.adk.agents.base_agent import BaseAgent
from google.adk.tools._node_tool import NodeTool
from google.adk.workflow._base_node import BaseNode
new_tools = []
for t in data['tools']:
if isinstance(t, BaseAgent):
raise ValueError(
f"Agent '{t.name}' cannot be wrapped as a NodeTool. Agents should"
' be invoked as sub-agents.'
)
elif isinstance(t, BaseNode):
description = t.description
if not description:
raise ValueError(
f"Workflow/Node '{t.name}' must have a description to be"
' wrapped as a tool.'
)
new_tools.append(NodeTool(node=t, description=description))
else:
new_tools.append(t)
data['tools'] = new_tools
return data
@model_validator(mode='after')
def __model_validator_after(self) -> LlmAgent:
return self
+156
View File
@@ -0,0 +1,156 @@
# Copyright 2026 Google LLC
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
from __future__ import annotations
from typing import Any
from google.genai import types
from typing_extensions import override
from ..utils._schema_utils import schema_to_json_schema
from ..workflow._base_node import BaseNode
from ..workflow._errors import NodeInterruptedError
from .base_tool import BaseTool
from .tool_context import ToolContext
class NodeTool(BaseTool):
"""A tool wrapper that executes a BaseNode (e.g. a Workflow or loop node)."""
def __init__(
self,
node: BaseNode,
name: str | None = None,
description: str | None = None,
):
from ..agents.base_agent import BaseAgent
from ..workflow._function_node import FunctionNode
if isinstance(node, BaseAgent):
raise ValueError(
f"Agent '{node.name}' cannot be wrapped as a NodeTool. Agents should"
' be invoked as Sub-Agents instead.'
)
# Automatically align FunctionNode binding
if (
isinstance(node, FunctionNode)
and node.parameter_binding != 'node_input'
):
orig_input_schema = getattr(node, 'input_schema', None)
orig_output_schema = getattr(node, 'output_schema', None)
node = FunctionNode(
func=node._func,
name=node.name,
rerun_on_resume=node.rerun_on_resume,
retry_config=node.retry_config,
timeout=node.timeout,
auth_config=node.auth_config,
parameter_binding='node_input', # Force binding to node_input
state_schema=node.state_schema,
)
if orig_input_schema is not None:
node.input_schema = orig_input_schema
if orig_output_schema is not None:
node.output_schema = orig_output_schema
if not getattr(node, 'input_schema', None):
raise ValueError(
f"Node '{node.name}' does not have an input_schema defined."
' NodeTool requires an explicit Pydantic input_schema on the wrapped'
' node.'
)
self.node = node
super().__init__(
name=name or node.name,
description=description
or node.description
or f'Executes the node: {node.name}',
)
self.is_long_running = True
@override
def _get_declaration(self) -> types.FunctionDeclaration:
schema = schema_to_json_schema(self.node.input_schema)
# The GenAI API strictly requires parameters_json_schema to be an 'object'
# type schema. If the node has a primitive input schema (e.g., str, int),
# we wrap it into an object schema with a 'request' property.
if isinstance(schema, dict) and schema.get('type') != 'object':
schema = {
'type': 'object',
'properties': {
'request': schema,
},
'required': ['request'],
}
decl = types.FunctionDeclaration(
name=self.name,
description=self.description,
parameters_json_schema=schema,
)
output_schema = getattr(self.node, 'output_schema', None)
if output_schema:
decl.response_json_schema = schema_to_json_schema(output_schema)
return decl
@override
async def run_async(
self,
*,
args: dict[str, Any],
tool_context: ToolContext,
) -> Any:
import inspect
from pydantic import BaseModel
input_schema = self.node.input_schema
node_input: Any
if inspect.isclass(input_schema) and issubclass(input_schema, BaseModel):
try:
# Convert input based on Pydantic schema
node_input = input_schema.model_validate(args)
except Exception as e:
return f'Error validating input for node: {e}'
else:
schema = schema_to_json_schema(input_schema)
if isinstance(schema, dict) and schema.get('type') != 'object':
node_input = args.get('request')
else:
node_input = args
fc_id = tool_context.function_call_id
base_branch = tool_context.branch
segment = f'{self.name}@{fc_id}'
tool_branch = f'{base_branch}.{segment}' if base_branch else segment
try:
return await tool_context.run_node(
self.node,
node_input=node_input,
override_branch=tool_branch,
use_sub_branch=False,
raise_on_wait=True,
)
except NodeInterruptedError as nie:
# Propagates the interrupt up so the runner pauses the invocation
raise nie
except Exception as e:
return f'Error running node {self.name}: {e}'
+22 -3
View File
@@ -328,9 +328,15 @@ class FunctionNode(BaseNode):
is passed through directly and all other non-context parameters are
looked up in ``ctx.state``.
"""
from pydantic import BaseModel
input_bound = self.parameter_binding == 'node_input'
source: Any
if input_bound:
source = node_input if isinstance(node_input, dict) else {}
if isinstance(node_input, (dict, BaseModel)):
source = node_input
else:
source = {}
else:
source = ctx.state
source_name = 'node_input' if input_bound else 'state'
@@ -353,8 +359,21 @@ class FunctionNode(BaseNode):
kwargs[param_name] = value
continue
if param_name in source:
value = source[param_name]
has_param = False
value = None
if isinstance(source, BaseModel):
if hasattr(source, param_name):
has_param = True
value = getattr(source, param_name)
else:
try:
if param_name in source:
has_param = True
value = source[param_name]
except (TypeError, KeyError):
pass
if has_param:
if param_name in self._type_hints:
value = self._coerce_param(
param_name,
File diff suppressed because it is too large Load Diff