Skip to content

Commit 223d9a7

Browse files
haiyuan-eng-googlecopybara-github
authored andcommitted
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
1 parent 4260ef0 commit 223d9a7

File tree

12 files changed

+667
-212
lines changed

12 files changed

+667
-212
lines changed

contributing/samples/skills_agent/agent.py

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@
1919
from google.adk import Agent
2020
from google.adk.skills import load_skill_from_dir
2121
from google.adk.skills import models
22-
from google.adk.tools import skill_toolset
22+
from google.adk.tools.skill_toolset import SkillToolset
2323

2424
greeting_skill = models.Skill(
2525
frontmatter=models.Frontmatter(
@@ -44,15 +44,12 @@
4444
pathlib.Path(__file__).parent / "skills" / "weather_skill"
4545
)
4646

47-
my_skill_toolset = skill_toolset.SkillToolset(
48-
skills=[greeting_skill, weather_skill]
49-
)
47+
my_skill_toolset = SkillToolset(skills=[greeting_skill, weather_skill])
5048

5149
root_agent = Agent(
5250
model="gemini-2.5-flash",
5351
name="skill_user_agent",
5452
description="An agent that can use specialized skills.",
55-
instruction=skill_toolset.DEFAULT_SKILL_SYSTEM_INSTRUCTION,
5653
tools=[
5754
my_skill_toolset,
5855
],

src/google/adk/skills/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,11 +14,11 @@
1414

1515
"""Agent Development Kit - Skills."""
1616

17+
from ._utils import _load_skill_from_dir as load_skill_from_dir
1718
from .models import Frontmatter
1819
from .models import Resources
1920
from .models import Script
2021
from .models import Skill
21-
from .utils import load_skill_from_dir
2222

2323
__all__ = [
2424
"Frontmatter",

src/google/adk/skills/_utils.py

Lines changed: 234 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,234 @@
1+
# Copyright 2026 Google LLC
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# http://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
15+
"""Utility functions for Agent Skills."""
16+
17+
from __future__ import annotations
18+
19+
import pathlib
20+
from typing import Union
21+
22+
import yaml
23+
24+
from . import models
25+
26+
_ALLOWED_FRONTMATTER_KEYS = frozenset({
27+
"name",
28+
"description",
29+
"license",
30+
"allowed-tools",
31+
"allowed_tools",
32+
"metadata",
33+
"compatibility",
34+
})
35+
36+
37+
def _load_dir(directory: pathlib.Path) -> dict[str, str]:
38+
"""Recursively load files from a directory into a dictionary.
39+
40+
Args:
41+
directory: Path to the directory to load.
42+
43+
Returns:
44+
Dictionary mapping relative file paths to their string content.
45+
"""
46+
files = {}
47+
if directory.exists() and directory.is_dir():
48+
for file_path in directory.rglob("*"):
49+
if "__pycache__" in file_path.parts:
50+
continue
51+
if file_path.is_file():
52+
relative_path = file_path.relative_to(directory)
53+
try:
54+
files[str(relative_path)] = file_path.read_text(encoding="utf-8")
55+
except UnicodeDecodeError:
56+
# Binary files or non-UTF-8 files are skipped for text content.
57+
continue
58+
return files
59+
60+
61+
def _parse_skill_md(
62+
skill_dir: pathlib.Path,
63+
) -> tuple[dict, str, pathlib.Path]:
64+
"""Parse SKILL.md from a skill directory.
65+
66+
Args:
67+
skill_dir: Resolved path to the skill directory.
68+
69+
Returns:
70+
Tuple of (parsed_frontmatter_dict, body_string, skill_md_path).
71+
72+
Raises:
73+
FileNotFoundError: If the directory or SKILL.md is not found.
74+
ValueError: If SKILL.md is invalid.
75+
"""
76+
if not skill_dir.is_dir():
77+
raise FileNotFoundError(f"Skill directory '{skill_dir}' not found.")
78+
79+
skill_md = None
80+
for name in ("SKILL.md", "skill.md"):
81+
path = skill_dir / name
82+
if path.exists():
83+
skill_md = path
84+
break
85+
86+
if skill_md is None:
87+
raise FileNotFoundError(f"SKILL.md not found in '{skill_dir}'.")
88+
89+
content = skill_md.read_text(encoding="utf-8")
90+
if not content.startswith("---"):
91+
raise ValueError("SKILL.md must start with YAML frontmatter (---)")
92+
93+
parts = content.split("---", 2)
94+
if len(parts) < 3:
95+
raise ValueError("SKILL.md frontmatter not properly closed with ---")
96+
97+
frontmatter_str = parts[1]
98+
body = parts[2].strip()
99+
100+
try:
101+
parsed = yaml.safe_load(frontmatter_str)
102+
except yaml.YAMLError as e:
103+
raise ValueError(f"Invalid YAML in frontmatter: {e}") from e
104+
105+
if not isinstance(parsed, dict):
106+
raise ValueError("SKILL.md frontmatter must be a YAML mapping")
107+
108+
return parsed, body, skill_md
109+
110+
111+
def _load_skill_from_dir(skill_dir: Union[str, pathlib.Path]) -> models.Skill:
112+
"""Load a complete skill from a directory.
113+
114+
Args:
115+
skill_dir: Path to the skill directory.
116+
117+
Returns:
118+
Skill object with all components loaded.
119+
120+
Raises:
121+
FileNotFoundError: If the skill directory or SKILL.md is not found.
122+
ValueError: If SKILL.md is invalid or the skill name does not match
123+
the directory name.
124+
"""
125+
skill_dir = pathlib.Path(skill_dir).resolve()
126+
127+
parsed, body, skill_md = _parse_skill_md(skill_dir)
128+
129+
# Use model_validate to handle aliases like allowed-tools
130+
frontmatter = models.Frontmatter.model_validate(parsed)
131+
132+
# Validate that skill name matches the directory name
133+
if skill_dir.name != frontmatter.name:
134+
raise ValueError(
135+
f"Skill name '{frontmatter.name}' does not match directory"
136+
f" name '{skill_dir.name}'."
137+
)
138+
139+
references = _load_dir(skill_dir / "references")
140+
assets = _load_dir(skill_dir / "assets")
141+
raw_scripts = _load_dir(skill_dir / "scripts")
142+
scripts = {
143+
name: models.Script(src=content) for name, content in raw_scripts.items()
144+
}
145+
146+
resources = models.Resources(
147+
references=references,
148+
assets=assets,
149+
scripts=scripts,
150+
)
151+
152+
return models.Skill(
153+
frontmatter=frontmatter,
154+
instructions=body,
155+
resources=resources,
156+
)
157+
158+
159+
def _validate_skill_dir(
160+
skill_dir: Union[str, pathlib.Path],
161+
) -> list[str]:
162+
"""Validate a skill directory without fully loading it.
163+
164+
Checks that the directory exists, contains a valid SKILL.md with correct
165+
frontmatter, and that the skill name matches the directory name.
166+
167+
Args:
168+
skill_dir: Path to the skill directory.
169+
170+
Returns:
171+
List of problem strings. Empty list means the skill is valid.
172+
"""
173+
problems: list[str] = []
174+
skill_dir = pathlib.Path(skill_dir).resolve()
175+
176+
if not skill_dir.exists():
177+
return [f"Directory '{skill_dir}' does not exist."]
178+
if not skill_dir.is_dir():
179+
return [f"'{skill_dir}' is not a directory."]
180+
181+
skill_md = None
182+
for name in ("SKILL.md", "skill.md"):
183+
path = skill_dir / name
184+
if path.exists():
185+
skill_md = path
186+
break
187+
if skill_md is None:
188+
return [f"SKILL.md not found in '{skill_dir}'."]
189+
190+
try:
191+
parsed, _, _ = _parse_skill_md(skill_dir)
192+
except (FileNotFoundError, ValueError) as e:
193+
return [str(e)]
194+
195+
unknown = set(parsed.keys()) - _ALLOWED_FRONTMATTER_KEYS
196+
if unknown:
197+
problems.append(f"Unknown frontmatter fields: {sorted(unknown)}")
198+
199+
try:
200+
frontmatter = models.Frontmatter.model_validate(parsed)
201+
except Exception as e:
202+
problems.append(f"Frontmatter validation error: {e}")
203+
return problems
204+
205+
if skill_dir.name != frontmatter.name:
206+
problems.append(
207+
f"Skill name '{frontmatter.name}' does not match directory"
208+
f" name '{skill_dir.name}'."
209+
)
210+
211+
return problems
212+
213+
214+
def _read_skill_properties(
215+
skill_dir: Union[str, pathlib.Path],
216+
) -> models.Frontmatter:
217+
"""Read only the frontmatter properties from a skill directory.
218+
219+
This is a lightweight alternative to ``load_skill_from_dir`` when you
220+
only need the skill metadata without loading instructions or resources.
221+
222+
Args:
223+
skill_dir: Path to the skill directory.
224+
225+
Returns:
226+
Frontmatter object with the skill's metadata.
227+
228+
Raises:
229+
FileNotFoundError: If the directory or SKILL.md is not found.
230+
ValueError: If the frontmatter is invalid.
231+
"""
232+
skill_dir = pathlib.Path(skill_dir).resolve()
233+
parsed, _, _ = _parse_skill_md(skill_dir)
234+
return models.Frontmatter.model_validate(parsed)

src/google/adk/skills/models.py

Lines changed: 48 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,9 +16,16 @@
1616

1717
from __future__ import annotations
1818

19+
import re
1920
from typing import Optional
21+
import unicodedata
2022

2123
from pydantic import BaseModel
24+
from pydantic import ConfigDict
25+
from pydantic import Field
26+
from pydantic import field_validator
27+
28+
_NAME_PATTERN = re.compile(r"^[a-z0-9]+(-[a-z0-9]+)*$")
2229

2330

2431
class Frontmatter(BaseModel):
@@ -31,17 +38,57 @@ class Frontmatter(BaseModel):
3138
license: License for the skill (optional).
3239
compatibility: Compatibility information for the skill (optional).
3340
allowed_tools: Tool patterns the skill requires (optional, experimental).
41+
Accepts both ``allowed_tools`` and the YAML-friendly ``allowed-tools``
42+
key.
3443
metadata: Key-value pairs for client-specific properties (defaults to
3544
empty dict).
3645
"""
3746

47+
model_config = ConfigDict(
48+
extra="allow",
49+
populate_by_name=True,
50+
)
51+
3852
name: str
3953
description: str
4054
license: Optional[str] = None
4155
compatibility: Optional[str] = None
42-
allowed_tools: Optional[str] = None
56+
allowed_tools: Optional[str] = Field(
57+
default=None,
58+
alias="allowed-tools",
59+
serialization_alias="allowed-tools",
60+
)
4361
metadata: dict[str, str] = {}
4462

63+
@field_validator("name")
64+
@classmethod
65+
def _validate_name(cls, v: str) -> str:
66+
v = unicodedata.normalize("NFKC", v)
67+
if len(v) > 64:
68+
raise ValueError("name must be at most 64 characters")
69+
if not _NAME_PATTERN.match(v):
70+
raise ValueError(
71+
"name must be lowercase kebab-case (a-z, 0-9, hyphens),"
72+
" with no leading, trailing, or consecutive hyphens"
73+
)
74+
return v
75+
76+
@field_validator("description")
77+
@classmethod
78+
def _validate_description(cls, v: str) -> str:
79+
if not v:
80+
raise ValueError("description must not be empty")
81+
if len(v) > 1024:
82+
raise ValueError("description must be at most 1024 characters")
83+
return v
84+
85+
@field_validator("compatibility")
86+
@classmethod
87+
def _validate_compatibility(cls, v: Optional[str]) -> Optional[str]:
88+
if v is not None and len(v) > 500:
89+
raise ValueError("compatibility must be at most 500 characters")
90+
return v
91+
4592

4693
class Script(BaseModel):
4794
"""Wrapper for script content."""

src/google/adk/skills/prompt.py

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -18,15 +18,18 @@
1818

1919
import html
2020
from typing import List
21+
from typing import Union
2122

2223
from . import models
2324

2425

25-
def format_skills_as_xml(skills: List[models.Frontmatter]) -> str:
26+
def format_skills_as_xml(
27+
skills: List[Union[models.Frontmatter, models.Skill]],
28+
) -> str:
2629
"""Formats available skills into a standard XML string.
2730
2831
Args:
29-
skills: A list of skill frontmatter objects.
32+
skills: A list of skill frontmatter or full skill objects.
3033
3134
Returns:
3235
XML string with <available_skills> block containing each skill's
@@ -38,13 +41,13 @@ def format_skills_as_xml(skills: List[models.Frontmatter]) -> str:
3841

3942
lines = ["<available_skills>"]
4043

41-
for skill in skills:
44+
for item in skills:
4245
lines.append("<skill>")
4346
lines.append("<name>")
44-
lines.append(html.escape(skill.name))
47+
lines.append(html.escape(item.name))
4548
lines.append("</name>")
4649
lines.append("<description>")
47-
lines.append(html.escape(skill.description))
50+
lines.append(html.escape(item.description))
4851
lines.append("</description>")
4952
lines.append("</skill>")
5053

0 commit comments

Comments
 (0)