feat: Add support for ADK tools in SkillToolset

To use ADK tools, users can specify the tool name in a skill object's `additional_tools` and pass the tool in when initializing a SkillToolset.

Co-authored-by: Kathy Wu <wukathy@google.com>
PiperOrigin-RevId: 879230409
This commit is contained in:
Kathy Wu
2026-03-05 13:52:23 -08:00
committed by Copybara-Service
parent bcf38fa2ba
commit 44a5e6bdb8
6 changed files with 239 additions and 12 deletions
@@ -20,7 +20,44 @@ from google.adk import Agent
from google.adk.code_executors.unsafe_local_code_executor import UnsafeLocalCodeExecutor
from google.adk.skills import load_skill_from_dir
from google.adk.skills import models
from google.adk.tools.base_tool import BaseTool
from google.adk.tools.skill_toolset import SkillToolset
from google.genai import types
class GetTimezoneTool(BaseTool):
"""A tool to get the timezone for a given location."""
def __init__(self):
super().__init__(
name="get_timezone",
description="Returns the timezone for a given location.",
)
def _get_declaration(self) -> types.FunctionDeclaration | None:
return types.FunctionDeclaration(
name=self.name,
description=self.description,
parameters_json_schema={
"type": "object",
"properties": {
"location": {
"type": "string",
"description": "The location to get the timezone for.",
},
},
"required": ["location"],
},
)
async def run_async(self, *, args: dict, tool_context) -> str:
return f"The timezone for {args['location']} is UTC+00:00."
def get_current_humidity(location: str) -> str:
"""Returns the current humidity for a given location."""
return f"The humidity in {location} is 45%."
greeting_skill = models.Skill(
frontmatter=models.Frontmatter(
@@ -28,6 +65,7 @@ greeting_skill = models.Skill(
description=(
"A friendly greeting skill that can say hello to a specific person."
),
metadata={"adk_additional_tools": ["get_timezone"]},
),
instructions=(
"Step 1: Read the 'references/hello_world.txt' file to understand how"
@@ -49,6 +87,7 @@ weather_skill = load_skill_from_dir(
# be used in production environments.
my_skill_toolset = SkillToolset(
skills=[greeting_skill, weather_skill],
additional_tools=[GetTimezoneTool(), get_current_humidity],
code_executor=UnsafeLocalCodeExecutor(),
)
@@ -0,0 +1,10 @@
---
name: weather-skill
description: A skill that provides weather information based on reference data.
metadata:
adk_additional_tools:
- get_current_humidity
---
Step 1: Check 'references/weather_info.md' for the current weather.
Step 2: Provide the weather update to the user.
+17 -5
View File
@@ -17,6 +17,7 @@
from __future__ import annotations
import re
from typing import Any
from typing import Optional
import unicodedata
@@ -37,11 +38,13 @@ class Frontmatter(BaseModel):
(required).
license: License for the skill (optional).
compatibility: Compatibility information for the skill (optional).
allowed_tools: Tool patterns the skill requires (optional, experimental).
Accepts both ``allowed_tools`` and the YAML-friendly ``allowed-tools``
key.
allowed_tools: A space-delimited list of tools that are pre-approved to
run (optional, experimental). Accepts both ``allowed_tools`` and the
YAML-friendly ``allowed-tools`` key. For more details, see
https://agentskills.io/specification#allowed-tools-field.
metadata: Key-value pairs for client-specific properties (defaults to
empty dict).
empty dict). For example, to include additional tools, use the
``adk_additional_tools`` key with a list of tools.
"""
model_config = ConfigDict(
@@ -58,7 +61,16 @@ class Frontmatter(BaseModel):
alias="allowed-tools",
serialization_alias="allowed-tools",
)
metadata: dict[str, str] = {}
metadata: dict[str, Any] = {}
@field_validator("metadata")
@classmethod
def _validate_metadata(cls, v: dict[str, Any]) -> dict[str, Any]:
if "adk_additional_tools" in v:
tools = v["adk_additional_tools"]
if not isinstance(tools, list):
raise ValueError("adk_additional_tools must be a list of strings")
return v
@field_validator("name")
@classmethod
+68 -3
View File
@@ -37,9 +37,11 @@ from ..skills import models
from ..skills import prompt
from .base_tool import BaseTool
from .base_toolset import BaseToolset
from .function_tool import FunctionTool
from .tool_context import ToolContext
if TYPE_CHECKING:
from ..agents.llm_agent import ToolUnion
from ..models.llm_request import LlmRequest
logger = logging.getLogger("google_adk." + __name__)
@@ -138,6 +140,15 @@ class LoadSkillTool(BaseTool):
"error_code": "SKILL_NOT_FOUND",
}
# Record skill activation in agent state for tool resolution.
agent_name = tool_context.agent_name
state_key = f"_adk_activated_skill_{agent_name}"
activated_skills = list(tool_context.state.get(state_key, []))
if skill_name not in activated_skills:
activated_skills.append(skill_name)
tool_context.state[state_key] = activated_skills
return {
"skill_name": skill_name,
"instructions": skill.instructions,
@@ -586,6 +597,7 @@ class SkillToolset(BaseToolset):
*,
code_executor: Optional[BaseCodeExecutor] = None,
script_timeout: int = _DEFAULT_SCRIPT_TIMEOUT,
additional_tools: list[ToolUnion] | None = None,
):
"""Initializes the SkillToolset.
@@ -609,20 +621,73 @@ class SkillToolset(BaseToolset):
self._code_executor = code_executor
self._script_timeout = script_timeout
self._provided_tools_by_name = {}
for tool_union in additional_tools or []:
if isinstance(tool_union, BaseTool):
self._provided_tools_by_name[tool_union.name] = tool_union
elif callable(tool_union):
ft = FunctionTool(tool_union)
self._provided_tools_by_name[ft.name] = ft
# Initialize core skill tools
self._tools = [
ListSkillsTool(self),
LoadSkillTool(self),
LoadSkillResourceTool(self),
RunSkillScriptTool(self),
]
# Always add RunSkillScriptTool, relies on invocation_context fallback if _code_executor is None
self._tools.append(RunSkillScriptTool(self))
async def get_tools(
self, readonly_context: ReadonlyContext | None = None
) -> list[BaseTool]:
"""Returns the list of tools in this toolset."""
return self._tools
dynamic_tools = await self._resolve_additional_tools_from_state(
readonly_context
)
return self._tools + dynamic_tools
async def _resolve_additional_tools_from_state(
self, readonly_context: ReadonlyContext | None
) -> list[BaseTool]:
"""Resolves tools listed in the "adk_additional_tools" metadata of skills."""
if not readonly_context:
return []
agent_name = readonly_context.agent_name
state_key = f"_adk_activated_skill_{agent_name}"
activated_skills = readonly_context.state.get(state_key, [])
if not activated_skills:
return []
additional_tool_names = set()
for skill_name in activated_skills:
skill = self._skills.get(skill_name)
if skill:
additional_tools = skill.frontmatter.metadata.get(
"adk_additional_tools"
)
if additional_tools:
additional_tool_names.update(additional_tools)
if not additional_tool_names:
return []
resolved_tools = []
existing_tool_names = {t.name for t in self._tools}
for name in additional_tool_names:
if name in self._provided_tools_by_name:
tool = self._provided_tools_by_name[name]
if tool.name in existing_tool_names:
logger.error(
"Tool name collision: tool '%s' already exists.", tool.name
)
continue
resolved_tools.append(tool)
existing_tool_names.add(tool.name)
return resolved_tools
def _get_skill(self, name: str) -> models.Skill | None:
"""Retrieves a skill by name."""
+31
View File
@@ -173,3 +173,34 @@ def test_allowed_tools_serialization_alias():
dumped = fm.model_dump(by_alias=True)
assert "allowed-tools" in dumped
assert dumped["allowed-tools"] == "tool-pattern"
def test_metadata_adk_additional_tools_list():
fm = models.Frontmatter.model_validate({
"name": "my-skill",
"description": "desc",
"metadata": {"adk_additional_tools": ["tool1", "tool2"]},
})
assert fm.metadata["adk_additional_tools"] == ["tool1", "tool2"]
def test_metadata_adk_additional_tools_rejected_as_string():
with pytest.raises(
ValidationError, match="adk_additional_tools must be a list of strings"
):
models.Frontmatter.model_validate({
"name": "my-skill",
"description": "desc",
"metadata": {"adk_additional_tools": "tool1 tool2"},
})
def test_metadata_adk_additional_tools_invalid_type():
with pytest.raises(
ValidationError, match="adk_additional_tools must be a list of strings"
):
models.Frontmatter.model_validate({
"name": "my-skill",
"description": "desc",
"metadata": {"adk_additional_tools": 123},
})
+74 -4
View File
@@ -12,9 +12,7 @@
# See the License for the specific language governing permissions and
# limitations under the License.
# pylint: disable=redefined-outer-name,g-import-not-at-top,protected-access
import logging
from unittest import mock
from google.adk.code_executors.base_code_executor import BaseCodeExecutor
@@ -145,7 +143,13 @@ def mock_skill2(mock_skill2_frontmatter):
@pytest.fixture
def tool_context_instance():
"""Fixture for tool context."""
return mock.create_autospec(tool_context.ToolContext, instance=True)
ctx = mock.create_autospec(tool_context.ToolContext, instance=True)
ctx._invocation_context = mock.MagicMock()
ctx._invocation_context.agent = mock.MagicMock()
ctx._invocation_context.agent.name = "test_agent"
ctx._invocation_context.agent_states = {}
ctx.agent_name = "test_agent"
return ctx
# SkillToolset tests
@@ -361,6 +365,10 @@ def _make_tool_context_with_agent(agent=None):
ctx = mock.MagicMock(spec=tool_context.ToolContext)
ctx._invocation_context = mock.MagicMock()
ctx._invocation_context.agent = agent or mock.MagicMock()
ctx._invocation_context.agent.name = "test_agent"
ctx._invocation_context.agent_states = {}
ctx.agent_name = "test_agent"
ctx.state = {}
return ctx
@@ -1202,3 +1210,65 @@ async def test_execute_script_binary_content_packaged():
assert "b'\\x00\\x01\\x02'" in code_input.code
# Wrapper code handles binary with 'wb' mode
assert "'wb' if isinstance(content, bytes)" in code_input.code
@pytest.mark.asyncio
async def test_skill_toolset_dynamic_tool_resolution(mock_skill1):
# Set up a skill with additional_tools in metadata
mock_skill1.frontmatter.metadata = {
"adk_additional_tools": ["my_custom_tool", "my_func"]
}
mock_skill1.name = "skill1"
# Prepare additional tools
custom_tool = mock.create_autospec(skill_toolset.BaseTool, instance=True)
custom_tool.name = "my_custom_tool"
def my_func():
"""My function description."""
pass
toolset = skill_toolset.SkillToolset(
[mock_skill1],
additional_tools=[custom_tool, my_func],
)
ctx = _make_tool_context_with_agent()
# Initial tools (only core)
tools = await toolset.get_tools(readonly_context=ctx)
assert len(tools) == 4
# Activate skill
load_tool = skill_toolset.LoadSkillTool(toolset)
await load_tool.run_async(args={"name": "skill1"}, tool_context=ctx)
# Dynamic tools should now be resolved
tools = await toolset.get_tools(readonly_context=ctx)
tool_names = {t.name for t in tools}
assert "my_custom_tool" in tool_names
assert "my_func" in tool_names
# Check specific tool resolution details
my_func_tool = next(t for t in tools if t.name == "my_func")
assert isinstance(my_func_tool, skill_toolset.FunctionTool)
assert my_func_tool.description == "My function description."
@pytest.mark.asyncio
async def test_skill_toolset_resolution_error_handling(mock_skill1, caplog):
mock_skill1.frontmatter.metadata = {
"adk_additional_tools": ["nonexistent_tool"]
}
mock_skill1.name = "skill1"
toolset = skill_toolset.SkillToolset([mock_skill1])
ctx = _make_tool_context_with_agent()
# Activate skill
load_tool = skill_toolset.LoadSkillTool(toolset)
await load_tool.run_async(args={"name": "skill1"}, tool_context=ctx)
with caplog.at_level(logging.WARNING):
tools = await toolset.get_tools(readonly_context=ctx)
# Should still return basic skill tools
assert len(tools) == 4