Skip to content

[amplifier-bundle-skills] tool-skills: @namespace:path skill sources silently dropped when configured at mount time #287

@Joi

Description

@Joi

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:

  1. 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]).

  2. 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).

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions