feat(skills): skills.create_dir routes agent-created skills to a configured directory
Salvaged from PR #13996 (@giwaov, issue #13963), modernized onto current main: config key renamed to skills.create_dir (per PR #81002's naming), resolution centralized in agent/skill_utils.get_skill_create_dir() with ~/${VAR} expansion and HERMES_HOME-relative paths, and the directory is folded into get_all_skills_dirs() so created skills are discovered, trusted, findable, and patchable like local ones. Out-of-root creations report their absolute path instead of crashing relative_to().
This commit is contained in:
+73
-4
@@ -613,11 +613,76 @@ def get_external_skills_dirs() -> List[Path]:
|
||||
return result
|
||||
|
||||
|
||||
def get_skill_create_dir() -> Optional[Path]:
|
||||
"""Return the configured ``skills.create_dir``, or ``None`` when unset.
|
||||
|
||||
When set, agent-created skills (``skill_manage`` action=create) land in
|
||||
this directory instead of the profile-local ``~/.hermes/skills/``, and
|
||||
every user-facing instruction string that names the creation path renders
|
||||
this directory instead of the default.
|
||||
|
||||
The entry is expanded (``~`` and ``${VAR}``); relative paths resolve
|
||||
against HERMES_HOME. A value that resolves to the local skills dir is
|
||||
treated as unset (that is already the default behaviour). The directory
|
||||
does NOT need to exist yet — skill creation mkdirs it on first write.
|
||||
"""
|
||||
parsed = _load_raw_config()
|
||||
if not parsed:
|
||||
return None
|
||||
skills_cfg = parsed.get("skills")
|
||||
if not isinstance(skills_cfg, dict):
|
||||
return None
|
||||
raw = skills_cfg.get("create_dir")
|
||||
if not raw or not isinstance(raw, (str, os.PathLike)):
|
||||
return None
|
||||
entry = str(raw).strip()
|
||||
if not entry:
|
||||
return None
|
||||
|
||||
from hermes_constants import get_hermes_home
|
||||
|
||||
expanded = os.path.expanduser(os.path.expandvars(entry))
|
||||
p = Path(expanded)
|
||||
if not p.is_absolute():
|
||||
p = get_hermes_home() / p
|
||||
try:
|
||||
resolved = p.resolve()
|
||||
except OSError:
|
||||
resolved = p
|
||||
try:
|
||||
if resolved == get_skills_dir().resolve():
|
||||
return None
|
||||
except OSError:
|
||||
pass
|
||||
return resolved
|
||||
|
||||
|
||||
def display_skill_create_dir() -> str:
|
||||
"""User-facing display string for where new skills are created.
|
||||
|
||||
Renders the configured ``skills.create_dir`` (with ``~/`` shorthand when
|
||||
under the user's home) or the default ``<home>/skills/`` path. Used by
|
||||
instruction text (tool schema descriptions, prompts, docs strings) so a
|
||||
configured creation dir changes every instruction that names the path.
|
||||
"""
|
||||
from hermes_constants import display_hermes_home
|
||||
|
||||
create_dir = get_skill_create_dir()
|
||||
if create_dir is None:
|
||||
return f"{display_hermes_home()}/skills/"
|
||||
try:
|
||||
return "~/" + create_dir.relative_to(Path.home()).as_posix() + "/"
|
||||
except ValueError:
|
||||
return create_dir.as_posix() + "/"
|
||||
|
||||
|
||||
def get_all_skills_dirs() -> List[Path]:
|
||||
"""Return all skill directories: local ``~/.hermes/skills/`` first, then external.
|
||||
|
||||
The local dir is always first (and always included even if it doesn't exist
|
||||
yet — callers handle that). External dirs follow in config order.
|
||||
yet — callers handle that). When ``skills.create_dir`` is configured, it
|
||||
follows immediately after the local dir (so agent-created skills are
|
||||
discovered, trusted, and modifiable). External dirs follow in config order.
|
||||
|
||||
NOTE: trusted project-local dirs (``./.hermes/skills`` at the git root) are
|
||||
NOT part of this list — they have *higher* precedence than the local dir,
|
||||
@@ -626,7 +691,12 @@ def get_all_skills_dirs() -> List[Path]:
|
||||
precedence-ordered list.
|
||||
"""
|
||||
dirs = [get_skills_dir()]
|
||||
dirs.extend(get_external_skills_dirs())
|
||||
create_dir = get_skill_create_dir()
|
||||
if create_dir is not None and create_dir.is_dir():
|
||||
dirs.append(create_dir)
|
||||
for d in get_external_skills_dirs():
|
||||
if d not in dirs:
|
||||
dirs.append(d)
|
||||
return dirs
|
||||
|
||||
|
||||
@@ -810,8 +880,7 @@ def get_scan_ordered_skills_dirs() -> List[Path]:
|
||||
priority over profile-local and external ones.
|
||||
"""
|
||||
dirs = list(get_project_skills_dirs())
|
||||
dirs.append(get_skills_dir())
|
||||
dirs.extend(get_external_skills_dirs())
|
||||
dirs.extend(get_all_skills_dirs())
|
||||
return dirs
|
||||
|
||||
|
||||
|
||||
@@ -195,6 +195,20 @@ MAX_NAME_LENGTH = 64
|
||||
MAX_DESCRIPTION_LENGTH = 1024
|
||||
|
||||
|
||||
def _display_create_dir() -> str:
|
||||
"""Display string for the skill-creation directory (schema/instruction text).
|
||||
|
||||
Renders ``skills.create_dir`` when configured so every instruction that
|
||||
names the creation path follows the config, falling back to the
|
||||
profile-local skills dir.
|
||||
"""
|
||||
try:
|
||||
from agent.skill_utils import display_skill_create_dir
|
||||
return display_skill_create_dir()
|
||||
except Exception:
|
||||
return f"{display_hermes_home()}/skills/"
|
||||
|
||||
|
||||
def _containing_skills_root(skill_path: Path) -> Path:
|
||||
"""Return the skills root directory (local or external_dirs entry) that
|
||||
contains ``skill_path``. Falls back to the local ``SKILLS_DIR`` if no
|
||||
@@ -670,10 +684,23 @@ def _validate_content_size(content: str, label: str = "SKILL.md") -> Optional[st
|
||||
|
||||
|
||||
def _resolve_skill_dir(name: str, category: str = None) -> Path:
|
||||
"""Build the directory path for a new skill, optionally under a category."""
|
||||
"""Build the directory path for a new skill, optionally under a category.
|
||||
|
||||
Honors ``skills.create_dir`` from config.yaml: when configured, new
|
||||
skills are created there (e.g. a shared brain/fleet directory) instead
|
||||
of the profile-local skills dir. Falls back to the local dir when unset.
|
||||
"""
|
||||
base = _skills_dir()
|
||||
try:
|
||||
from agent.skill_utils import get_skill_create_dir
|
||||
create_dir = get_skill_create_dir()
|
||||
if create_dir is not None:
|
||||
base = create_dir
|
||||
except Exception:
|
||||
logger.debug("skills.create_dir lookup failed", exc_info=True)
|
||||
if category:
|
||||
return _skills_dir() / category / name
|
||||
return _skills_dir() / name
|
||||
return base / category / name
|
||||
return base / name
|
||||
|
||||
|
||||
def _find_skill(name: str) -> Optional[Dict[str, Any]]:
|
||||
@@ -1028,10 +1055,16 @@ def _create_skill(name: str, content: str, category: str = None) -> Dict[str, An
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
try:
|
||||
_display_path = str(skill_dir.relative_to(_skills_dir()))
|
||||
except ValueError:
|
||||
# Skill created under skills.create_dir — not relative to the
|
||||
# profile-local root, so show the absolute path.
|
||||
_display_path = str(skill_dir)
|
||||
result = {
|
||||
"success": True,
|
||||
"message": f"Skill '{name}' created.",
|
||||
"path": str(skill_dir.relative_to(_skills_dir())),
|
||||
"path": _display_path,
|
||||
"skill_md": str(skill_md),
|
||||
"_change": {"description": _desc},
|
||||
}
|
||||
@@ -2107,7 +2140,7 @@ SKILL_MANAGE_SCHEMA = {
|
||||
"recurring task types. The call is an operations array (a single "
|
||||
"edit is a list of one); it applies atomically — any failure rolls "
|
||||
"every touched skill back. Ops: create (full SKILL.md; lands in "
|
||||
f"{display_hermes_home()}/skills/; must precede that skill's other "
|
||||
f"{_display_create_dir()}; must precede that skill's other "
|
||||
"ops), patch (targeted old_string/new_string fix — preferred; "
|
||||
"content alone REPLACES the whole file, read it via skill_view() "
|
||||
"first), write_file/remove_file (supporting files), delete (sole "
|
||||
|
||||
Reference in New Issue
Block a user