feat: Add SkillToolset to adk

Currently supports load skill and load skill resource, scripts support coming later.

Co-authored-by: Kathy Wu <wukathy@google.com>
PiperOrigin-RevId: 867756231
This commit is contained in:
Kathy Wu
2026-02-09 13:46:49 -08:00
committed by Copybara-Service
parent f50847460f
commit 8d0279251c
4 changed files with 553 additions and 0 deletions
@@ -0,0 +1,15 @@
# 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 . import agent
@@ -0,0 +1,54 @@
# 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.
"""Example agent demonstrating the use of SkillToolset."""
import inspect
from google.adk import Agent
from google.adk.skills import models
from google.adk.tools import skill_toolset
greeting_skill = models.Skill(
frontmatter=models.Frontmatter(
name="greeting-skill",
description=(
"A friendly greeting skill that can say hello to a specific person."
),
),
instructions=(
"Step 1: Read the 'references/hello_world.txt' file to understand how"
" to greet the user. Step 2: Return a greeting based on the reference."
),
resources=models.Resources(
references={
"hello_world.txt": "Hello! 👋👋👋 So glad to have you here! ✨✨✨",
"example.md": "This is an example reference.",
},
),
)
my_skill_toolset = skill_toolset.SkillToolset(skills=[greeting_skill])
root_agent = Agent(
model="gemini-2.5-flash",
name="skill_user_agent",
description="An agent that can use specialized skills.",
instruction=(
"You are a helpful assistant that can leverage skills to perform tasks."
),
tools=[
my_skill_toolset,
],
)
+223
View File
@@ -0,0 +1,223 @@
# 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.
"""Toolset for discovering, viewing, and executing agent skills."""
from __future__ import annotations
from typing import Any
from google.genai import types
from ..agents.readonly_context import ReadonlyContext
from ..models.llm_request import LlmRequest
from ..skills import models
from ..skills import prompt
from .base_tool import BaseTool
from .base_toolset import BaseToolset
from .tool_context import ToolContext
class LoadSkillTool(BaseTool):
"""Tool to load a skill's instructions."""
def __init__(self, toolset: "SkillToolset"):
super().__init__(
name="load_skill",
description="Loads the SKILL.md instructions for a given skill.",
)
self._toolset = toolset
def _get_declaration(self) -> types.FunctionDeclaration | None:
return types.FunctionDeclaration(
name=self.name,
description=self.description,
parameters_json_schema={
"type": "object",
"properties": {
"name": {
"type": "string",
"description": "The name of the skill to load.",
},
},
"required": ["name"],
},
)
async def run_async(
self, *, args: dict[str, Any], tool_context: ToolContext
) -> Any:
skill_name = args.get("name")
if not skill_name:
return {
"error": "Skill name is required.",
"error_code": "MISSING_SKILL_NAME",
}
skill = self._toolset._get_skill(skill_name)
if not skill:
return {
"error": f"Skill '{skill_name}' not found.",
"error_code": "SKILL_NOT_FOUND",
}
return {
"skill_name": skill_name,
"instructions": skill.instructions,
"frontmatter": skill.frontmatter.model_dump(),
}
class LoadSkillResourceTool(BaseTool):
"""Tool to load resources (references or assets) from a skill."""
def __init__(self, toolset: "SkillToolset"):
super().__init__(
name="load_skill_resource",
description=(
"Loads a resource file (from references/ or assets/) from within a"
" skill."
),
)
self._toolset = toolset
def _get_declaration(self) -> types.FunctionDeclaration | None:
return types.FunctionDeclaration(
name=self.name,
description=self.description,
parameters_json_schema={
"type": "object",
"properties": {
"skill_name": {
"type": "string",
"description": "The name of the skill.",
},
"path": {
"type": "string",
"description": (
"The relative path to the resource (e.g.,"
" 'references/my_doc.md' or 'assets/template.txt')."
),
},
},
"required": ["skill_name", "path"],
},
)
async def run_async(
self, *, args: dict[str, Any], tool_context: ToolContext
) -> Any:
skill_name = args.get("skill_name")
resource_path = args.get("path")
if not skill_name:
return {
"error": "Skill name is required.",
"error_code": "MISSING_SKILL_NAME",
}
if not resource_path:
return {
"error": "Resource path is required.",
"error_code": "MISSING_RESOURCE_PATH",
}
skill = self._toolset._get_skill(skill_name)
if not skill:
return {
"error": f"Skill '{skill_name}' not found.",
"error_code": "SKILL_NOT_FOUND",
}
content = None
if resource_path.startswith("references/"):
ref_name = resource_path[len("references/") :]
content = skill.resources.get_reference(ref_name)
elif resource_path.startswith("assets/"):
asset_name = resource_path[len("assets/") :]
content = skill.resources.get_asset(asset_name)
else:
return {
"error": "Path must start with 'references/' or 'assets/'.",
"error_code": "INVALID_RESOURCE_PATH",
}
if content is None:
return {
"error": (
f"Resource '{resource_path}' not found in skill '{skill_name}'."
),
"error_code": "RESOURCE_NOT_FOUND",
}
return {
"skill_name": skill_name,
"path": resource_path,
"content": content,
}
class SkillToolset(BaseToolset):
"""A toolset for managing and interacting with agent skills."""
def __init__(self, skills: list[models.Skill]):
super().__init__()
self._skills = {skill.name: skill for skill in skills}
self._tools = [
LoadSkillTool(self),
LoadSkillResourceTool(self),
]
async def get_tools(
self, readonly_context: ReadonlyContext | None = None
) -> list[BaseTool]:
"""Returns the list of tools in this toolset."""
return self._tools
def _get_skill(self, name: str) -> models.Skill | None:
"""Retrieves a skill by name."""
return self._skills.get(name)
def _list_skills(self) -> list[models.Frontmatter]:
"""Lists the frontmatter of all available skills."""
return [s.frontmatter for s in self._skills.values()]
async def process_llm_request(
self,
*,
tool_context: ToolContext,
llm_request: LlmRequest,
) -> None:
"""Adds available skills to the system instruction."""
skill_frontmatters = self._list_skills()
# Append the skill instruction into the system instruction
skills_xml = prompt.format_skills_as_xml(skill_frontmatters)
skill_si = f"""
You can use specialized 'skills' to help you with complex tasks. Each skill has a name and a description listed below:
{skills_xml}
Skills are folders of instructions and resources that extend your capabilities for specialized tasks. Each skill folder contains:
- **SKILL.md** (required): The main instruction file with skill metadata and detailed markdown instructions.
- **references/** (Optional): Additional documentation or examples for skill usage.
- **assets/** (Optional): Templates, scripts or other resources used by the skill.
This is very important:
1. If a skill seems relevant to the current user query, you MUST use the `load_skill` tool with `name="<SKILL_NAME>"` to read its full instructions before proceeding.
2. Once you have read the instructions, follow them exactly as documented before replying to the user. For example, If the instruction lists multiple steps, please make sure you complete all of them in order.
3. The `load_skill_resource` tool is for viewing files within a skill's directory (e.g., `references/*`, `assets/*`). Do NOT use other tools to access these files.
"""
llm_request.append_instructions([skill_si])
+261
View File
@@ -0,0 +1,261 @@
# 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 unittest import mock
from google.adk.models import llm_request
from google.adk.skills import models
from google.adk.tools import skill_toolset
from google.adk.tools import tool_context
import pytest
@pytest.fixture
def mock_skill1_frontmatter():
"""Fixture for skill1 frontmatter."""
frontmatter = mock.create_autospec(models.Frontmatter, instance=True)
frontmatter.name = "skill1"
frontmatter.description = "Skill 1 description"
frontmatter.model_dump.return_value = {
"name": "skill1",
"description": "Skill 1 description",
}
return frontmatter
@pytest.fixture
def mock_skill1(mock_skill1_frontmatter):
"""Fixture for skill1."""
skill = mock.create_autospec(models.Skill, instance=True)
skill.name = "skill1"
skill.instructions = "instructions for skill1"
skill.frontmatter = mock_skill1_frontmatter
skill.resources = mock.MagicMock(
spec=["get_reference", "get_asset", "get_script"]
)
def get_ref(name):
if name == "ref1.md":
return "ref content 1"
return None
def get_asset(name):
if name == "asset1.txt":
return "asset content 1"
return None
skill.resources.get_reference.side_effect = get_ref
skill.resources.get_asset.side_effect = get_asset
return skill
@pytest.fixture
def mock_skill2_frontmatter():
"""Fixture for skill2 frontmatter."""
frontmatter = mock.create_autospec(models.Frontmatter, instance=True)
frontmatter.name = "skill2"
frontmatter.description = "Skill 2 description"
frontmatter.model_dump.return_value = {
"name": "skill2",
"description": "Skill 2 description",
}
return frontmatter
@pytest.fixture
def mock_skill2(mock_skill2_frontmatter):
"""Fixture for skill2."""
skill = mock.create_autospec(models.Skill, instance=True)
skill.name = "skill2"
skill.instructions = "instructions for skill2"
skill.frontmatter = mock_skill2_frontmatter
skill.resources = mock.MagicMock(
spec=["get_reference", "get_asset", "get_script"]
)
def get_ref(name):
if name == "ref2.md":
return "ref content 2"
return None
def get_asset(name):
if name == "asset2.txt":
return "asset content 2"
return None
skill.resources.get_reference.side_effect = get_ref
skill.resources.get_asset.side_effect = get_asset
return skill
@pytest.fixture
def tool_context_instance():
"""Fixture for tool context."""
return mock.create_autospec(tool_context.ToolContext, instance=True)
# SkillToolset tests
def test_get_skill(mock_skill1, mock_skill2):
toolset = skill_toolset.SkillToolset([mock_skill1, mock_skill2])
assert toolset._get_skill("skill1") == mock_skill1
assert toolset._get_skill("nonexistent") is None
def test_list_skills(mock_skill1, mock_skill2):
toolset = skill_toolset.SkillToolset([mock_skill1, mock_skill2])
frontmatters = toolset._list_skills()
assert len(frontmatters) == 2
assert mock_skill1.frontmatter in frontmatters
assert mock_skill2.frontmatter in frontmatters
@pytest.mark.asyncio
async def test_get_tools(mock_skill1, mock_skill2):
toolset = skill_toolset.SkillToolset([mock_skill1, mock_skill2])
tools = await toolset.get_tools()
assert len(tools) == 2
assert isinstance(tools[0], skill_toolset.LoadSkillTool)
assert isinstance(tools[1], skill_toolset.LoadSkillResourceTool)
@pytest.mark.asyncio
async def test_process_llm_request(
mock_skill1, mock_skill2, tool_context_instance
):
toolset = skill_toolset.SkillToolset([mock_skill1, mock_skill2])
mock_llm_request = llm_request.LlmRequest()
mock_llm_request.config.system_instruction = "existing instruction"
await toolset.process_llm_request(
tool_context=tool_context_instance, llm_request=mock_llm_request
)
assert "<available_skills>" in mock_llm_request.config.system_instruction
assert (
"You can use specialized 'skills'"
in mock_llm_request.config.system_instruction
)
assert (
"skills are folders" in mock_llm_request.config.system_instruction.lower()
)
assert mock_llm_request.config.system_instruction.startswith(
"existing instruction"
)
@pytest.mark.asyncio
@pytest.mark.parametrize(
"args, expected_result",
[
(
{"name": "skill1"},
{
"skill_name": "skill1",
"instructions": "instructions for skill1",
"frontmatter": {
"name": "skill1",
"description": "Skill 1 description",
},
},
),
(
{"name": "nonexistent"},
{
"error": "Skill 'nonexistent' not found.",
"error_code": "SKILL_NOT_FOUND",
},
),
(
{},
{
"error": "Skill name is required.",
"error_code": "MISSING_SKILL_NAME",
},
),
],
)
async def test_load_skill_run_async(
mock_skill1, tool_context_instance, args, expected_result
):
toolset = skill_toolset.SkillToolset([mock_skill1])
tool = skill_toolset.LoadSkillTool(toolset)
result = await tool.run_async(args=args, tool_context=tool_context_instance)
assert result == expected_result
@pytest.mark.asyncio
@pytest.mark.parametrize(
"args, expected_result",
[
(
{"skill_name": "skill1", "path": "references/ref1.md"},
{
"skill_name": "skill1",
"path": "references/ref1.md",
"content": "ref content 1",
},
),
(
{"skill_name": "skill1", "path": "assets/asset1.txt"},
{
"skill_name": "skill1",
"path": "assets/asset1.txt",
"content": "asset content 1",
},
),
(
{"skill_name": "nonexistent", "path": "references/ref1.md"},
{
"error": "Skill 'nonexistent' not found.",
"error_code": "SKILL_NOT_FOUND",
},
),
(
{"skill_name": "skill1", "path": "references/other.md"},
{
"error": (
"Resource 'references/other.md' not found in skill"
" 'skill1'."
),
"error_code": "RESOURCE_NOT_FOUND",
},
),
(
{"skill_name": "skill1", "path": "invalid/path.txt"},
{
"error": "Path must start with 'references/' or 'assets/'.",
"error_code": "INVALID_RESOURCE_PATH",
},
),
(
{"path": "references/ref1.md"},
{
"error": "Skill name is required.",
"error_code": "MISSING_SKILL_NAME",
},
),
(
{"skill_name": "skill1"},
{
"error": "Resource path is required.",
"error_code": "MISSING_RESOURCE_PATH",
},
),
],
)
async def test_load_resource_run_async(
mock_skill1, tool_context_instance, args, expected_result
):
toolset = skill_toolset.SkillToolset([mock_skill1])
tool = skill_toolset.LoadSkillResourceTool(toolset)
result = await tool.run_async(args=args, tool_context=tool_context_instance)
assert result == expected_result