feat: wire skills.external_dirs into all remaining discovery paths

The config key skills.external_dirs and core resolution (get_all_skills_dirs,
get_external_skills_dirs in agent/skill_utils.py) already existed but several
code paths still only scanned SKILLS_DIR. Now external dirs are respected
everywhere:

- skills_categories(): scan all dirs for category discovery
- _get_category_from_path(): resolve categories against any skills root
- skill_manager_tool._find_skill(): search all dirs for edit/patch/delete
- credential_files.get_skills_directory_mount(): mount all dirs into
  Docker/Singularity containers (external dirs at external_skills/<idx>)
- credential_files.iter_skills_files(): list files from all dirs for
  Modal/Daytona upload
- tools/environments/ssh.py: rsync all skill dirs to remote hosts
- gateway _check_unavailable_skill(): check disabled skills across all dirs

Usage in config.yaml:
  skills:
    external_dirs:
      - ~/repos/agent-skills/hermes
      - /shared/team-skills
This commit is contained in:
Teknium 2026-04-03 21:14:34 -07:00
parent 5a98ce5973
commit ad4feeaf0d
No known key found for this signature in database
8 changed files with 149 additions and 86 deletions

View file

@ -193,8 +193,8 @@ def get_credential_file_mounts() -> List[Dict[str, str]]:
def get_skills_directory_mount(
container_base: str = "/root/.hermes",
) -> Dict[str, str] | None:
"""Return mount info for a symlink-safe copy of the skills directory.
) -> list[Dict[str, str]]:
"""Return mount info for all skill directories (local + external).
Skills may include ``scripts/``, ``templates/``, and ``references/``
subdirectories that the agent needs to execute inside remote sandboxes.
@ -206,18 +206,34 @@ def get_skills_directory_mount(
symlinks are present (the common case), the original directory is returned
directly with zero overhead.
Returns a dict with ``host_path`` and ``container_path`` keys, or None.
Returns a list of dicts with ``host_path`` and ``container_path`` keys.
The local skills dir mounts at ``<container_base>/skills``, external dirs
at ``<container_base>/external_skills/<index>``.
"""
mounts = []
hermes_home = _resolve_hermes_home()
skills_dir = hermes_home / "skills"
if not skills_dir.is_dir():
return None
if skills_dir.is_dir():
host_path = _safe_skills_path(skills_dir)
mounts.append({
"host_path": host_path,
"container_path": f"{container_base.rstrip('/')}/skills",
})
host_path = _safe_skills_path(skills_dir)
return {
"host_path": host_path,
"container_path": f"{container_base.rstrip('/')}/skills",
}
# Mount external skill dirs
try:
from agent.skill_utils import get_external_skills_dirs
for idx, ext_dir in enumerate(get_external_skills_dirs()):
if ext_dir.is_dir():
host_path = _safe_skills_path(ext_dir)
mounts.append({
"host_path": host_path,
"container_path": f"{container_base.rstrip('/')}/external_skills/{idx}",
})
except ImportError:
pass
return mounts
_safe_skills_tempdir: Path | None = None
@ -271,24 +287,44 @@ def iter_skills_files(
) -> List[Dict[str, str]]:
"""Yield individual (host_path, container_path) entries for skills files.
Skips symlinks entirely. Preferred for backends that upload files
individually (Daytona, Modal) rather than mounting a directory.
Includes both the local skills dir and any external dirs configured via
skills.external_dirs. Skips symlinks entirely. Preferred for backends
that upload files individually (Daytona, Modal) rather than mounting a
directory.
"""
result: List[Dict[str, str]] = []
hermes_home = _resolve_hermes_home()
skills_dir = hermes_home / "skills"
if not skills_dir.is_dir():
return []
if skills_dir.is_dir():
container_root = f"{container_base.rstrip('/')}/skills"
for item in skills_dir.rglob("*"):
if item.is_symlink() or not item.is_file():
continue
rel = item.relative_to(skills_dir)
result.append({
"host_path": str(item),
"container_path": f"{container_root}/{rel}",
})
# Include external skill dirs
try:
from agent.skill_utils import get_external_skills_dirs
for idx, ext_dir in enumerate(get_external_skills_dirs()):
if not ext_dir.is_dir():
continue
container_root = f"{container_base.rstrip('/')}/external_skills/{idx}"
for item in ext_dir.rglob("*"):
if item.is_symlink() or not item.is_file():
continue
rel = item.relative_to(ext_dir)
result.append({
"host_path": str(item),
"container_path": f"{container_root}/{rel}",
})
except ImportError:
pass
container_root = f"{container_base.rstrip('/')}/skills"
result: List[Dict[str, str]] = []
for item in skills_dir.rglob("*"):
if item.is_symlink() or not item.is_file():
continue
rel = item.relative_to(skills_dir)
result.append({
"host_path": str(item),
"container_path": f"{container_root}/{rel}",
})
return result