diff --git a/contributing/samples/skills_agent/agent.py b/contributing/samples/skills_agent/agent.py index fbd4e5f4..5c2fb91c 100644 --- a/contributing/samples/skills_agent/agent.py +++ b/contributing/samples/skills_agent/agent.py @@ -14,9 +14,10 @@ """Example agent demonstrating the use of SkillToolset.""" -import inspect +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 @@ -39,7 +40,13 @@ greeting_skill = models.Skill( ), ) -my_skill_toolset = skill_toolset.SkillToolset(skills=[greeting_skill]) +weather_skill = load_skill_from_dir( + pathlib.Path(__file__).parent / "skills" / "weather_skill" +) + +my_skill_toolset = skill_toolset.SkillToolset( + skills=[greeting_skill, weather_skill] +) root_agent = Agent( model="gemini-2.5-flash", diff --git a/contributing/samples/skills_agent/skills/weather_skill/SKILL.md b/contributing/samples/skills_agent/skills/weather_skill/SKILL.md new file mode 100644 index 00000000..67d87105 --- /dev/null +++ b/contributing/samples/skills_agent/skills/weather_skill/SKILL.md @@ -0,0 +1,7 @@ +--- +name: weather-skill +description: A skill that provides weather information based on reference data. +--- + +Step 1: Check 'references/weather_info.md' for the current weather. +Step 2: Provide the weather update to the user. diff --git a/contributing/samples/skills_agent/skills/weather_skill/references/weather_info.md b/contributing/samples/skills_agent/skills/weather_skill/references/weather_info.md new file mode 100644 index 00000000..ebe6b633 --- /dev/null +++ b/contributing/samples/skills_agent/skills/weather_skill/references/weather_info.md @@ -0,0 +1,6 @@ +# Weather Information + +- **Location:** San Francisco, CA +- **Condition:** Sunny ☀️ +- **Temperature:** 72°F (22°C) +- **Forecast:** Clear skies all day. diff --git a/src/google/adk/skills/__init__.py b/src/google/adk/skills/__init__.py index 3480bc7d..73184b2b 100644 --- a/src/google/adk/skills/__init__.py +++ b/src/google/adk/skills/__init__.py @@ -18,12 +18,12 @@ from .models import Frontmatter from .models import Resources from .models import Script from .models import Skill -from .prompt import format_skills_as_xml +from .utils import load_skill_from_dir __all__ = [ "Frontmatter", "Resources", "Script", "Skill", - "format_skills_as_xml", + "load_skill_from_dir", ] diff --git a/src/google/adk/skills/utils.py b/src/google/adk/skills/utils.py new file mode 100644 index 00000000..3f357311 --- /dev/null +++ b/src/google/adk/skills/utils.py @@ -0,0 +1,112 @@ +# 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 file_path.is_file(): + relative_path = file_path.relative_to(directory) + files[str(relative_path)] = file_path.read_text(encoding="utf-8") + 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, + ) diff --git a/tests/unittests/skills/test_utils.py b/tests/unittests/skills/test_utils.py new file mode 100644 index 00000000..d922719d --- /dev/null +++ b/tests/unittests/skills/test_utils.py @@ -0,0 +1,56 @@ +# 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"