Summary
The documented @bundle:skills source form works correctly when registered at runtime via load_skill(source="@bundle:skills"), but is silently dropped when supplied through the tools[tool-skills].config.skills: list at mount time. No error is raised; the entries simply do not register. This affects every bundle author who follows the documented "ship-your-own-skills" pattern.
In our case, 48 bundle-shipped skills failed to load into visibility for an unknown duration — the failure was only surfaced when a learning-digest hook flagged repeated load_skill failures referencing skills that exist on disk.
The root cause is two parallel resolution paths in tool-skills/__init__.py that handle @-prefixed sources inconsistently — runtime path dispatches to mention_resolver, mount-time path falls through to Path().
Reproducer
Minimal tools config in any behavior file:
tools:
- module: tool-skills
source: git+https://github.com/microsoft/amplifier-bundle-skills@main#subdirectory=modules/tool-skills
config:
skills:
- "@mybundle:skills" # <-- silently dropped at mount; never resolved
Expected: skills under <mybundle root>/skills/ are discovered and visible.
Actual: zero skills from the bundle load. No error, no warning at default log level — only a logger.debug line "Local skill source does not exist: /cwd/@mybundle:skills".
To prove @namespace: is otherwise functional, the same source resolves correctly via the runtime path:
load_skill(source="@mybundle:skills") # works — discovers and registers all skills
Expected vs. actual behavior
The README and skills-instructions.md both document @mybundle:skills as a canonical config source type:
| Source type |
Example |
When to use |
| Bundle reference |
@mybundle:skills |
Skills shipped inside your own bundle |
Users following this documentation will find their skills missing with no diagnostic. The workaround (use the git+ssh://...#subdirectory=skills form, or an absolute local path) requires reading source code to discover.
Root cause
In modules/tool-skills/amplifier_module_tool_skills/__init__.py:
Mount-time path — _resolve_skill_sources (lines ~57–134):
# Check if any sources are remote (need async resolution)
has_remote = any(is_remote_source(s) for s in sources)
if has_remote:
return await resolve_skill_sources(sources)
else:
# All local - just expand paths
resolved = []
for source in sources:
path = Path(source).expanduser().resolve()
if path.exists():
resolved.append(path)
else:
logger.debug(f"Local skill source does not exist: {path}")
return resolved if resolved else get_default_skills_dirs()
is_remote_source (in sources.py:24) matches only git+… / https:// / http:// prefixes, so @mybundle:skills returns False. The source then falls into the local branch where Path("@mybundle:skills").expanduser().resolve() produces <cwd>/@mybundle:skills, which does not exist, and the source is dropped at debug log level.
Runtime path — SkillsTool._resolve_source (lines ~488–514):
async def _resolve_source(self, source: str) -> Path | None:
# @namespace:path — use mention resolver
if source.startswith("@"):
if self.coordinator:
resolver = self.coordinator.get_capability("mention_resolver")
if resolver:
return resolver.resolve(source)
return None
if is_remote_source(source):
return await resolve_skill_source(source)
path = Path(source).expanduser().resolve()
return path if path.exists() else None
This method correctly dispatches @-prefixed sources via mention_resolver — but it is only invoked from execute() at runtime (line ~530), never from _resolve_skill_sources at mount.
The two paths diverged: the runtime resolver was extended to support @namespace: (likely when mention-resolution capability was added to the kernel), but the equivalent change was never applied to the mount-time resolver.
Test coverage confirms the omission: tests/test_source_parameter.py:110-119 exercises the runtime path only; no test exercises mount-time resolution of @-prefixed sources.
Proposed fix
Pre-resolve @-prefixed sources via mention_resolver before the is_remote_source dispatch. Minimal patch to _resolve_skill_sources:
# Insert immediately after sources are collected (before "has_remote = any(...)"):
# Pre-resolve @namespace: sources via mention_resolver (parallel to runtime path)
resolver = coordinator.get_capability("mention_resolver") if coordinator else None
resolved_sources: list[str] = []
for source in sources:
if isinstance(source, str) and source.startswith("@"):
if resolver is None:
logger.warning(
"Cannot resolve @-namespace skill source %r — "
"mention_resolver capability not available", source,
)
continue
resolved_path = resolver.resolve(source)
if resolved_path is None:
logger.warning(
"Could not resolve @-namespace skill source %r — "
"no matching bundle registered", source,
)
continue
resolved_sources.append(str(resolved_path))
else:
resolved_sources.append(source)
sources = resolved_sources
Notes:
- Uses
logger.warning rather than logger.debug for @-source failures, because a configured @-source that fails to resolve is almost certainly a misconfiguration the user wants to know about (in contrast to a genuinely-optional local path that may not exist on this machine).
- The resolved string still flows through the existing
has_remote / local-Path branches, so cache reuse and async resolution behavior are unchanged for the non-@ paths.
- A longer-term refactor would extract the per-source dispatch into a shared helper called by both
_resolve_skill_sources (mount) and _resolve_source (runtime), eliminating the parallel-path drift class of bug. Happy to submit either form as a PR if you'd like.
Suggested test addition
async def test_resolve_skill_sources_handles_namespace_mention(
monkeypatch, tmp_path,
):
skills_dir = tmp_path / "skills"
skills_dir.mkdir()
class FakeResolver:
def resolve(self, mention: str):
assert mention == "@mybundle:skills"
return skills_dir
class FakeCoordinator:
config = {}
def get_capability(self, name):
return FakeResolver() if name == "mention_resolver" else None
config = {"skills": ["@mybundle:skills"]}
result = await _resolve_skill_sources(config, FakeCoordinator())
assert skills_dir in result
Workarounds for affected users
Either of these works today without an upstream fix:
-
Git URL form (preferred, portable):
skills:
- "git+ssh://git@github.com/<owner>/<bundle>@main#subdirectory=skills"
Hits is_remote_source → cached resolution. Same source URL the bundle itself loads from, so the cache is reused. This is the workaround we shipped on our side ([commit reference]).
-
Absolute local path (single-machine):
skills:
- /absolute/path/to/bundle/skills
Loses cross-machine portability.
Neither workaround is discoverable from the documentation; both required reading tool-skills source.
Severity / impact
- Severity: medium. Silent failure with no error surface is the worst-case UX class. The fact that documented behavior diverges from actual behavior means anyone following the canonical README example hits this and has no diagnostic to start from.
- Generality: affects every bundle author who ships skills inside their own bundle and uses the documented
@bundle:skills pattern. The pattern is documented and encouraged but the third-party-bundle-shipping-its-own-skills ecosystem is still young, so practical impact today is narrow but will grow.
- Fix size: ~12 lines plus a regression test.
Related
- Documentation showing
@mybundle:skills as canonical: bundle.md in this repo (lines ~53-64) and context/skills-instructions.md.
- Test coverage gap:
tests/test_source_parameter.py:110-119 (runtime-only).
Happy to convert this into a PR if it's useful — let me know which form you'd prefer for the fix (the minimal patch above, or the longer-term shared-helper refactor).
Summary
The documented
@bundle:skillssource form works correctly when registered at runtime viaload_skill(source="@bundle:skills"), but is silently dropped when supplied through thetools[tool-skills].config.skills:list at mount time. No error is raised; the entries simply do not register. This affects every bundle author who follows the documented "ship-your-own-skills" pattern.In our case, 48 bundle-shipped skills failed to load into visibility for an unknown duration — the failure was only surfaced when a learning-digest hook flagged repeated
load_skillfailures referencing skills that exist on disk.The root cause is two parallel resolution paths in
tool-skills/__init__.pythat handle@-prefixed sources inconsistently — runtime path dispatches tomention_resolver, mount-time path falls through toPath().Reproducer
Minimal
toolsconfig in any behavior file:Expected: skills under
<mybundle root>/skills/are discovered and visible.Actual: zero skills from the bundle load. No error, no warning at default log level — only a
logger.debugline"Local skill source does not exist: /cwd/@mybundle:skills".To prove
@namespace:is otherwise functional, the same source resolves correctly via the runtime path:Expected vs. actual behavior
The README and
skills-instructions.mdboth document@mybundle:skillsas a canonical config source type:Users following this documentation will find their skills missing with no diagnostic. The workaround (use the
git+ssh://...#subdirectory=skillsform, or an absolute local path) requires reading source code to discover.Root cause
In
modules/tool-skills/amplifier_module_tool_skills/__init__.py:Mount-time path —
_resolve_skill_sources(lines ~57–134):is_remote_source(insources.py:24) matches onlygit+…/https:///http://prefixes, so@mybundle:skillsreturnsFalse. The source then falls into the local branch wherePath("@mybundle:skills").expanduser().resolve()produces<cwd>/@mybundle:skills, which does not exist, and the source is dropped at debug log level.Runtime path —
SkillsTool._resolve_source(lines ~488–514):This method correctly dispatches
@-prefixed sources viamention_resolver— but it is only invoked fromexecute()at runtime (line ~530), never from_resolve_skill_sourcesat mount.The two paths diverged: the runtime resolver was extended to support
@namespace:(likely when mention-resolution capability was added to the kernel), but the equivalent change was never applied to the mount-time resolver.Test coverage confirms the omission:
tests/test_source_parameter.py:110-119exercises the runtime path only; no test exercises mount-time resolution of@-prefixed sources.Proposed fix
Pre-resolve
@-prefixed sources viamention_resolverbefore theis_remote_sourcedispatch. Minimal patch to_resolve_skill_sources:Notes:
logger.warningrather thanlogger.debugfor@-source failures, because a configured@-source that fails to resolve is almost certainly a misconfiguration the user wants to know about (in contrast to a genuinely-optional local path that may not exist on this machine).has_remote/ local-Path branches, so cache reuse and async resolution behavior are unchanged for the non-@paths._resolve_skill_sources(mount) and_resolve_source(runtime), eliminating the parallel-path drift class of bug. Happy to submit either form as a PR if you'd like.Suggested test addition
Workarounds for affected users
Either of these works today without an upstream fix:
Git URL form (preferred, portable):
Hits
is_remote_source→ cached resolution. Same source URL the bundle itself loads from, so the cache is reused. This is the workaround we shipped on our side ([commit reference]).Absolute local path (single-machine):
Loses cross-machine portability.
Neither workaround is discoverable from the documentation; both required reading
tool-skillssource.Severity / impact
@bundle:skillspattern. The pattern is documented and encouraged but the third-party-bundle-shipping-its-own-skills ecosystem is still young, so practical impact today is narrow but will grow.Related
@mybundle:skillsas canonical:bundle.mdin this repo (lines ~53-64) andcontext/skills-instructions.md.tests/test_source_parameter.py:110-119(runtime-only).Happy to convert this into a PR if it's useful — let me know which form you'd prefer for the fix (the minimal patch above, or the longer-term shared-helper refactor).