Skip to content
Closed
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
65 changes: 59 additions & 6 deletions hermes_cli/web_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -7031,6 +7031,27 @@ async def update_skills_hub():
}


def _skill_hub_source_entry(source_id: str, *, error: str | None = None) -> dict:
entry = {
"id": source_id,
"label": _SKILL_HUB_SOURCE_LABELS.get(source_id, source_id),
}
if source_id == "hermes-index":
entry["available"] = False
if source_id == "github":
entry["rate_limited"] = False
if error:
entry["error"] = error
return entry


def _default_skill_hub_source_entries(*, error: str | None = None) -> list[dict]:
return [
_skill_hub_source_entry(source_id, error=error)
for source_id in _SKILL_HUB_SOURCE_LABELS
]


def _skill_meta_to_payload(m) -> dict:
return {
"name": m.name,
Expand Down Expand Up @@ -7079,12 +7100,31 @@ async def list_skills_hub_sources():
def _run():
from tools.skills_hub import create_source_router

sources = create_source_router()
try:
sources = create_source_router()
except Exception as exc:
source_error = str(exc) or exc.__class__.__name__
return {
"sources": _default_skill_hub_source_entries(error=source_error),
"index_available": False,
"featured": [],
"installed": _installed_hub_identifiers(),
}

out = []
index_available = False
featured = []
for src in sources:
sid = src.source_id()
try:
sid = src.source_id()
except Exception as exc:
out.append(
_skill_hub_source_entry(
"unknown",
error=str(exc) or exc.__class__.__name__,
)
)
continue
entry = {
"id": sid,
"label": _SKILL_HUB_SOURCE_LABELS.get(sid, sid),
Expand All @@ -7093,22 +7133,25 @@ def _run():
if sid == "github":
try:
entry["rate_limited"] = bool(getattr(src, "is_rate_limited", False))
except Exception:
except Exception as exc:
entry["rate_limited"] = False
entry["error"] = str(exc) or exc.__class__.__name__
if sid == "hermes-index":
try:
index_available = bool(getattr(src, "is_available", False))
except Exception:
except Exception as exc:
index_available = False
entry["error"] = str(exc) or exc.__class__.__name__
entry["available"] = index_available
# Empty-query search on the index returns featured/popular skills.
if index_available:
try:
featured = [
_skill_meta_to_payload(m) for m in src.search("", limit=12)
]
except Exception:
except Exception as exc:
featured = []
entry["error"] = str(exc) or exc.__class__.__name__
out.append(entry)
return {
"sources": out,
Expand Down Expand Up @@ -7142,8 +7185,18 @@ def _run():

sources = create_source_router()
capped = min(max(limit, 1), 50)
per_source_limits = {}
for src in sources:
try:
per_source_limits[src.source_id()] = capped
except Exception:
pass
all_results, source_counts, timed_out = parallel_search_sources(
sources, query=query, source_filter=source or "all", overall_timeout=30
sources,
query=query,
per_source_limits=per_source_limits,
source_filter=source or "all",
overall_timeout=30,
)

# Dedupe by identifier, preferring higher trust (mirrors unified_search).
Expand Down
52 changes: 51 additions & 1 deletion tests/hermes_cli/test_dashboard_admin_endpoints.py
Original file line number Diff line number Diff line change
Expand Up @@ -362,6 +362,41 @@ def test_empty_query_returns_empty(self):
assert body["timed_out"] == []
assert body["installed"] == {}

def test_search_returns_partial_results_with_timeout_metadata(self, monkeypatch):
class _Src:
def __init__(self, sid):
self._sid = sid

def source_id(self):
return self._sid

class _Meta:
name = "fast"
description = "desc"
source = "fast"
identifier = "fast/skill"
trust_level = "community"
repo = "owner/repo"
tags = ["a"]

def _fake_search(sources, **kwargs):
assert kwargs["per_source_limits"] == {"fast": 3, "slow": 3}
assert kwargs["source_filter"] == "all"
return [_Meta()], {"fast": 1}, ["slow"]

monkeypatch.setattr(
"tools.skills_hub.create_source_router",
lambda: [_Src("fast"), _Src("slow")],
)
monkeypatch.setattr("tools.skills_hub.parallel_search_sources", _fake_search)

r = self.client.get("/api/skills/hub/search?q=foo&limit=3")
assert r.status_code == 200
body = r.json()
assert body["results"][0]["identifier"] == "fast/skill"
assert body["source_counts"] == {"fast": 1}
assert body["timed_out"] == ["slow"]


class _FakeMeta:
"""Minimal SkillMeta stand-in for monkeypatched source search."""
Expand Down Expand Up @@ -440,6 +475,22 @@ def _fake_router():
assert body["featured"][0]["trust_level"] == "trusted"
assert isinstance(body["installed"], dict)

def test_sources_returns_known_hubs_when_router_fails(self, monkeypatch):
def _boom():
raise RuntimeError("router down")

monkeypatch.setattr("tools.skills_hub.create_source_router", _boom)

r = self.client.get("/api/skills/hub/sources")
assert r.status_code == 200
body = r.json()
ids = {s["id"] for s in body["sources"]}
assert {"official", "hermes-index", "github"} <= ids
assert body["index_available"] is False
assert body["featured"] == []
assert all(s.get("label") for s in body["sources"])
assert all(s.get("error") == "router down" for s in body["sources"])


class TestSkillsHubPreviewEndpoint:
@pytest.fixture(autouse=True)
Expand Down Expand Up @@ -953,4 +1004,3 @@ def test_endpoints_require_session_token(self):
kwargs["json"] = payload
r = fn(path, **kwargs)
assert r.status_code == 401, f"{method} {path} not gated"

69 changes: 69 additions & 0 deletions tests/tools/test_skills_hub.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
"""Tests for tools/skills_hub.py — source adapters, lock file, taps, dedup logic."""

import json
import time
from unittest.mock import patch, MagicMock

import httpx
Expand All @@ -21,6 +22,7 @@
bundle_content_hash,
check_for_skill_updates,
create_source_router,
parallel_search_sources,
unified_search,
append_audit_log,
_skill_meta_to_dict,
Expand Down Expand Up @@ -1493,6 +1495,73 @@ def test_missing_meta(self):
assert "name: bare-agent" in result


# ---------------------------------------------------------------------------
# parallel_search_sources — timeout/error handling
# ---------------------------------------------------------------------------


class TestParallelSearchSources:
def _make_source(self, source_id, results=None, *, delay=0.0, error=None):
class _Source:
def source_id(self):
return source_id

def search(self, query, limit=10):
if delay:
time.sleep(delay)
if error:
raise error
return results or []

return _Source()

def test_timeout_returns_partial_results_without_waiting_for_slow_source(self):
fast_result = SkillMeta(
name="fast",
description="d",
source="fast",
identifier="fast/skill",
trust_level="community",
)
fast = self._make_source("fast", [fast_result])
slow = self._make_source("slow", [], delay=0.8)

start = time.monotonic()
results, counts, timed_out = parallel_search_sources(
[fast, slow],
query="skill",
overall_timeout=0.05,
)
elapsed = time.monotonic() - start

assert elapsed < 0.5
assert results == [fast_result]
assert counts == {"fast": 1}
assert timed_out == ["slow"]

def test_source_error_returns_empty_results_for_that_source(self):
ok_result = SkillMeta(
name="ok",
description="d",
source="ok",
identifier="ok/skill",
trust_level="community",
)
failing = self._make_source("fail", error=RuntimeError("boom"))
ok = self._make_source("ok", [ok_result])

results, counts, timed_out = parallel_search_sources(
[failing, ok],
query="skill",
overall_timeout=1,
)

assert results == [ok_result]
assert counts["fail"] == 0
assert counts["ok"] == 1
assert timed_out == []


# ---------------------------------------------------------------------------
# unified_search — dedup logic
# ---------------------------------------------------------------------------
Expand Down
43 changes: 31 additions & 12 deletions tools/skills_hub.py
Original file line number Diff line number Diff line change
Expand Up @@ -3739,7 +3739,11 @@ def parallel_search_sources(
*on_source_done* is an optional callback ``(source_id, count) -> None``
invoked as each source completes — useful for progress indicators.
"""
from concurrent.futures import ThreadPoolExecutor, as_completed
from concurrent.futures import (
ThreadPoolExecutor,
TimeoutError as FuturesTimeoutError,
as_completed,
)

per_source_limits = per_source_limits or {}

Expand Down Expand Up @@ -3774,32 +3778,47 @@ def parallel_search_sources(
if not active:
return all_results, source_counts, timed_out_ids

with ThreadPoolExecutor(max_workers=min(len(active), 8)) as pool:
futures = {}
pool = ThreadPoolExecutor(max_workers=min(len(active), 8))
futures = {}
processed = set()

def _record_future(fut) -> None:
processed.add(fut)
try:
sid, results = fut.result(timeout=0)
source_counts[sid] = len(results)
all_results.extend(results)
if on_source_done:
on_source_done(sid, len(results))
except Exception:
pass

try:
for src in active:
lim = per_source_limits.get(src.source_id(), 50)
fut = pool.submit(_search_one_source, src, query, lim)
futures[fut] = src.source_id()

try:
for fut in as_completed(futures, timeout=overall_timeout):
try:
sid, results = fut.result(timeout=0)
source_counts[sid] = len(results)
all_results.extend(results)
if on_source_done:
on_source_done(sid, len(results))
except Exception:
pass
except TimeoutError:
_record_future(fut)
except FuturesTimeoutError:
for fut in futures:
if fut.done() and fut not in processed:
_record_future(fut)
timed_out_ids = [
futures[f] for f in futures if not f.done()
]
for fut in futures:
if not fut.done():
fut.cancel()
if timed_out_ids:
logger.debug(
"Skills browse timed out waiting for: %s",
", ".join(timed_out_ids),
)
finally:
pool.shutdown(wait=False, cancel_futures=True)

return all_results, source_counts, timed_out_ids

Expand Down
5 changes: 4 additions & 1 deletion web/src/lib/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -963,9 +963,10 @@ export const api = {
}),
updateSkillsFromHub: () =>
fetchJSON<ActionResponse>("/api/skills/hub/update", { method: "POST" }),
searchSkillsHub: (q: string, source = "all", limit = 20) =>
searchSkillsHub: (q: string, source = "all", limit = 20, init?: RequestInit) =>
fetchJSON<SkillHubSearchResponse>(
`/api/skills/hub/search?q=${encodeURIComponent(q)}&source=${encodeURIComponent(source)}&limit=${limit}`,
init,
),
getSkillHubSources: () =>
fetchJSON<SkillHubSourcesResponse>("/api/skills/hub/sources"),
Expand Down Expand Up @@ -1057,6 +1058,8 @@ export interface SkillHubSource {
rate_limited?: boolean;
/** hermes-index only: whether the centralized index loaded. */
available?: boolean;
/** Best-effort source status detail when probing this source failed. */
error?: string;
}

export interface SkillHubSourcesResponse {
Expand Down
Loading
Loading