Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 3 additions & 6 deletions contributing/samples/skills_agent/agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@
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(
Expand All @@ -41,18 +41,15 @@
)

weather_skill = load_skill_from_dir(
pathlib.Path(__file__).parent / "skills" / "weather_skill"
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,
],
Expand Down
4 changes: 4 additions & 0 deletions src/google/adk/skills/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,11 +19,15 @@
from .models import Script
from .models import Skill
from .utils import load_skill_from_dir
from .utils import read_skill_properties
from .utils import validate_skill_dir

__all__ = [
"Frontmatter",
"Resources",
"Script",
"Skill",
"load_skill_from_dir",
"read_skill_properties",
"validate_skill_dir",
]
53 changes: 51 additions & 2 deletions src/google/adk/skills/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand All @@ -30,18 +37,58 @@ 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).
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."""
Expand Down Expand Up @@ -131,11 +178,13 @@ class Skill(BaseModel):
frontmatter: Parsed skill frontmatter from SKILL.md.
instructions: L2 skill content: markdown instruction from SKILL.md body.
resources: L3 skill content: additional instructions, assets, and scripts.
source_path: Filesystem path to the SKILL.md that was loaded (optional).
"""

frontmatter: Frontmatter
instructions: str
resources: Resources = Resources()
source_path: Optional[str] = None

@property
def name(self) -> str:
Expand Down
23 changes: 17 additions & 6 deletions src/google/adk/skills/prompt.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,34 +18,45 @@

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
name and description.
name, description, and optionally source location.
"""

if not skills:
return "<available_skills>\n</available_skills>"

lines = ["<available_skills>"]

for skill in skills:
for item in skills:
source_path = None
if isinstance(item, models.Skill):
source_path = item.source_path

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>")
if source_path is not None:
lines.append("<location>")
lines.append(html.escape(source_path))
lines.append("</location>")
lines.append("</skill>")

lines.append("</available_skills>")
Expand Down
134 changes: 125 additions & 9 deletions src/google/adk/skills/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,15 @@

from . import models

_ALLOWED_FRONTMATTER_KEYS = frozenset({
"name",
"description",
"license",
"allowed-tools",
"metadata",
"compatibility",
})


def _load_dir(directory: pathlib.Path) -> dict[str, str]:
"""Recursively load files from a directory into a dictionary.
Expand All @@ -48,21 +57,21 @@ def _load_dir(directory: pathlib.Path) -> dict[str, str]:
return files


def load_skill_from_dir(skill_dir: Union[str, pathlib.Path]) -> models.Skill:
"""Load a complete skill from a directory.
def _parse_skill_md(
skill_dir: pathlib.Path,
) -> tuple[dict, str, pathlib.Path]:
"""Parse SKILL.md from a skill directory.

Args:
skill_dir: Path to the skill directory.
skill_dir: Resolved path to the skill directory.

Returns:
Skill object with all components loaded.
Tuple of (parsed_frontmatter_dict, body_string, skill_md_path).

Raises:
FileNotFoundError: If the skill directory or SKILL.md is not found.
FileNotFoundError: If the 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.")

Expand Down Expand Up @@ -95,8 +104,36 @@ def load_skill_from_dir(skill_dir: Union[str, pathlib.Path]) -> models.Skill:
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)
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")
Expand All @@ -115,4 +152,83 @@ def load_skill_from_dir(skill_dir: Union[str, pathlib.Path]) -> models.Skill:
frontmatter=frontmatter,
instructions=body,
resources=resources,
source_path=str(skill_md),
)


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)
Loading