diff --git a/contributing/samples/skills_agent/agent.py b/contributing/samples/skills_agent/agent.py index 9232545a..6d5355db 100644 --- a/contributing/samples/skills_agent/agent.py +++ b/contributing/samples/skills_agent/agent.py @@ -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(), ) diff --git a/contributing/samples/skills_agent/skills/weather_skill/SKILL.md b/contributing/samples/skills_agent/skills/weather_skill/SKILL.md new file mode 100644 index 00000000..ea79220a --- /dev/null +++ b/contributing/samples/skills_agent/skills/weather_skill/SKILL.md @@ -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. diff --git a/src/google/adk/skills/models.py b/src/google/adk/skills/models.py index f98b0f10..f7674cd9 100644 --- a/src/google/adk/skills/models.py +++ b/src/google/adk/skills/models.py @@ -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 diff --git a/src/google/adk/tools/skill_toolset.py b/src/google/adk/tools/skill_toolset.py index 12411b41..81ce0c45 100644 --- a/src/google/adk/tools/skill_toolset.py +++ b/src/google/adk/tools/skill_toolset.py @@ -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.""" diff --git a/tests/unittests/skills/test_models.py b/tests/unittests/skills/test_models.py index 3bc7fd30..5685e9d8 100644 --- a/tests/unittests/skills/test_models.py +++ b/tests/unittests/skills/test_models.py @@ -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}, + }) diff --git a/tests/unittests/tools/test_skill_toolset.py b/tests/unittests/tools/test_skill_toolset.py index cbccecdb..7ebf4f40 100644 --- a/tests/unittests/tools/test_skill_toolset.py +++ b/tests/unittests/tools/test_skill_toolset.py @@ -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