Skip to content

Commit 9f7d5b3

Browse files
wukathcopybara-github
authored andcommitted
feat: Add load_skill_from_dir() method
This allows users to load skills from a directory and pass it into the SkillToolset constructor. Co-authored-by: Kathy Wu <wukathy@google.com> PiperOrigin-RevId: 868929937
1 parent b7f9110 commit 9f7d5b3

File tree

6 files changed

+192
-4
lines changed

6 files changed

+192
-4
lines changed

contributing/samples/skills_agent/agent.py

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,9 +14,10 @@
1414

1515
"""Example agent demonstrating the use of SkillToolset."""
1616

17-
import inspect
17+
import pathlib
1818

1919
from google.adk import Agent
20+
from google.adk.skills import load_skill_from_dir
2021
from google.adk.skills import models
2122
from google.adk.tools import skill_toolset
2223

@@ -39,7 +40,13 @@
3940
),
4041
)
4142

42-
my_skill_toolset = skill_toolset.SkillToolset(skills=[greeting_skill])
43+
weather_skill = load_skill_from_dir(
44+
pathlib.Path(__file__).parent / "skills" / "weather_skill"
45+
)
46+
47+
my_skill_toolset = skill_toolset.SkillToolset(
48+
skills=[greeting_skill, weather_skill]
49+
)
4350

4451
root_agent = Agent(
4552
model="gemini-2.5-flash",
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
name: weather-skill
3+
description: A skill that provides weather information based on reference data.
4+
---
5+
6+
Step 1: Check 'references/weather_info.md' for the current weather.
7+
Step 2: Provide the weather update to the user.
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
# Weather Information
2+
3+
- **Location:** San Francisco, CA
4+
- **Condition:** Sunny ☀️
5+
- **Temperature:** 72°F (22°C)
6+
- **Forecast:** Clear skies all day.

src/google/adk/skills/__init__.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,12 +18,12 @@
1818
from .models import Resources
1919
from .models import Script
2020
from .models import Skill
21-
from .prompt import format_skills_as_xml
21+
from .utils import load_skill_from_dir
2222

2323
__all__ = [
2424
"Frontmatter",
2525
"Resources",
2626
"Script",
2727
"Skill",
28-
"format_skills_as_xml",
28+
"load_skill_from_dir",
2929
]

src/google/adk/skills/utils.py

Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
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+
27+
def _load_dir(directory: pathlib.Path) -> dict[str, str]:
28+
"""Recursively load files from a directory into a dictionary.
29+
30+
Args:
31+
directory: Path to the directory to load.
32+
33+
Returns:
34+
Dictionary mapping relative file paths to their string content.
35+
"""
36+
files = {}
37+
if directory.exists() and directory.is_dir():
38+
for file_path in directory.rglob("*"):
39+
if file_path.is_file():
40+
relative_path = file_path.relative_to(directory)
41+
files[str(relative_path)] = file_path.read_text(encoding="utf-8")
42+
return files
43+
44+
45+
def load_skill_from_dir(skill_dir: Union[str, pathlib.Path]) -> models.Skill:
46+
"""Load a complete skill from a directory.
47+
48+
Args:
49+
skill_dir: Path to the skill directory.
50+
51+
Returns:
52+
Skill object with all components loaded.
53+
54+
Raises:
55+
FileNotFoundError: If the skill directory or SKILL.md is not found.
56+
ValueError: If SKILL.md is invalid.
57+
"""
58+
skill_dir = pathlib.Path(skill_dir).resolve()
59+
60+
if not skill_dir.is_dir():
61+
raise FileNotFoundError(f"Skill directory '{skill_dir}' not found.")
62+
63+
skill_md = None
64+
for name in ("SKILL.md", "skill.md"):
65+
path = skill_dir / name
66+
if path.exists():
67+
skill_md = path
68+
break
69+
70+
if skill_md is None:
71+
raise FileNotFoundError(f"SKILL.md not found in '{skill_dir}'.")
72+
73+
content = skill_md.read_text(encoding="utf-8")
74+
if not content.startswith("---"):
75+
raise ValueError("SKILL.md must start with YAML frontmatter (---)")
76+
77+
parts = content.split("---", 2)
78+
if len(parts) < 3:
79+
raise ValueError("SKILL.md frontmatter not properly closed with ---")
80+
81+
frontmatter_str = parts[1]
82+
body = parts[2].strip()
83+
84+
try:
85+
parsed = yaml.safe_load(frontmatter_str)
86+
except yaml.YAMLError as e:
87+
raise ValueError(f"Invalid YAML in frontmatter: {e}") from e
88+
89+
if not isinstance(parsed, dict):
90+
raise ValueError("SKILL.md frontmatter must be a YAML mapping")
91+
92+
# Frontmatter class handles required field validation
93+
frontmatter = models.Frontmatter(**parsed)
94+
95+
references = _load_dir(skill_dir / "references")
96+
assets = _load_dir(skill_dir / "assets")
97+
raw_scripts = _load_dir(skill_dir / "scripts")
98+
scripts = {
99+
name: models.Script(src=content) for name, content in raw_scripts.items()
100+
}
101+
102+
resources = models.Resources(
103+
references=references,
104+
assets=assets,
105+
scripts=scripts,
106+
)
107+
108+
return models.Skill(
109+
frontmatter=frontmatter,
110+
instructions=body,
111+
resources=resources,
112+
)
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
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+
"""Unit tests for skill utilities."""
16+
17+
from google.adk.skills import load_skill_from_dir
18+
import pytest
19+
20+
21+
def test_load_skill_from_dir(tmp_path):
22+
"""Tests loading a skill from a directory."""
23+
skill_dir = tmp_path / "test-skill"
24+
skill_dir.mkdir()
25+
26+
skill_md_content = """---
27+
name: test-skill
28+
description: Test description
29+
---
30+
Test instructions
31+
"""
32+
(skill_dir / "SKILL.md").write_text(skill_md_content)
33+
34+
# Create references
35+
ref_dir = skill_dir / "references"
36+
ref_dir.mkdir()
37+
(ref_dir / "ref1.md").write_text("ref1 content")
38+
39+
# Create assets
40+
assets_dir = skill_dir / "assets"
41+
assets_dir.mkdir()
42+
(assets_dir / "asset1.txt").write_text("asset1 content")
43+
44+
# Create scripts
45+
scripts_dir = skill_dir / "scripts"
46+
scripts_dir.mkdir()
47+
(scripts_dir / "script1.sh").write_text("echo hello")
48+
49+
skill = load_skill_from_dir(skill_dir)
50+
51+
assert skill.name == "test-skill"
52+
assert skill.description == "Test description"
53+
assert skill.instructions == "Test instructions"
54+
assert skill.resources.get_reference("ref1.md") == "ref1 content"
55+
assert skill.resources.get_asset("asset1.txt") == "asset1 content"
56+
assert skill.resources.get_script("script1.sh").src == "echo hello"

0 commit comments

Comments
 (0)