Skip to content
1,961 changes: 1,961 additions & 0 deletions constraints-3.10.txt

Large diffs are not rendered by default.

2,261 changes: 2,261 additions & 0 deletions constraints-3.11.txt

Large diffs are not rendered by default.

1,937 changes: 1,937 additions & 0 deletions constraints-3.12.txt

Large diffs are not rendered by default.

1,917 changes: 1,917 additions & 0 deletions constraints-3.13.txt

Large diffs are not rendered by default.

1,917 changes: 1,917 additions & 0 deletions constraints-3.14.txt

Large diffs are not rendered by default.

4 changes: 2 additions & 2 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -315,11 +315,11 @@ known_third_party = [ "a2a", "google.adk" ]
# hel/serie/strin -> substrings in test fixtures; te -> local variable;
# rouge -> the ROUGE metric; unparseable -> valid spelling variant;
# re-use/re-used -> intentional hyphenation; lamda -> Google LaMDA project.
ignore-words-list = "hel,serie,strin,te,rouge,unparseable,re-use,re-used,lamda"
ignore-words-list = "hel,serie,strin,te,rouge,unparseable,re-use,re-used,lamda,astroid"
# CHANGELOG.md is generated from commit messages; lockfiles, notebooks, JSON
# fixtures, bundled JS/source maps, and the vendored CLI browser bundle are
# generated or data files, not prose we own.
skip = "*CHANGELOG.md,*.lock,*.ipynb,*.json,*.js,*.map,*/cli/browser/*"
skip = "*CHANGELOG.md,*.lock,*.ipynb,*.json,*.js,*.map,*/cli/browser/*,constraints-*.txt"

[tool.mypy]
mypy_path = [ "src" ]
Expand Down
8 changes: 8 additions & 0 deletions src/google/adk/skills/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,9 +18,13 @@
import warnings

from ._utils import _list_skills_in_dir as list_skills_in_dir
from ._utils import _list_skills_in_dir_async as list_skills_in_dir_async
from ._utils import _list_skills_in_gcs_dir as list_skills_in_gcs_dir
from ._utils import _list_skills_in_gcs_dir_async as list_skills_in_gcs_dir_async
from ._utils import _load_skill_from_dir as load_skill_from_dir
from ._utils import _load_skill_from_dir_async as load_skill_from_dir_async
from ._utils import _load_skill_from_gcs_dir as load_skill_from_gcs_dir
from ._utils import _load_skill_from_gcs_dir_async as load_skill_from_gcs_dir_async
from ._utils import _load_skills_from_dir as load_skills_from_dir
from .models import Frontmatter
from .models import Resources
Expand All @@ -36,9 +40,13 @@
"Skill",
"SkillRegistry",
"list_skills_in_dir",
"list_skills_in_dir_async",
"list_skills_in_gcs_dir",
"list_skills_in_gcs_dir_async",
"load_skill_from_dir",
"load_skill_from_dir_async",
"load_skill_from_gcs_dir",
"load_skill_from_gcs_dir_async",
"load_skills_from_dir",
]

Expand Down
84 changes: 84 additions & 0 deletions src/google/adk/skills/_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@

from __future__ import annotations

import asyncio
import io
import logging
import pathlib
Expand Down Expand Up @@ -586,3 +587,86 @@ def _load_files_in_dir(subdir: str) -> Dict[str, Union[str, bytes]]:
instructions=body,
resources=resources,
)


async def _load_skill_from_dir_async(
skill_dir: Union[str, pathlib.Path],
) -> models.Skill:
"""Load a complete skill from a directory asynchronously.

Args:
skill_dir: Path to the skill directory.

Returns:
Skill object with all components loaded.
"""
return await asyncio.to_thread(_load_skill_from_dir, skill_dir)


async def _load_skill_from_gcs_dir_async(
bucket_name: str,
skill_id: str,
skills_base_path: str = "",
project_id: str | None = None,
credentials: auth.Credentials | None = None,
) -> models.Skill:
"""Load a complete skill from a GCS directory asynchronously.

Args:
bucket_name: Name of the GCS bucket.
skill_id: The ID of the skill (directory name).
skills_base_path: Base directory within the bucket (e.g., 'path/to/skills').
project_id: Project ID to use for GCS client.
credentials: Credentials to use for GCS client.

Returns:
Skill object with all components loaded.
"""
return await asyncio.to_thread(
_load_skill_from_gcs_dir,
bucket_name,
skill_id,
skills_base_path,
project_id,
credentials,
)


async def _list_skills_in_dir_async(
skills_base_path: Union[str, pathlib.Path],
) -> dict[str, models.Frontmatter]:
"""List skills in a local directory asynchronously.

Args:
skills_base_path: Path to the base directory containing skills.

Returns:
Dictionary mapping skill IDs to their frontmatter.
"""
return await asyncio.to_thread(_list_skills_in_dir, skills_base_path)


async def _list_skills_in_gcs_dir_async(
bucket_name: str,
skills_base_path: str = "",
project_id: str | None = None,
credentials: auth.Credentials | None = None,
) -> dict[str, models.Frontmatter]:
"""List skills in a GCS directory asynchronously.

Args:
bucket_name: Name of the GCS bucket.
skills_base_path: Base directory within the bucket (e.g., 'path/to/skills').
project_id: Project ID to use for GCS client.
credentials: Credentials to use for GCS client.

Returns:
Dictionary mapping skill IDs to their frontmatter.
"""
return await asyncio.to_thread(
_list_skills_in_gcs_dir,
bucket_name,
skills_base_path,
project_id,
credentials,
)
118 changes: 118 additions & 0 deletions tests/unittests/skills/test__utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,9 +21,13 @@
import zipfile

from google.adk.skills import list_skills_in_dir
from google.adk.skills import list_skills_in_dir_async as _list_skills_in_dir_async
from google.adk.skills import list_skills_in_gcs_dir as _list_skills_in_gcs_dir
from google.adk.skills import list_skills_in_gcs_dir_async as _list_skills_in_gcs_dir_async
from google.adk.skills import load_skill_from_dir as _load_skill_from_dir
from google.adk.skills import load_skill_from_dir_async as _load_skill_from_dir_async
from google.adk.skills import load_skill_from_gcs_dir as _load_skill_from_gcs_dir
from google.adk.skills import load_skill_from_gcs_dir_async as _load_skill_from_gcs_dir_async
from google.adk.skills import load_skills_from_dir as _load_skills_from_dir
from google.adk.skills._utils import _load_skill_from_zip_bytes
from google.adk.skills._utils import _read_skill_properties
Expand Down Expand Up @@ -396,6 +400,120 @@ def mock_import(name, globals=None, locals=None, fromlist=(), level=0):
_load_skill_from_gcs_dir("my-bucket", "skills/my-skill/")


@pytest.mark.asyncio
async def test_load_skill_from_dir_async(tmp_path):
"""Tests loading a skill from a directory asynchronously."""
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")

skill = await _load_skill_from_dir_async(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"


@pytest.mark.asyncio
async def test_list_skills_in_dir_async(tmp_path):
"""Tests listing skills in a directory asynchronously."""
skills_dir = tmp_path / "skills"
skills_dir.mkdir()

# Valid skill 1
skill1_dir = skills_dir / "skill1"
skill1_dir.mkdir()
(skill1_dir / "SKILL.md").write_text(
"---\nname: skill1\ndescription: desc1\n---\nbody"
)

skills = await _list_skills_in_dir_async(skills_dir)

assert len(skills) == 1
assert "skill1" in skills
assert skills["skill1"].name == "skill1"


@pytest.mark.asyncio
@mock.patch("google.cloud.storage.Client")
async def test_load_skill_from_gcs_dir_async(mock_client_class):
"""Tests loading a skill from GCS asynchronously."""
mock_client = mock.MagicMock()
mock_client_class.return_value = mock_client
mock_bucket = mock.MagicMock()
mock_client.bucket.return_value = mock_bucket

def mock_blob_side_effect(path):
m = mock.MagicMock()
if path.endswith("SKILL.md"):
m.exists.return_value = True
m.download_as_text.return_value = (
"---\nname: my-skill\ndescription: Test description\n---\nTest"
" instructions"
)
else:
m.exists.return_value = False
return m

mock_bucket.blob.side_effect = mock_blob_side_effect

# For resources
def list_blobs_side_effect(prefix=None):
if prefix.endswith("references/"):
m = mock.MagicMock()
m.name = prefix + "ref1.md"
m.download_as_text.return_value = "ref1 content"
return [m]
return []

mock_bucket.list_blobs.side_effect = list_blobs_side_effect

skill = await _load_skill_from_gcs_dir_async("my-bucket", "skills/my-skill/")

assert skill.name == "my-skill"
assert skill.description == "Test description"
assert skill.instructions == "Test instructions"
assert skill.resources.get_reference("ref1.md") == "ref1 content"


@pytest.mark.asyncio
@mock.patch("google.cloud.storage.Client")
async def test_list_skills_in_gcs_dir_async(mock_client_class):
"""Tests listing skills in GCS asynchronously."""
mock_client = mock.MagicMock()
mock_client_class.return_value = mock_client
mock_bucket = mock.MagicMock()
mock_client.bucket.return_value = mock_bucket

mock_iterator = mock.MagicMock()
mock_iterator.prefixes = ["skills/my-skill/"]
mock_bucket.list_blobs.return_value = mock_iterator

mock_blob = mock.MagicMock()
mock_blob.exists.return_value = True
mock_blob.download_as_text.return_value = (
"---\nname: my-skill\ndescription: A skill\n---\nBody"
)
mock_bucket.blob.return_value = mock_blob

skills = await _list_skills_in_gcs_dir_async("my-bucket", "skills/")
assert "my-skill" in skills
assert skills["my-skill"].name == "my-skill"


def test__load_skills_from_dir(tmp_path):
"""Tests loading multiple skills from a directory."""
skills_dir = tmp_path / "skills"
Expand Down