Skip to content

Commit 88ebd42

Browse files
wukathcopybara-github
authored andcommitted
feat: Implement GCPSkillRegistry in ADK
This CL implements the class in the integrations folder, used specifically for the Skill Registry API. Co-authored-by: Kathy Wu <wukathy@google.com> PiperOrigin-RevId: 915627057
1 parent 9f38973 commit 88ebd42

7 files changed

Lines changed: 467 additions & 0 deletions

File tree

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
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+
from . import agent
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
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+
"""Sample agent demonstrating the use of GCPSkillRegistry."""
16+
17+
from google.adk import Agent
18+
from google.adk.integrations.skill_registry import GCPSkillRegistry
19+
from google.adk.tools.skill_toolset import SkillToolset
20+
21+
# Initialize GCP Skill Registry
22+
registry = GCPSkillRegistry(
23+
project_id="your-project-id", location="us-central1"
24+
)
25+
26+
# Initialize SkillToolset with registry
27+
skill_toolset = SkillToolset(skills=[], registry=registry)
28+
29+
root_agent = Agent(
30+
model="gemini-2.5-flash",
31+
name="skill_registry_agent",
32+
description=(
33+
"An agent that can discover and load skills from GCP Skill Registry."
34+
),
35+
instruction=(
36+
"Use search_skills to find skills and load_skill to load them if"
37+
" needed."
38+
),
39+
tools=[skill_toolset],
40+
)
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
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+
"""Skill Registry integrations."""
16+
17+
from .gcp_skill_registry import GCPSkillRegistry
18+
19+
__all__ = ["GCPSkillRegistry"]
Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
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+
"""GCP Skill Registry implementation."""
16+
17+
from __future__ import annotations
18+
19+
import asyncio
20+
import base64
21+
import os
22+
23+
from google.adk.skills import _utils
24+
from google.adk.skills import models
25+
from google.adk.skills.skill_registry import SkillRegistry
26+
import vertexai
27+
28+
29+
class GCPSkillRegistry(SkillRegistry):
30+
"""GCP implementation of SkillRegistry using GCP Skill Registry API."""
31+
32+
def __init__(
33+
self, *, project_id: str | None = None, location: str | None = None
34+
):
35+
"""Initializes the GCP Skill Registry.
36+
37+
Args:
38+
project_id: Optional GCP project ID. If omitted, loads from environment.
39+
location: Optional GCP location. If omitted, loads from environment.
40+
"""
41+
self.project_id = project_id or os.environ.get("GOOGLE_CLOUD_PROJECT")
42+
self.location = location or os.environ.get("GOOGLE_CLOUD_LOCATION")
43+
self._client = vertexai.Client(
44+
project=self.project_id,
45+
location=self.location,
46+
http_options={
47+
"api_version": "v1beta1",
48+
},
49+
).aio
50+
51+
async def get_skill(self, *, name: str) -> models.Skill:
52+
"""Fetches a skill from the registry.
53+
54+
Args:
55+
name: The name of the skill.
56+
57+
Returns:
58+
A Skill object.
59+
"""
60+
full_name = (
61+
f"projects/{self.project_id}/locations/{self.location}/skills/{name}"
62+
)
63+
skill_resource = await self._client.skills.get(name=full_name)
64+
65+
zip_bytes_base64 = skill_resource.zipped_filesystem
66+
if not zip_bytes_base64:
67+
raise ValueError(f"Skill '{name}' does not contain zipped filesystem.")
68+
69+
zip_bytes = base64.b64decode(zip_bytes_base64)
70+
71+
return await asyncio.to_thread(_utils._load_skill_from_zip_bytes, zip_bytes)
72+
73+
async def search_skills(self, *, query: str) -> list[models.Frontmatter]:
74+
"""Searches for skills in the registry.
75+
76+
Args:
77+
query: The search query.
78+
79+
Returns:
80+
A list of Frontmatter objects for discovery.
81+
"""
82+
response = await self._client.skills.retrieve(query=query)
83+
84+
results = []
85+
if response.retrieved_skills:
86+
for s in response.retrieved_skills:
87+
results.append(
88+
models.Frontmatter(
89+
name=s.skill_name.split("/")[-1] if s.skill_name else "",
90+
description=s.description or "",
91+
)
92+
)
93+
return results

src/google/adk/skills/_utils.py

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,9 +16,12 @@
1616

1717
from __future__ import annotations
1818

19+
import io
1920
import logging
2021
import pathlib
22+
from typing import Dict
2123
from typing import Union
24+
import zipfile
2225

2326
from google.auth import credentials as auth
2427
from google.cloud import storage
@@ -177,6 +180,96 @@ def _load_skill_from_dir(skill_dir: Union[str, pathlib.Path]) -> models.Skill:
177180
)
178181

179182

183+
def _load_skill_from_zip_bytes(zip_bytes: bytes) -> models.Skill:
184+
"""Load a complete skill directly from in-memory zip file bytes.
185+
186+
Args:
187+
zip_bytes: The raw bytes of the zip file containing the skill.
188+
189+
Returns:
190+
Skill object with all components loaded.
191+
192+
Raises:
193+
FileNotFoundError: If SKILL.md is not found in the archive.
194+
ValueError: If SKILL.md is invalid or contains dangerous paths.
195+
"""
196+
with zipfile.ZipFile(io.BytesIO(zip_bytes)) as z:
197+
# Security check for zip slip
198+
for member in z.infolist():
199+
filename = member.filename
200+
if (
201+
filename.startswith("/")
202+
or filename.startswith("../")
203+
or "/../" in filename
204+
):
205+
raise ValueError(f"Dangerous zip entry ignored: {filename}")
206+
207+
# Find SKILL.md or skill.md
208+
skill_md_content = None
209+
for name in ("SKILL.md", "skill.md"):
210+
try:
211+
skill_md_content = z.read(name).decode("utf-8")
212+
break
213+
except KeyError:
214+
continue
215+
216+
if skill_md_content is None:
217+
raise FileNotFoundError("SKILL.md not found in zipped filesystem.")
218+
219+
parsed, body = _parse_skill_md_content(skill_md_content)
220+
skill_name = parsed.get("name")
221+
if not skill_name:
222+
raise ValueError("SKILL.md frontmatter must contain 'name'")
223+
if (
224+
not isinstance(skill_name, str)
225+
or pathlib.Path(skill_name).name != skill_name
226+
):
227+
raise ValueError(f"Invalid skill name in SKILL.md: {skill_name}")
228+
229+
frontmatter = models.Frontmatter.model_validate(parsed)
230+
231+
# Helper to load files under a directory prefix inside the zip
232+
def _load_zip_dir(prefix: str) -> dict[str, str]:
233+
result = {}
234+
if not prefix.endswith("/"):
235+
prefix += "/"
236+
for info in z.infolist():
237+
if info.is_dir():
238+
continue
239+
if info.filename.startswith(prefix):
240+
# Avoid cache files or similar
241+
if "__pycache__" in info.filename:
242+
continue
243+
relative_path = info.filename[len(prefix) :]
244+
if not relative_path:
245+
continue
246+
try:
247+
result[relative_path] = z.read(info).decode("utf-8")
248+
except UnicodeDecodeError:
249+
continue
250+
return result
251+
252+
references = _load_zip_dir("references")
253+
assets = _load_zip_dir("assets")
254+
raw_scripts = _load_zip_dir("scripts")
255+
scripts = {
256+
name: models.Script(src=content)
257+
for name, content in raw_scripts.items()
258+
}
259+
260+
resources = models.Resources(
261+
references=references,
262+
assets=assets,
263+
scripts=scripts,
264+
)
265+
266+
return models.Skill(
267+
frontmatter=frontmatter,
268+
instructions=body,
269+
resources=resources,
270+
)
271+
272+
180273
def _validate_skill_dir(
181274
skill_dir: Union[str, pathlib.Path],
182275
) -> list[str]:

0 commit comments

Comments
 (0)