Files
adk-python/tests/unittests/tools/test_skill_toolset.py
T
Haiyuan CaoandCopybara-Service 223d9a7ff5 feat: Agent Skills spec compliance — validation, aliases, scripts, and auto-injection
Close gaps between ADK's Agent Skills implementation and the public
Agent Skills spec (agentskills.io/specification):

- Frontmatter: add field validators for name (kebab-case, max 64),
  description (non-empty, max 1024), compatibility (max 500);
  add allowed-tools alias; add extra='allow'; add populate_by_name
- utils: extract _parse_skill_md helper; use model_validate() for
  alias support; enforce name-dir matching; add validate_skill_dir()
  and read_skill_properties()
- prompt: accept Union[Frontmatter, Skill];
- skill_toolset: add scripts/ resource loading; auto-inject system
  instruction (with inject_instruction opt-out); duplicate name check;
  _list_skills() returns Skill objects
- sample agent: remove manual instruction (auto-injected now)

Co-authored-by: Haiyuan Cao <haiyuan@google.com>
PiperOrigin-RevId: 873177060
2026-02-20 19:56:43 -08:00

311 lines
9.3 KiB
Python

# 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 as llm_request_model
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.description = "Skill 1 description"
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
def get_script(name):
if name == "setup.sh":
return models.Script(src="echo setup")
return None
skill.resources.get_reference.side_effect = get_ref
skill.resources.get_asset.side_effect = get_asset
skill.resources.get_script.side_effect = get_script
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.description = "Skill 2 description"
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])
skills = toolset._list_skills()
assert len(skills) == 2
assert mock_skill1 in skills
assert mock_skill2 in skills
@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) == 3
assert isinstance(tools[0], skill_toolset.ListSkillsTool)
assert isinstance(tools[1], skill_toolset.LoadSkillTool)
assert isinstance(tools[2], skill_toolset.LoadSkillResourceTool)
@pytest.mark.asyncio
@pytest.mark.asyncio
async def test_list_skills_tool(
mock_skill1, mock_skill2, tool_context_instance
):
toolset = skill_toolset.SkillToolset([mock_skill1, mock_skill2])
tool = skill_toolset.ListSkillsTool(toolset)
result = await tool.run_async(args={}, tool_context=tool_context_instance)
assert "<available_skills>" in result
assert "skill1" in result
assert "skill2" in result
@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": "skill1", "path": "scripts/setup.sh"},
{
"skill_name": "skill1",
"path": "scripts/setup.sh",
"content": "echo setup",
},
),
(
{"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/', 'assets/',"
" or 'scripts/'."
),
"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
@pytest.mark.asyncio
async def test_process_llm_request(
mock_skill1, mock_skill2, tool_context_instance
):
toolset = skill_toolset.SkillToolset([mock_skill1, mock_skill2])
llm_req = mock.create_autospec(llm_request_model.LlmRequest, instance=True)
await toolset.process_llm_request(
tool_context=tool_context_instance, llm_request=llm_req
)
llm_req.append_instructions.assert_called_once()
args, _ = llm_req.append_instructions.call_args
instructions = args[0]
assert len(instructions) == 2
assert instructions[0] == skill_toolset.DEFAULT_SKILL_SYSTEM_INSTRUCTION
assert "<available_skills>" in instructions[1]
assert "skill1" in instructions[1]
assert "skill2" in instructions[1]
def test_duplicate_skill_name_raises(mock_skill1):
skill_dup = mock.create_autospec(models.Skill, instance=True)
skill_dup.name = "skill1"
with pytest.raises(ValueError, match="Duplicate skill name"):
skill_toolset.SkillToolset([mock_skill1, skill_dup])
@pytest.mark.asyncio
async def test_scripts_resource_not_found(mock_skill1, tool_context_instance):
toolset = skill_toolset.SkillToolset([mock_skill1])
tool = skill_toolset.LoadSkillResourceTool(toolset)
result = await tool.run_async(
args={"skill_name": "skill1", "path": "scripts/nonexistent.sh"},
tool_context=tool_context_instance,
)
assert result["error_code"] == "RESOURCE_NOT_FOUND"