mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-31 19:16:29 +00:00
feat(skills-hub): unify both science skill repos under one 'science' bucket
Adds an optional tap-level 'bucket' key that stamps a shared hub category on every skill from a tap when the repo ships no skills.sh.json grouping (the sidecar still wins when present). Taps both scientific-skill repos under bucket='science' so they surface together instead of as two unrelated repos: - K-Dense-AI/scientific-agent-skills (~150, flat skills/<name>/, MIT) - synthetic-sciences/openscience (~290, nested backend/cli/skills/<cat>/<name>/, Apache-2.0) — one tap entry per category since _list_skills_in_repo only walks one level under a tap path Both community trust (NOT in TRUSTED_REPOS): guard scans every skill, INSTALL_POLICY auto-installs only 'safe'. Overlapping skill names surface from both repos with distinct identifiers; the user picks the source. Tests: bucket stamps category with no sidecar; sidecar grouping wins over bucket. 160/160 test_skills_hub pass.
This commit is contained in:
parent
beffeea053
commit
c7657974aa
2 changed files with 101 additions and 19 deletions
|
|
@ -198,6 +198,59 @@ class TestSkillsShGroupings:
|
|||
assert len(skills) == 1
|
||||
assert "category" not in skills[0].extra
|
||||
|
||||
def test_list_skills_bucket_stamps_category_when_no_sidecar(self):
|
||||
# A tap-level bucket labels every skill when the repo ships no
|
||||
# skills.sh.json grouping — this is how several repos share one hub
|
||||
# category (e.g. science).
|
||||
auth = MagicMock()
|
||||
src = GitHubSource(auth=auth)
|
||||
|
||||
meta = SkillMeta(
|
||||
name="rdkit", description="d", source="github",
|
||||
identifier="K-Dense-AI/scientific-agent-skills/skills/rdkit",
|
||||
trust_level="community",
|
||||
)
|
||||
resp = MagicMock()
|
||||
resp.status_code = 200
|
||||
resp.json.return_value = [{"type": "dir", "name": "rdkit"}]
|
||||
|
||||
with patch.object(src, "_read_cache", return_value=None), \
|
||||
patch.object(src, "_write_cache"), \
|
||||
patch.object(src, "_get_skillsh_groupings", return_value=None), \
|
||||
patch.object(src, "inspect", return_value=meta), \
|
||||
patch("tools.skills_hub.httpx.get", return_value=resp):
|
||||
skills = src._list_skills_in_repo(
|
||||
"K-Dense-AI/scientific-agent-skills", "skills/", "science"
|
||||
)
|
||||
|
||||
assert len(skills) == 1
|
||||
assert skills[0].extra["category"] == "science"
|
||||
|
||||
def test_list_skills_sidecar_grouping_wins_over_bucket(self):
|
||||
# When a repo DOES publish a skills.sh.json grouping, that wins over
|
||||
# the tap-level bucket fallback.
|
||||
auth = MagicMock()
|
||||
src = GitHubSource(auth=auth)
|
||||
|
||||
meta = SkillMeta(
|
||||
name="cuopt-developer", description="d", source="github",
|
||||
identifier="NVIDIA/skills/skills/cuopt-developer", trust_level="trusted",
|
||||
)
|
||||
resp = MagicMock()
|
||||
resp.status_code = 200
|
||||
resp.json.return_value = [{"type": "dir", "name": "cuopt-developer"}]
|
||||
|
||||
with patch.object(src, "_read_cache", return_value=None), \
|
||||
patch.object(src, "_write_cache"), \
|
||||
patch.object(src, "_get_skillsh_groupings",
|
||||
return_value={"cuopt-developer": "Decision Optimization"}), \
|
||||
patch.object(src, "inspect", return_value=meta), \
|
||||
patch("tools.skills_hub.httpx.get", return_value=resp):
|
||||
skills = src._list_skills_in_repo("NVIDIA/skills", "skills/", "science")
|
||||
|
||||
assert len(skills) == 1
|
||||
assert skills[0].extra["category"] == "Decision Optimization"
|
||||
|
||||
def test_meta_to_dict_roundtrip_preserves_extra(self):
|
||||
meta = SkillMeta(
|
||||
name="x", description="d", source="github",
|
||||
|
|
|
|||
|
|
@ -525,19 +525,36 @@ class GitHubSource(SkillSource):
|
|||
# https://github.com/NVIDIA/skills/tree/main/skills
|
||||
{"repo": "NVIDIA/skills", "path": "skills/"},
|
||||
{"repo": "garrytan/gstack", "path": ""},
|
||||
# K-Dense-AI/scientific-agent-skills: ~150 scientific research skills
|
||||
# (ML training/eval, computational biology, cheminformatics, physics,
|
||||
# scientific databases). MIT-licensed skill content; the flat
|
||||
# skills/<name>/ layout matches the anthropic/huggingface taps. This is
|
||||
# a community tap on purpose — it is deliberately NOT in
|
||||
# tools/skills_guard.py::TRUSTED_REPOS, so it resolves at "community"
|
||||
# trust: the security guard still scans every skill and INSTALL_POLICY
|
||||
# only auto-installs the ones it rates "safe". Individual skills wrap
|
||||
# third-party tools whose OWN licenses vary (some GPL, KEGG needs a
|
||||
# commercial license for non-academic use); those terms are the
|
||||
# installer's concern at use time and are surfaced in each skill's own
|
||||
# frontmatter/Prerequisites, not vetted here.
|
||||
{"repo": "K-Dense-AI/scientific-agent-skills", "path": "skills/"},
|
||||
# --- Science bucket -------------------------------------------------
|
||||
# Two upstream scientific-skill repos are tapped under one shared hub
|
||||
# category ("science") via the tap-level "bucket" key, so their skills
|
||||
# surface together instead of as two unrelated repos. Both are
|
||||
# community trust on purpose — deliberately NOT in
|
||||
# tools/skills_guard.py::TRUSTED_REPOS — so the security guard scans
|
||||
# every skill and INSTALL_POLICY only auto-installs "safe" ones.
|
||||
# Individual skills wrap third-party tools whose OWN licenses vary
|
||||
# (some GPL; KEGG needs a commercial license for non-academic use);
|
||||
# those terms are surfaced per-skill (frontmatter/Prerequisites) and
|
||||
# are the installer's concern at use time, not vetted here.
|
||||
#
|
||||
# K-Dense-AI/scientific-agent-skills: ~150 skills, flat skills/<name>/
|
||||
# layout (matches the anthropic/huggingface taps). MIT-licensed content.
|
||||
{"repo": "K-Dense-AI/scientific-agent-skills", "path": "skills/", "bucket": "science"},
|
||||
# synthetic-sciences/openscience: ~290 skills, Apache-2.0. Nested
|
||||
# backend/cli/skills/<category>/<name>/ layout — _list_skills_in_repo
|
||||
# only walks ONE level under a tap path, so each category is tapped
|
||||
# separately (same one-entry-per-inner-path pattern as openai above).
|
||||
*(
|
||||
{"repo": "synthetic-sciences/openscience",
|
||||
"path": f"backend/cli/skills/{_cat}/", "bucket": "science"}
|
||||
for _cat in (
|
||||
"biology", "chemistry", "cloud-compute", "coding",
|
||||
"data-engineering", "databases", "document-parsing",
|
||||
"llm-tools", "ml-inference", "ml-training", "other",
|
||||
"physics", "quantum", "research", "scholar-evaluation",
|
||||
"visualization", "writing",
|
||||
)
|
||||
),
|
||||
]
|
||||
|
||||
def __init__(self, auth: GitHubAuth, extra_taps: Optional[List[Dict]] = None):
|
||||
|
|
@ -579,7 +596,7 @@ class GitHubSource(SkillSource):
|
|||
|
||||
for tap in self.taps:
|
||||
try:
|
||||
skills = self._list_skills_in_repo(tap["repo"], tap.get("path", ""))
|
||||
skills = self._list_skills_in_repo(tap["repo"], tap.get("path", ""), tap.get("bucket"))
|
||||
for skill in skills:
|
||||
searchable = f"{skill.name} {skill.description} {' '.join(skill.tags)}".lower()
|
||||
if query_lower in searchable:
|
||||
|
|
@ -676,9 +693,15 @@ class GitHubSource(SkillSource):
|
|||
|
||||
# -- Internal helpers --
|
||||
|
||||
def _list_skills_in_repo(self, repo: str, path: str) -> List[SkillMeta]:
|
||||
"""List skill directories in a GitHub repo path, using cached index."""
|
||||
cache_key = f"{repo}_{path}".replace("/", "_").replace(" ", "_")
|
||||
def _list_skills_in_repo(self, repo: str, path: str, bucket: Optional[str] = None) -> List[SkillMeta]:
|
||||
"""List skill directories in a GitHub repo path, using cached index.
|
||||
|
||||
``bucket`` optionally forces a category label on every skill from this
|
||||
tap (used to unify skills from several repos under one hub category,
|
||||
e.g. multiple science repos → "science"). A repo's own
|
||||
``skills.sh.json`` grouping still wins when present.
|
||||
"""
|
||||
cache_key = f"{repo}_{path}_{bucket or ''}".replace("/", "_").replace(" ", "_")
|
||||
cached = self._read_cache(cache_key)
|
||||
if cached is not None:
|
||||
return [SkillMeta(**s) for s in cached]
|
||||
|
|
@ -706,10 +729,16 @@ class GitHubSource(SkillSource):
|
|||
skill_identifier = f"{repo}/{prefix}/{dir_name}" if prefix else f"{repo}/{dir_name}"
|
||||
meta = self.inspect(skill_identifier)
|
||||
if meta:
|
||||
category = None
|
||||
if groupings:
|
||||
category = groupings.get(meta.name) or groupings.get(dir_name)
|
||||
if category:
|
||||
meta.extra["category"] = category
|
||||
# Tap-level bucket is the fallback when the repo ships no
|
||||
# skills.sh.json grouping — lets several repos share one
|
||||
# hub category (e.g. science) without a sidecar we can't edit.
|
||||
if not category and bucket:
|
||||
category = bucket
|
||||
if category:
|
||||
meta.extra["category"] = category
|
||||
skills.append(meta)
|
||||
|
||||
# Cache the results
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue