You've already forked adk-python
mirror of
https://github.com/encounter/adk-python.git
synced 2026-07-09 18:19:28 -07:00
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:
committed by
Copybara-Service
parent
bcf38fa2ba
commit
44a5e6bdb8
@@ -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,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
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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},
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user