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
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions