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:43 -08:00
committed by Copybara-Service
parent 4260ef0c7c
commit 223d9a7ff5
12 changed files with 667 additions and 212 deletions
+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)