Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 7 additions & 5 deletions hermes_cli/skills_hub.py
Original file line number Diff line number Diff line change
Expand Up @@ -319,12 +319,14 @@ def do_browse(page: int = 1, page_size: int = 20, source: str = "all",
c.print("[dim]No skills found in the Skills Hub.[/]\n")
return

# Deduplicate by name, preferring higher trust
# Deduplicate by identifier, preferring higher trust.
# identifier is always unique per skill; name is not (browse-sh skills from different
# sites can share the same task name, e.g. "search-listings" on Airbnb and Booking.com).
seen: dict = {}
for r in all_results:
rank = _TRUST_RANK.get(r.trust_level, 0)
if r.name not in seen or rank > _TRUST_RANK.get(seen[r.name].trust_level, 0):
seen[r.name] = r
if r.identifier not in seen or rank > _TRUST_RANK.get(seen[r.identifier].trust_level, 0):
seen[r.identifier] = r
deduped = list(seen.values())

# Sort: official first, then by trust level (desc), then alphabetically
Expand Down Expand Up @@ -702,8 +704,8 @@ def browse_skills(page: int = 1, page_size: int = 20, source: str = "all") -> di
seen: dict = {}
for r in all_results:
rank = _TRUST_RANK.get(r.trust_level, 0)
if r.name not in seen or rank > _TRUST_RANK.get(seen[r.name].trust_level, 0):
seen[r.name] = r
if r.identifier not in seen or rank > _TRUST_RANK.get(seen[r.identifier].trust_level, 0):
seen[r.identifier] = r
deduped = list(seen.values())
deduped.sort(key=lambda r: (-_TRUST_RANK.get(r.trust_level, 0), r.source != "official", r.name.lower()))
total = len(deduped)
Expand Down
41 changes: 41 additions & 0 deletions tests/hermes_cli/test_skills_hub.py
Original file line number Diff line number Diff line change
Expand Up @@ -524,3 +524,44 @@ def test_existing_categories_returns_empty_when_skills_dir_missing(monkeypatch,

from hermes_cli.skills_hub import _existing_categories
assert _existing_categories() == []


# ---------------------------------------------------------------------------
# browse_skills — dedup by identifier, not name
# ---------------------------------------------------------------------------


def test_browse_skills_dedup_uses_identifier_not_name(monkeypatch):
"""browse_skills() must not collapse browse-sh skills that share a task name.

Airbnb and Booking.com both publish a 'search-listings' skill. Before the
fix, both were keyed by name so only one survived deduplication. After the
fix, each unique identifier produces a distinct result.
"""
from tools.skills_hub import SkillMeta
from hermes_cli.skills_hub import browse_skills

airbnb = SkillMeta(
name="search-listings", description="Airbnb search", source="browse-sh",
identifier="browse-sh/airbnb.com/search-listings-ddgioa", trust_level="community",
)
booking = SkillMeta(
name="search-listings", description="Booking.com search", source="browse-sh",
identifier="browse-sh/booking.com/search-listings-xyzab", trust_level="community",
)

mock_src = type("S", (), {
"source_id": lambda self: "browse-sh",
"search": lambda self, q, limit=500: [airbnb, booking],
})()

# browse_skills() imports create_source_router locally from tools.skills_hub,
# so the patch must target the source module, not hermes_cli.skills_hub.
with patch("tools.skills_hub.create_source_router", return_value=[mock_src]):
result = browse_skills(page=1, page_size=50)

names = [item["name"] for item in result["items"]]
assert names.count("search-listings") == 2, (
"browse_skills() must not deduplicate browse-sh skills with the same name "
"but different identifiers"
)
35 changes: 27 additions & 8 deletions tests/tools/test_skills_hub.py
Original file line number Diff line number Diff line change
Expand Up @@ -1279,21 +1279,23 @@ def _make_source(self, source_id, results):
return src

def test_dedup_keeps_first_seen(self):
# Same identifier from two sources — only the first (community) is kept when equal trust.
s1 = SkillMeta(name="skill", description="from A", source="a",
identifier="a/skill", trust_level="community")
identifier="shared/skill", trust_level="community")
s2 = SkillMeta(name="skill", description="from B", source="b",
identifier="b/skill", trust_level="community")
identifier="shared/skill", trust_level="community")
src_a = self._make_source("a", [s1])
src_b = self._make_source("b", [s2])
results = unified_search("skill", [src_a, src_b])
assert len(results) == 1
assert results[0].description == "from A"

def test_dedup_prefers_trusted_over_community(self):
# Same identifier — trusted wins over community.
community = SkillMeta(name="skill", description="community", source="a",
identifier="a/skill", trust_level="community")
identifier="shared/skill", trust_level="community")
trusted = SkillMeta(name="skill", description="trusted", source="b",
identifier="b/skill", trust_level="trusted")
identifier="shared/skill", trust_level="trusted")
src_a = self._make_source("a", [community])
src_b = self._make_source("b", [trusted])
results = unified_search("skill", [src_a, src_b])
Expand All @@ -1303,9 +1305,9 @@ def test_dedup_prefers_trusted_over_community(self):
def test_dedup_prefers_builtin_over_trusted(self):
"""Regression: builtin must not be overwritten by trusted."""
builtin = SkillMeta(name="skill", description="builtin", source="a",
identifier="a/skill", trust_level="builtin")
identifier="shared/skill", trust_level="builtin")
trusted = SkillMeta(name="skill", description="trusted", source="b",
identifier="b/skill", trust_level="trusted")
identifier="shared/skill", trust_level="trusted")
src_a = self._make_source("a", [builtin])
src_b = self._make_source("b", [trusted])
results = unified_search("skill", [src_a, src_b])
Expand All @@ -1314,14 +1316,31 @@ def test_dedup_prefers_builtin_over_trusted(self):

def test_dedup_trusted_not_overwritten_by_community(self):
trusted = SkillMeta(name="skill", description="trusted", source="a",
identifier="a/skill", trust_level="trusted")
identifier="shared/skill", trust_level="trusted")
community = SkillMeta(name="skill", description="community", source="b",
identifier="b/skill", trust_level="community")
identifier="shared/skill", trust_level="community")
src_a = self._make_source("a", [trusted])
src_b = self._make_source("b", [community])
results = unified_search("skill", [src_a, src_b])
assert results[0].trust_level == "trusted"

def test_browse_sh_same_name_different_site_not_deduped(self):
# Browse.sh skills from different hostnames share task names (e.g. "search-listings")
# but have unique identifiers. They must NOT be collapsed into one result.
airbnb = SkillMeta(
name="search-listings", description="Airbnb search", source="browse-sh",
identifier="browse-sh/airbnb.com/search-listings-ddgioa", trust_level="community",
)
booking = SkillMeta(
name="search-listings", description="Booking.com search", source="browse-sh",
identifier="browse-sh/booking.com/search-listings-xyzab", trust_level="community",
)
src = self._make_source("browse-sh", [airbnb, booking])
results = unified_search("search-listings", [src])
assert len(results) == 2, (
"browse-sh skills with the same name but different sites must not be deduplicated"
)

def test_source_filter(self):
s1 = SkillMeta(name="s1", description="d", source="a",
identifier="x", trust_level="community")
Expand Down
25 changes: 15 additions & 10 deletions tools/skills_hub.py
Original file line number Diff line number Diff line change
Expand Up @@ -379,14 +379,16 @@ def search(self, query: str, limit: int = 10) -> List[SkillMeta]:
logger.debug(f"Failed to search {tap['repo']}: {e}")
continue

# Deduplicate by name, preferring higher trust levels
# Deduplicate by identifier, preferring higher trust levels.
# identifier is unique per skill; name is not (two configured taps can
# publish skills with the same name but different identifiers).
_trust_rank = {"builtin": 2, "trusted": 1, "community": 0}
seen = {}
for r in results:
if r.name not in seen:
seen[r.name] = r
elif _trust_rank.get(r.trust_level, 0) > _trust_rank.get(seen[r.name].trust_level, 0):
seen[r.name] = r
if r.identifier not in seen:
seen[r.identifier] = r
elif _trust_rank.get(r.trust_level, 0) > _trust_rank.get(seen[r.identifier].trust_level, 0):
seen[r.identifier] = r
results = list(seen.values())

return results[:limit]
Expand Down Expand Up @@ -3425,14 +3427,17 @@ def unified_search(query: str, sources: List[SkillSource],
overall_timeout=30,
)

# Deduplicate by name, preferring higher trust levels
# Deduplicate by identifier, preferring higher trust levels.
# identifier is always unique per skill (e.g. "browse-sh/airbnb.com/search-listings-ddgioa").
# Using name would incorrectly collapse browse-sh skills from different sites that share
# the same task name (e.g. "search-listings" from Airbnb and Booking.com).
_TRUST_RANK = {"builtin": 2, "trusted": 1, "community": 0}
seen: Dict[str, SkillMeta] = {}
for r in all_results:
if r.name not in seen:
seen[r.name] = r
elif _TRUST_RANK.get(r.trust_level, 0) > _TRUST_RANK.get(seen[r.name].trust_level, 0):
seen[r.name] = r
if r.identifier not in seen:
seen[r.identifier] = r
elif _TRUST_RANK.get(r.trust_level, 0) > _TRUST_RANK.get(seen[r.identifier].trust_level, 0):
seen[r.identifier] = r
deduped = list(seen.values())

return deduped[:limit]
Loading