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
This commit is contained in:
Haiyuan Cao
2026-02-20 19:56:12 -08:00
committed by Copybara-Service
parent 4260ef0c7c
commit 223d9a7ff5
12 changed files with 667 additions and 212 deletions
+2 -5
View File
@@ -19,7 +19,7 @@ import pathlib
from google.adk import Agent
from google.adk.skills import load_skill_from_dir
from google.adk.skills import models
from google.adk.tools import skill_toolset
from google.adk.tools.skill_toolset import SkillToolset
greeting_skill = models.Skill(
frontmatter=models.Frontmatter(
@@ -44,15 +44,12 @@ weather_skill = load_skill_from_dir(
pathlib.Path(__file__).parent / "skills" / "weather_skill"
)
my_skill_toolset = skill_toolset.SkillToolset(
skills=[greeting_skill, weather_skill]
)
my_skill_toolset = SkillToolset(skills=[greeting_skill, weather_skill])
root_agent = Agent(
model="gemini-2.5-flash",
name="skill_user_agent",
description="An agent that can use specialized skills.",
instruction=skill_toolset.DEFAULT_SKILL_SYSTEM_INSTRUCTION,
tools=[
my_skill_toolset,
],
+1 -1
View File
@@ -14,11 +14,11 @@
"""Agent Development Kit - Skills."""
from ._utils import _load_skill_from_dir as load_skill_from_dir
from .models import Frontmatter
from .models import Resources
from .models import Script
from .models import Skill
from .utils import load_skill_from_dir
__all__ = [
"Frontmatter",
+234
View File
@@ -0,0 +1,234 @@
# 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.
"""Utility functions for Agent Skills."""
from __future__ import annotations
import pathlib
from typing import Union
import yaml
from . import models
_ALLOWED_FRONTMATTER_KEYS = frozenset({
"name",
"description",
"license",
"allowed-tools",
"allowed_tools",
"metadata",
"compatibility",
})
def _load_dir(directory: pathlib.Path) -> dict[str, str]:
"""Recursively load files from a directory into a dictionary.
Args:
directory: Path to the directory to load.
Returns:
Dictionary mapping relative file paths to their string content.
"""
files = {}
if directory.exists() and directory.is_dir():
for file_path in directory.rglob("*"):
if "__pycache__" in file_path.parts:
continue
if file_path.is_file():
relative_path = file_path.relative_to(directory)
try:
files[str(relative_path)] = file_path.read_text(encoding="utf-8")
except UnicodeDecodeError:
# Binary files or non-UTF-8 files are skipped for text content.
continue
return files
def _parse_skill_md(
skill_dir: pathlib.Path,
) -> tuple[dict, str, pathlib.Path]:
"""Parse SKILL.md from a skill directory.
Args:
skill_dir: Resolved path to the skill directory.
Returns:
Tuple of (parsed_frontmatter_dict, body_string, skill_md_path).
Raises:
FileNotFoundError: If the directory or SKILL.md is not found.
ValueError: If SKILL.md is invalid.
"""
if not skill_dir.is_dir():
raise FileNotFoundError(f"Skill directory '{skill_dir}' not found.")
skill_md = None
for name in ("SKILL.md", "skill.md"):
path = skill_dir / name
if path.exists():
skill_md = path
break
if skill_md is None:
raise FileNotFoundError(f"SKILL.md not found in '{skill_dir}'.")
content = skill_md.read_text(encoding="utf-8")
if not content.startswith("---"):
raise ValueError("SKILL.md must start with YAML frontmatter (---)")
parts = content.split("---", 2)
if len(parts) < 3:
raise ValueError("SKILL.md frontmatter not properly closed with ---")
frontmatter_str = parts[1]
body = parts[2].strip()
try:
parsed = yaml.safe_load(frontmatter_str)
except yaml.YAMLError as e:
raise ValueError(f"Invalid YAML in frontmatter: {e}") from e
if not isinstance(parsed, dict):
raise ValueError("SKILL.md frontmatter must be a YAML mapping")
return parsed, body, skill_md
def _load_skill_from_dir(skill_dir: Union[str, pathlib.Path]) -> models.Skill:
"""Load a complete skill from a directory.
Args:
skill_dir: Path to the skill directory.
Returns:
Skill object with all components loaded.
Raises:
FileNotFoundError: If the skill directory or SKILL.md is not found.
ValueError: If SKILL.md is invalid or the skill name does not match
the directory name.
"""
skill_dir = pathlib.Path(skill_dir).resolve()
parsed, body, skill_md = _parse_skill_md(skill_dir)
# Use model_validate to handle aliases like allowed-tools
frontmatter = models.Frontmatter.model_validate(parsed)
# Validate that skill name matches the directory name
if skill_dir.name != frontmatter.name:
raise ValueError(
f"Skill name '{frontmatter.name}' does not match directory"
f" name '{skill_dir.name}'."
)
references = _load_dir(skill_dir / "references")
assets = _load_dir(skill_dir / "assets")
raw_scripts = _load_dir(skill_dir / "scripts")
scripts = {
name: models.Script(src=content) for name, content in raw_scripts.items()
}
resources = models.Resources(
references=references,
assets=assets,
scripts=scripts,
)
return models.Skill(
frontmatter=frontmatter,
instructions=body,
resources=resources,
)
def _validate_skill_dir(
skill_dir: Union[str, pathlib.Path],
) -> list[str]:
"""Validate a skill directory without fully loading it.
Checks that the directory exists, contains a valid SKILL.md with correct
frontmatter, and that the skill name matches the directory name.
Args:
skill_dir: Path to the skill directory.
Returns:
List of problem strings. Empty list means the skill is valid.
"""
problems: list[str] = []
skill_dir = pathlib.Path(skill_dir).resolve()
if not skill_dir.exists():
return [f"Directory '{skill_dir}' does not exist."]
if not skill_dir.is_dir():
return [f"'{skill_dir}' is not a directory."]
skill_md = None
for name in ("SKILL.md", "skill.md"):
path = skill_dir / name
if path.exists():
skill_md = path
break
if skill_md is None:
return [f"SKILL.md not found in '{skill_dir}'."]
try:
parsed, _, _ = _parse_skill_md(skill_dir)
except (FileNotFoundError, ValueError) as e:
return [str(e)]
unknown = set(parsed.keys()) - _ALLOWED_FRONTMATTER_KEYS
if unknown:
problems.append(f"Unknown frontmatter fields: {sorted(unknown)}")
try:
frontmatter = models.Frontmatter.model_validate(parsed)
except Exception as e:
problems.append(f"Frontmatter validation error: {e}")
return problems
if skill_dir.name != frontmatter.name:
problems.append(
f"Skill name '{frontmatter.name}' does not match directory"
f" name '{skill_dir.name}'."
)
return problems
def _read_skill_properties(
skill_dir: Union[str, pathlib.Path],
) -> models.Frontmatter:
"""Read only the frontmatter properties from a skill directory.
This is a lightweight alternative to ``load_skill_from_dir`` when you
only need the skill metadata without loading instructions or resources.
Args:
skill_dir: Path to the skill directory.
Returns:
Frontmatter object with the skill's metadata.
Raises:
FileNotFoundError: If the directory or SKILL.md is not found.
ValueError: If the frontmatter is invalid.
"""
skill_dir = pathlib.Path(skill_dir).resolve()
parsed, _, _ = _parse_skill_md(skill_dir)
return models.Frontmatter.model_validate(parsed)
+48 -1
View File
@@ -16,9 +16,16 @@
from __future__ import annotations
import re
from typing import Optional
import unicodedata
from pydantic import BaseModel
from pydantic import ConfigDict
from pydantic import Field
from pydantic import field_validator
_NAME_PATTERN = re.compile(r"^[a-z0-9]+(-[a-z0-9]+)*$")
class Frontmatter(BaseModel):
@@ -31,17 +38,57 @@ class Frontmatter(BaseModel):
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.
metadata: Key-value pairs for client-specific properties (defaults to
empty dict).
"""
model_config = ConfigDict(
extra="allow",
populate_by_name=True,
)
name: str
description: str
license: Optional[str] = None
compatibility: Optional[str] = None
allowed_tools: Optional[str] = None
allowed_tools: Optional[str] = Field(
default=None,
alias="allowed-tools",
serialization_alias="allowed-tools",
)
metadata: dict[str, str] = {}
@field_validator("name")
@classmethod
def _validate_name(cls, v: str) -> str:
v = unicodedata.normalize("NFKC", v)
if len(v) > 64:
raise ValueError("name must be at most 64 characters")
if not _NAME_PATTERN.match(v):
raise ValueError(
"name must be lowercase kebab-case (a-z, 0-9, hyphens),"
" with no leading, trailing, or consecutive hyphens"
)
return v
@field_validator("description")
@classmethod
def _validate_description(cls, v: str) -> str:
if not v:
raise ValueError("description must not be empty")
if len(v) > 1024:
raise ValueError("description must be at most 1024 characters")
return v
@field_validator("compatibility")
@classmethod
def _validate_compatibility(cls, v: Optional[str]) -> Optional[str]:
if v is not None and len(v) > 500:
raise ValueError("compatibility must be at most 500 characters")
return v
class Script(BaseModel):
"""Wrapper for script content."""
+8 -5
View File
@@ -18,15 +18,18 @@ from __future__ import annotations
import html
from typing import List
from typing import Union
from . import models
def format_skills_as_xml(skills: List[models.Frontmatter]) -> str:
def format_skills_as_xml(
skills: List[Union[models.Frontmatter, models.Skill]],
) -> str:
"""Formats available skills into a standard XML string.
Args:
skills: A list of skill frontmatter objects.
skills: A list of skill frontmatter or full skill objects.
Returns:
XML string with <available_skills> block containing each skill's
@@ -38,13 +41,13 @@ def format_skills_as_xml(skills: List[models.Frontmatter]) -> str:
lines = ["<available_skills>"]
for skill in skills:
for item in skills:
lines.append("<skill>")
lines.append("<name>")
lines.append(html.escape(skill.name))
lines.append(html.escape(item.name))
lines.append("</name>")
lines.append("<description>")
lines.append(html.escape(skill.description))
lines.append(html.escape(item.description))
lines.append("</description>")
lines.append("</skill>")
-118
View File
@@ -1,118 +0,0 @@
# 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.
"""Utility functions for Agent Skills."""
from __future__ import annotations
import pathlib
from typing import Union
import yaml
from . import models
def _load_dir(directory: pathlib.Path) -> dict[str, str]:
"""Recursively load files from a directory into a dictionary.
Args:
directory: Path to the directory to load.
Returns:
Dictionary mapping relative file paths to their string content.
"""
files = {}
if directory.exists() and directory.is_dir():
for file_path in directory.rglob("*"):
if "__pycache__" in file_path.parts:
continue
if file_path.is_file():
relative_path = file_path.relative_to(directory)
try:
files[str(relative_path)] = file_path.read_text(encoding="utf-8")
except UnicodeDecodeError:
# Binary files or non-UTF-8 files are skipped for text content.
continue
return files
def load_skill_from_dir(skill_dir: Union[str, pathlib.Path]) -> models.Skill:
"""Load a complete skill from a directory.
Args:
skill_dir: Path to the skill directory.
Returns:
Skill object with all components loaded.
Raises:
FileNotFoundError: If the skill directory or SKILL.md is not found.
ValueError: If SKILL.md is invalid.
"""
skill_dir = pathlib.Path(skill_dir).resolve()
if not skill_dir.is_dir():
raise FileNotFoundError(f"Skill directory '{skill_dir}' not found.")
skill_md = None
for name in ("SKILL.md", "skill.md"):
path = skill_dir / name
if path.exists():
skill_md = path
break
if skill_md is None:
raise FileNotFoundError(f"SKILL.md not found in '{skill_dir}'.")
content = skill_md.read_text(encoding="utf-8")
if not content.startswith("---"):
raise ValueError("SKILL.md must start with YAML frontmatter (---)")
parts = content.split("---", 2)
if len(parts) < 3:
raise ValueError("SKILL.md frontmatter not properly closed with ---")
frontmatter_str = parts[1]
body = parts[2].strip()
try:
parsed = yaml.safe_load(frontmatter_str)
except yaml.YAMLError as e:
raise ValueError(f"Invalid YAML in frontmatter: {e}") from e
if not isinstance(parsed, dict):
raise ValueError("SKILL.md frontmatter must be a YAML mapping")
# Frontmatter class handles required field validation
frontmatter = models.Frontmatter(**parsed)
references = _load_dir(skill_dir / "references")
assets = _load_dir(skill_dir / "assets")
raw_scripts = _load_dir(skill_dir / "scripts")
scripts = {
name: models.Script(src=content) for name, content in raw_scripts.items()
}
resources = models.Resources(
references=references,
assets=assets,
scripts=scripts,
)
return models.Skill(
frontmatter=frontmatter,
instructions=body,
resources=resources,
)
+38 -15
View File
@@ -39,12 +39,13 @@ Skills are folders of instructions and resources that extend your capabilities f
- **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.
- **scripts/** (Optional): Executable scripts that can be run via bash.
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.
3. The `load_skill_resource` tool is for viewing files within a skill's directory (e.g., `references/*`, `assets/*`, `scripts/*`). Do NOT use other tools to access these files.
"""
@@ -74,8 +75,8 @@ class ListSkillsTool(BaseTool):
async def run_async(
self, *, args: dict[str, Any], tool_context: ToolContext
) -> Any:
skill_frontmatters = self._toolset._list_skills()
return prompt.format_skills_as_xml(skill_frontmatters)
skills = self._toolset._list_skills()
return prompt.format_skills_as_xml(skills)
@experimental(FeatureName.SKILL_TOOLSET)
@@ -131,14 +132,14 @@ class LoadSkillTool(BaseTool):
@experimental(FeatureName.SKILL_TOOLSET)
class LoadSkillResourceTool(BaseTool):
"""Tool to load resources (references or assets) from a skill."""
"""Tool to load resources (references, assets, or scripts) 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."
"Loads a resource file (from references/, assets/, or"
" scripts/) from within a skill."
),
)
self._toolset = toolset
@@ -158,7 +159,8 @@ class LoadSkillResourceTool(BaseTool):
"type": "string",
"description": (
"The relative path to the resource (e.g.,"
" 'references/my_doc.md' or 'assets/template.txt')."
" 'references/my_doc.md', 'assets/template.txt',"
" or 'scripts/setup.sh')."
),
},
},
@@ -197,9 +199,16 @@ class LoadSkillResourceTool(BaseTool):
elif resource_path.startswith("assets/"):
asset_name = resource_path[len("assets/") :]
content = skill.resources.get_asset(asset_name)
elif resource_path.startswith("scripts/"):
script_name = resource_path[len("scripts/") :]
script = skill.resources.get_script(script_name)
if script is not None:
content = script.src
else:
return {
"error": "Path must start with 'references/' or 'assets/'.",
"error": (
"Path must start with 'references/', 'assets/', or 'scripts/'."
),
"error_code": "INVALID_RESOURCE_PATH",
}
@@ -222,8 +231,19 @@ class LoadSkillResourceTool(BaseTool):
class SkillToolset(BaseToolset):
"""A toolset for managing and interacting with agent skills."""
def __init__(self, skills: list[models.Skill]):
def __init__(
self,
skills: list[models.Skill],
):
super().__init__()
# Check for duplicate skill names
seen: set[str] = set()
for skill in skills:
if skill.name in seen:
raise ValueError(f"Duplicate skill name '{skill.name}'.")
seen.add(skill.name)
self._skills = {skill.name: skill for skill in skills}
self._tools = [
ListSkillsTool(self),
@@ -241,14 +261,17 @@ class SkillToolset(BaseToolset):
"""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()]
def _list_skills(self) -> list[models.Skill]:
"""Lists all available skills."""
return list(self._skills.values())
async def process_llm_request(
self, *, tool_context: ToolContext, llm_request: LlmRequest
) -> None:
"""Processes the outgoing LLM request to include available skills."""
skill_frontmatters = self._list_skills()
skills_xml = prompt.format_skills_as_xml(skill_frontmatters)
llm_request.append_instructions([skills_xml])
skills = self._list_skills()
skills_xml = prompt.format_skills_as_xml(skills)
instructions = []
instructions.append(DEFAULT_SKILL_SYSTEM_INSTRUCTION)
instructions.append(skills_xml)
llm_request.append_instructions(instructions)
+182
View File
@@ -0,0 +1,182 @@
# 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.
"""Unit tests for skill utilities."""
from google.adk.skills import load_skill_from_dir as _load_skill_from_dir
from google.adk.skills._utils import _read_skill_properties
from google.adk.skills._utils import _validate_skill_dir
import pytest
def test__load_skill_from_dir(tmp_path):
"""Tests loading a skill from a directory."""
skill_dir = tmp_path / "test-skill"
skill_dir.mkdir()
skill_md_content = """---
name: test-skill
description: Test description
---
Test instructions
"""
(skill_dir / "SKILL.md").write_text(skill_md_content)
# Create references
ref_dir = skill_dir / "references"
ref_dir.mkdir()
(ref_dir / "ref1.md").write_text("ref1 content")
# Create assets
assets_dir = skill_dir / "assets"
assets_dir.mkdir()
(assets_dir / "asset1.txt").write_text("asset1 content")
# Create scripts
scripts_dir = skill_dir / "scripts"
scripts_dir.mkdir()
(scripts_dir / "script1.sh").write_text("echo hello")
skill = _load_skill_from_dir(skill_dir)
assert skill.name == "test-skill"
assert skill.description == "Test description"
assert skill.instructions == "Test instructions"
assert skill.resources.get_reference("ref1.md") == "ref1 content"
assert skill.resources.get_asset("asset1.txt") == "asset1 content"
assert skill.resources.get_script("script1.sh").src == "echo hello"
def test_allowed_tools_yaml_key(tmp_path):
"""Tests that allowed-tools YAML key loads correctly."""
skill_dir = tmp_path / "my-skill"
skill_dir.mkdir()
skill_md = """---
name: my-skill
description: A skill
allowed-tools: "some-tool-*"
---
Instructions here
"""
(skill_dir / "SKILL.md").write_text(skill_md)
skill = _load_skill_from_dir(skill_dir)
assert skill.frontmatter.allowed_tools == "some-tool-*"
def test_name_directory_mismatch(tmp_path):
"""Tests that name-directory mismatch raises ValueError."""
skill_dir = tmp_path / "wrong-dir"
skill_dir.mkdir()
skill_md = """---
name: my-skill
description: A skill
---
Body
"""
(skill_dir / "SKILL.md").write_text(skill_md)
with pytest.raises(ValueError, match="does not match directory"):
_load_skill_from_dir(skill_dir)
def test_validate_skill_dir_valid(tmp_path):
"""Tests validate_skill_dir with a valid skill."""
skill_dir = tmp_path / "my-skill"
skill_dir.mkdir()
skill_md = """---
name: my-skill
description: A skill
---
Body
"""
(skill_dir / "SKILL.md").write_text(skill_md)
problems = _validate_skill_dir(skill_dir)
assert problems == []
def test_validate_skill_dir_missing_dir(tmp_path):
"""Tests validate_skill_dir with missing directory."""
problems = _validate_skill_dir(tmp_path / "nonexistent")
assert len(problems) == 1
assert "does not exist" in problems[0]
def test_validate_skill_dir_missing_skill_md(tmp_path):
"""Tests validate_skill_dir with missing SKILL.md."""
skill_dir = tmp_path / "my-skill"
skill_dir.mkdir()
problems = _validate_skill_dir(skill_dir)
assert len(problems) == 1
assert "SKILL.md not found" in problems[0]
def test_validate_skill_dir_name_mismatch(tmp_path):
"""Tests validate_skill_dir catches name-directory mismatch."""
skill_dir = tmp_path / "wrong-dir"
skill_dir.mkdir()
skill_md = """---
name: my-skill
description: A skill
---
Body
"""
(skill_dir / "SKILL.md").write_text(skill_md)
problems = _validate_skill_dir(skill_dir)
assert any("does not match" in p for p in problems)
def test_validate_skill_dir_unknown_fields(tmp_path):
"""Tests validate_skill_dir detects unknown frontmatter fields."""
skill_dir = tmp_path / "my-skill"
skill_dir.mkdir()
skill_md = """---
name: my-skill
description: A skill
unknown-field: something
---
Body
"""
(skill_dir / "SKILL.md").write_text(skill_md)
problems = _validate_skill_dir(skill_dir)
assert any("Unknown frontmatter" in p for p in problems)
def test__read_skill_properties(tmp_path):
"""Tests read_skill_properties basic usage."""
skill_dir = tmp_path / "my-skill"
skill_dir.mkdir()
skill_md = """---
name: my-skill
description: A cool skill
license: MIT
---
Body content
"""
(skill_dir / "SKILL.md").write_text(skill_md)
fm = _read_skill_properties(skill_dir)
assert fm.name == "my-skill"
assert fm.description == "A cool skill"
assert fm.license == "MIT"
+105
View File
@@ -15,6 +15,7 @@
"""Unit tests for skill models."""
from google.adk.skills import models
from pydantic import ValidationError
import pytest
@@ -68,3 +69,107 @@ def test_script_to_string():
"""Tests Script model."""
script = models.Script(src="print('hello')")
assert str(script) == "print('hello')"
# --- Name validation tests ---
def test_name_too_long():
with pytest.raises(ValidationError, match="at most 64 characters"):
models.Frontmatter(name="a" * 65, description="desc")
def test_name_uppercase_rejected():
with pytest.raises(ValidationError, match="lowercase kebab-case"):
models.Frontmatter(name="My-Skill", description="desc")
def test_name_leading_hyphen():
with pytest.raises(ValidationError, match="lowercase kebab-case"):
models.Frontmatter(name="-my-skill", description="desc")
def test_name_trailing_hyphen():
with pytest.raises(ValidationError, match="lowercase kebab-case"):
models.Frontmatter(name="my-skill-", description="desc")
def test_name_consecutive_hyphens():
with pytest.raises(ValidationError, match="lowercase kebab-case"):
models.Frontmatter(name="my--skill", description="desc")
def test_name_invalid_chars_underscore():
with pytest.raises(ValidationError, match="lowercase kebab-case"):
models.Frontmatter(name="my_skill", description="desc")
def test_name_invalid_chars_ampersand():
with pytest.raises(ValidationError, match="lowercase kebab-case"):
models.Frontmatter(name="skill&name", description="desc")
def test_name_valid_passes():
fm = models.Frontmatter(name="my-skill-2", description="desc")
assert fm.name == "my-skill-2"
def test_name_single_word():
fm = models.Frontmatter(name="skill", description="desc")
assert fm.name == "skill"
# --- Description validation tests ---
def test_description_empty():
with pytest.raises(ValidationError, match="must not be empty"):
models.Frontmatter(name="my-skill", description="")
def test_description_too_long():
with pytest.raises(ValidationError, match="at most 1024 characters"):
models.Frontmatter(name="my-skill", description="x" * 1025)
# --- Compatibility validation tests ---
def test_compatibility_too_long():
with pytest.raises(ValidationError, match="at most 500 characters"):
models.Frontmatter(
name="my-skill", description="desc", compatibility="c" * 501
)
# --- Extra field rejected ---
def test_extra_field_allowed():
fm = models.Frontmatter.model_validate({
"name": "my-skill",
"description": "desc",
"unknown_field": "value",
})
assert fm.name == "my-skill"
# --- allowed-tools alias ---
def test_allowed_tools_alias_via_model_validate():
fm = models.Frontmatter.model_validate({
"name": "my-skill",
"description": "desc",
"allowed-tools": "tool-pattern",
})
assert fm.allowed_tools == "tool-pattern"
def test_allowed_tools_serialization_alias():
fm = models.Frontmatter(
name="my-skill", description="desc", allowed_tools="tool-pattern"
)
dumped = fm.model_dump(by_alias=True)
assert "allowed-tools" in dumped
assert dumped["allowed-tools"] == "tool-pattern"
+2 -2
View File
@@ -42,8 +42,8 @@ class TestPrompt:
def test_format_skills_as_xml_escaping(self):
skills = [
models.Frontmatter(name="skill&name", description="desc<ription>"),
models.Frontmatter(name="my-skill", description="desc<ription>"),
]
xml = prompt.format_skills_as_xml(skills)
assert "skill&amp;name" in xml
assert "my-skill" in xml
assert "desc&lt;ription&gt;" in xml
-56
View File
@@ -1,56 +0,0 @@
# 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.
"""Unit tests for skill utilities."""
from google.adk.skills import load_skill_from_dir
import pytest
def test_load_skill_from_dir(tmp_path):
"""Tests loading a skill from a directory."""
skill_dir = tmp_path / "test-skill"
skill_dir.mkdir()
skill_md_content = """---
name: test-skill
description: Test description
---
Test instructions
"""
(skill_dir / "SKILL.md").write_text(skill_md_content)
# Create references
ref_dir = skill_dir / "references"
ref_dir.mkdir()
(ref_dir / "ref1.md").write_text("ref1 content")
# Create assets
assets_dir = skill_dir / "assets"
assets_dir.mkdir()
(assets_dir / "asset1.txt").write_text("asset1 content")
# Create scripts
scripts_dir = skill_dir / "scripts"
scripts_dir.mkdir()
(scripts_dir / "script1.sh").write_text("echo hello")
skill = load_skill_from_dir(skill_dir)
assert skill.name == "test-skill"
assert skill.description == "Test description"
assert skill.instructions == "Test instructions"
assert skill.resources.get_reference("ref1.md") == "ref1 content"
assert skill.resources.get_asset("asset1.txt") == "asset1 content"
assert skill.resources.get_script("script1.sh").src == "echo hello"
+47 -9
View File
@@ -39,6 +39,7 @@ 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(
@@ -55,8 +56,14 @@ def mock_skill1(mock_skill1_frontmatter):
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
@@ -78,6 +85,7 @@ 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(
@@ -114,10 +122,10 @@ def test_get_skill(mock_skill1, mock_skill2):
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
skills = toolset._list_skills()
assert len(skills) == 2
assert mock_skill1 in skills
assert mock_skill2 in skills
@pytest.mark.asyncio
@@ -203,6 +211,14 @@ async def test_load_skill_run_async(
"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"},
{
@@ -223,7 +239,10 @@ async def test_load_skill_run_async(
(
{"skill_name": "skill1", "path": "invalid/path.txt"},
{
"error": "Path must start with 'references/' or 'assets/'.",
"error": (
"Path must start with 'references/', 'assets/',"
" or 'scripts/'."
),
"error_code": "INVALID_RESOURCE_PATH",
},
),
@@ -266,7 +285,26 @@ async def test_process_llm_request(
llm_req.append_instructions.assert_called_once()
args, _ = llm_req.append_instructions.call_args
instructions = args[0]
assert len(instructions) == 1
assert "<available_skills>" in instructions[0]
assert "skill1" in instructions[0]
assert "skill2" in instructions[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"