feat(skills): let SkillToolset pin registry skills so they appear in list_skills / the catalog without a search_skills turn
@sanketpatil06 がすでに取り組んでいます。
2026年9月16日 から。
評価
この issue はまだ評価されていません。
説明
🔴 Required Information
Is your feature request related to a specific problem?
When SkillToolset is backed by a SkillRegistry (e.g. GCPSkillRegistry / Agent Registry), skills that live in the registry are never part of the L1 catalog the model sees. The model can only reach them through search_skills → load_skill, so a registry skill always costs at least one more model turn (plus one registry round-trip) than an equivalent local skill, even when the developer knows up front exactly which registry skills the agent should use.
Current behavior on main (ac0133a4):
list_skillsand the<available_skills>catalog are built fromSkillToolset._list_skills(), which only returns the skills passed viaskills=[...](src/google/adk/tools/skill_toolset.py:1594-1596). The registry is never consulted for the catalog.- The registry is only consulted from
_get_or_fetch_skill()(skill_toolset.py:1582,1592), i.e. after the model has already named the skill. The system instruction tells the model to fall back tosearch_skillswhen local skills are not sufficient (skill_toolset.py:1669-1675). - The new
SkillDiscoveryMode.EAGER(#7092 / #7093) removes the discovery turn for local skills, but its docstring explicitly leaves registry skills out: "Registry skills are unaffected: they are still reachable only throughsearch_skills." So after that change the gap is: local skills = 0 discovery turns, registry skills = always ≥ 1. - As a secondary cost, a registry skill the model has activated is re-downloaded on every later invocation: session state only keeps the activated skill names (
_adk_activated_skill_<agent>), and_fetched_skill_cacheis keyed byinvocation_id(skill_toolset.py:1564), so_resolve_additional_tools_from_state()callsregistry.get_skill()again each turn.
Concretely, for a fixed set of skills that happen to be published in Agent Registry instead of shipped with the agent:
| local skill | registry skill | |
|---|---|---|
turns before load_skill (LAZY) |
1 (list_skills) |
2 (list_skills → search_skills) |
turns before load_skill (EAGER) |
0 | 1 (search_skills) |
| registry round-trips per later turn | 0 | 1 per activated skill |
The extra turn also adds the search_skills tool declaration, the search result payload and the model's reasoning about it to the context on every conversation, which is the token waste we want to avoid. Our use case is the common one where the skill content is managed centrally in the registry (its update cycle is decoupled from the agent's deploy cycle), but the set of skills an agent uses is decided at build time, exactly like local skills.
Describe the Solution You'd Like
Let SkillToolset pin a known set of registry skills so they behave like local skills once fetched:
toolset = SkillToolset(
skills=load_skills_from_dir("./skills"), # local, as today
registry=GCPSkillRegistry(project_id=..., location=...),
registry_skills=["shared-skill-a", "shared-skill-b"], # NEW
discovery_mode=SkillDiscoveryMode.EAGER,
)
Proposed semantics:
registry_skills: list[str] | None = None(keyword-only, defaultNone, fully backward compatible).- The named skills are fetched lazily, once, on the first
get_tools()/process_llm_request()call for the toolset (both are alreadyasync, and the agent module itself is usually imported synchronously, sometimes inside a running event loop such asadk run/adk web, soasyncio.runat import time is not an option). Fetches for the whole list run concurrently and are guarded by a lock so concurrent first requests only fetch once. - After the fetch the skills are stored alongside the local ones, so they appear in
list_skills, in the EAGER catalog, and_get_or_fetch_skillresolves them without touching the registry again for the lifetime of the process.search_skillskeeps working for skills that are not pinned. - A public
async def prefetch()so callers that do have a startup hook (e.g. Agent EngineAdkApp.set_up(), which runs synchronously with no loop, or a FastAPI lifespan) can warm the toolset before the first request. - Name collisions: a fetched registry skill whose
SKILL.mdfrontmatter name already exists locally is skipped with a warning and the local skill wins. This matches the policySearchSkillsToolalready applies ("Skill naming conflict ... Registry skill is filtered."). Note the skill name comes from the archive's frontmatter, not from the registry resource name, so the check must happen after the fetch. - Fetch failures are logged and leave the toolset usable (the
search_skillspath still works); the next request retries. clone_with_updated_skills()forwardsregistry_skills, so the pinned set survives cloning.
Impact on your work
We run agents on Agent Engine that consume skills published in Agent Registry. Every conversation pays a search_skills turn before the skill can be loaded, and every subsequent turn re-downloads the skill archive. We currently work around this with a SkillToolset subclass that does the lazy prefetch described above, but it has to reach into private attributes (_skills, _registry) and is dropped by clone_with_updated_skills(), so we would much rather have it in the library. Not blocking, but it affects latency and token cost on every request.
Willingness to contribute
Yes. Happy to open a PR with the parameter, the lazy prefetch, prefetch(), and unit tests, once the maintainers agree on the shape. (Related: #6908 / #6824 — GCPSkillRegistry.get_skill() currently fails on the media-download redirect, so the prefetch needs that fix to work end to end against the real Agent Registry.)
🟡 Recommended Information
Describe Alternatives You've Considered
- Fetch at startup and pass the result via
skills=[...]. Works in principle, butget_skill()isasyncand there is no sync counterpart, and the agent module is imported inside a running event loop byadk run/adk web, soasyncio.run()at module import is not portable. - Snapshot the registry skills into the local
skills/directory at deploy time. Removes the runtime dependency entirely, but couples skill updates to the agent's deploy cycle, which is the opposite of why the skills are in a registry. - Extend
SkillDiscoveryMode.EAGERto include registry skills automatically. Would require a "list all" operation onSkillRegistry, which the interface does not have (onlyget_skillandsearch_skills), and would inject an unbounded catalog. An explicit allow-list keeps the catalog bounded and the registry interface unchanged. - Cache fetched skills across invocations (key
_fetched_skill_cacheby skill name instead ofinvocation_id). Solves the re-download cost but not the discovery turn; it would also be a useful, independent change and could be a separate PR.
Proposed API / Implementation
Sketch of the core (names are placeholders):
class SkillToolset(BaseToolset):
def __init__(self, ..., registry_skills: list[str] | None = None):
...
self._registry_skills = list(registry_skills or [])
self._registry_skills_loaded = False
self._registry_skills_lock = asyncio.Lock()
async def prefetch(self) -> None:
if self._registry_skills_loaded or not self._registry_skills:
return
async with self._registry_skills_lock:
if self._registry_skills_loaded:
return
missing = [n for n in self._registry_skills if n not in self._skills]
fetched = await asyncio.gather(
*(self._registry.get_skill(name=n) for n in missing)
)
for skill in fetched:
if skill.name in self._skills:
logger.warning("Registry skill %r collides with a local skill; keeping local", skill.name)
continue
self._skills[skill.name] = skill
self._registry_skills_loaded = True
async def get_tools(self, readonly_context=None):
await self.prefetch()
...
async def process_llm_request(self, *, tool_context, llm_request):
await self.prefetch()
...
Additional Context
SkillDiscoveryMode(#7092, #7093, merged as 3d3c1d90) — the local-skill half of the same problem.GCPSkillRegistry.get_skill()redirect bug (#6908, fix in #6824).- ADK version: main @ ac0133a4 (also verified on 2.9.1 for the catalog /
search_skillsbehavior).
- 主要言語
- Python
- スター
- 21.6k
- フォーク
- 4k
- 平均マージ
- 13時間 49分
- マージ済み PR(30日)
- 10
コントリビューションガイド
はじめの一歩
- issue を最後まで読み、次にプロジェクトのコントリビューションガイドを読みます。
- 着手することを issue にコメントします — 二人が同じ作業をするのを防げます。
- リポジトリをフォークし、ブランチを切って変更します。
- issue 番号を参照したプルリクエストを送ります。
google/adk-python のほかの issue
-
mcp
難易度 2/5 1〜3時間 初心者へのやさしさ 75/100
google/adk-python#7217 · コメント 3 件 · 担当者 1 名 ·
-
tools
難易度 2/5 1〜3時間 初心者へのやさしさ 78/100
google/adk-python#7206 · コメント 1 件 · 担当者 1 名 ·
-
request clarification tools
難易度 2/5 1〜3時間 初心者へのやさしさ 86/100
google/adk-python#7205 · コメント 2 件 · 担当者 1 名 ·
-
mcp
難易度 2/5 1〜3時間 初心者へのやさしさ 78/100
google/adk-python#7196 · コメント 1 件 · 担当者 1 名 ·
-
eval request clarification
難易度 1/5 1〜3時間 初心者へのやさしさ 86/100
google/adk-python#7146 · コメント 2 件 · 担当者 1 名 ·
google/adk-python の issue をすべて見る
似ている issue
-
bug
難易度 2/5 1〜3時間 初心者へのやさしさ 75/100
stephrobert/dsoxlab#238 ·
-
難易度 2/5 1〜3時間 初心者へのやさしさ 75/100
-
難易度 2/5 1〜3時間 初心者へのやさしさ 75/100
sublimehq/package_control#1780 ·
-
難易度 2/5 1〜3時間 初心者へのやさしさ 65/100
-
難易度 2/5 1〜3時間 初心者へのやさしさ 70/100
nwg-piotr/nwg-displays#145 ·