mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-05-29 06:31:32 +00:00
feat(api-server): add GET /v1/skills and /v1/toolsets (#33016)
Lets external clients enumerate the agent's skills and resolved toolsets deterministically over the OpenAI-compatible API server, without standing up the dashboard web server or sending a chat message and asking the model to list them. - GET /v1/skills — list installed skills (name, description, category) - GET /v1/toolsets — list toolsets resolved for the api_server platform, with enabled/configured state and the concrete tool names each expands to - Both gated by API_SERVER_KEY (same Bearer scheme as every other /v1/* endpoint) - /v1/capabilities advertises both new endpoints Closes the gap a community user just hit asking how to list skills over REST when only the OpenAI-compatible server is running. Test plan - python -m pytest tests/gateway/test_api_server.py -k "Skills or Toolsets or Capabilities" -o 'addopts=' -q → 9/9 pass - python -m pytest tests/gateway/test_api_server.py -o 'addopts=' -q → 156/156 pass, no regressions - E2E: started a real adapter on an isolated HERMES_HOME with a fake skill installed; curl-equivalent calls to /v1/capabilities, /v1/skills, /v1/toolsets returned the expected JSON; unauthenticated calls returned 401 with the configured API_SERVER_KEY.
This commit is contained in:
parent
febc4cfec0
commit
25f43d38de
2 changed files with 243 additions and 0 deletions
|
|
@ -1101,9 +1101,98 @@ class APIServerAdapter(BasePlatformAdapter):
|
|||
"run_events": {"method": "GET", "path": "/v1/runs/{run_id}/events"},
|
||||
"run_approval": {"method": "POST", "path": "/v1/runs/{run_id}/approval"},
|
||||
"run_stop": {"method": "POST", "path": "/v1/runs/{run_id}/stop"},
|
||||
"skills": {"method": "GET", "path": "/v1/skills"},
|
||||
"toolsets": {"method": "GET", "path": "/v1/toolsets"},
|
||||
},
|
||||
})
|
||||
|
||||
async def _handle_skills(self, request: "web.Request") -> "web.Response":
|
||||
"""GET /v1/skills — list installed skills visible to the API-server agent.
|
||||
|
||||
Read-only listing intended for external clients that need to know
|
||||
which skills are available without sending a chat message and asking
|
||||
the model. Mirrors what the gateway/CLI surfaces through
|
||||
``/skills list``, but as a deterministic JSON payload.
|
||||
|
||||
Returns the same skill metadata (name, description, category) the
|
||||
skills hub uses internally. Disabled skills are excluded so the
|
||||
listing matches what the agent actually loads.
|
||||
"""
|
||||
auth_err = self._check_auth(request)
|
||||
if auth_err:
|
||||
return auth_err
|
||||
|
||||
try:
|
||||
from tools.skills_tool import _find_all_skills, _sort_skills
|
||||
skills = _sort_skills(_find_all_skills(skip_disabled=False))
|
||||
except Exception:
|
||||
logger.exception("GET /v1/skills failed")
|
||||
return web.json_response(
|
||||
_openai_error("Failed to enumerate skills", err_type="server_error"),
|
||||
status=500,
|
||||
)
|
||||
|
||||
return web.json_response({
|
||||
"object": "list",
|
||||
"data": skills,
|
||||
})
|
||||
|
||||
async def _handle_toolsets(self, request: "web.Request") -> "web.Response":
|
||||
"""GET /v1/toolsets — list toolsets and their resolved tools.
|
||||
|
||||
Returns the toolset surface the api_server platform actually exposes
|
||||
to its agent: each toolset's enabled/configured state plus the
|
||||
concrete tool names it expands to. This is the deterministic
|
||||
equivalent of what a client would otherwise have to recover by
|
||||
asking the model what tools it can call.
|
||||
"""
|
||||
auth_err = self._check_auth(request)
|
||||
if auth_err:
|
||||
return auth_err
|
||||
|
||||
try:
|
||||
from hermes_cli.config import load_config
|
||||
from hermes_cli.tools_config import (
|
||||
_get_effective_configurable_toolsets,
|
||||
_get_platform_tools,
|
||||
_toolset_has_keys,
|
||||
)
|
||||
from toolsets import resolve_toolset
|
||||
|
||||
config = load_config()
|
||||
enabled_toolsets = _get_platform_tools(
|
||||
config,
|
||||
"api_server",
|
||||
include_default_mcp_servers=False,
|
||||
)
|
||||
data: List[Dict[str, Any]] = []
|
||||
for name, label, desc in _get_effective_configurable_toolsets():
|
||||
try:
|
||||
tools = sorted(set(resolve_toolset(name)))
|
||||
except Exception:
|
||||
tools = []
|
||||
is_enabled = name in enabled_toolsets
|
||||
data.append({
|
||||
"name": name,
|
||||
"label": label,
|
||||
"description": desc,
|
||||
"enabled": is_enabled,
|
||||
"configured": _toolset_has_keys(name, config),
|
||||
"tools": tools,
|
||||
})
|
||||
except Exception:
|
||||
logger.exception("GET /v1/toolsets failed")
|
||||
return web.json_response(
|
||||
_openai_error("Failed to enumerate toolsets", err_type="server_error"),
|
||||
status=500,
|
||||
)
|
||||
|
||||
return web.json_response({
|
||||
"object": "list",
|
||||
"platform": "api_server",
|
||||
"data": data,
|
||||
})
|
||||
|
||||
async def _handle_chat_completions(self, request: "web.Request") -> "web.Response":
|
||||
"""POST /v1/chat/completions — OpenAI Chat Completions format."""
|
||||
auth_err = self._check_auth(request)
|
||||
|
|
@ -3492,6 +3581,8 @@ class APIServerAdapter(BasePlatformAdapter):
|
|||
self._app.router.add_get("/v1/health", self._handle_health)
|
||||
self._app.router.add_get("/v1/models", self._handle_models)
|
||||
self._app.router.add_get("/v1/capabilities", self._handle_capabilities)
|
||||
self._app.router.add_get("/v1/skills", self._handle_skills)
|
||||
self._app.router.add_get("/v1/toolsets", self._handle_toolsets)
|
||||
self._app.router.add_post("/v1/chat/completions", self._handle_chat_completions)
|
||||
self._app.router.add_post("/v1/responses", self._handle_responses)
|
||||
self._app.router.add_get("/v1/responses/{response_id}", self._handle_get_response)
|
||||
|
|
|
|||
|
|
@ -413,6 +413,8 @@ def _create_app(adapter: APIServerAdapter) -> web.Application:
|
|||
app.router.add_get("/v1/health", adapter._handle_health)
|
||||
app.router.add_get("/v1/models", adapter._handle_models)
|
||||
app.router.add_get("/v1/capabilities", adapter._handle_capabilities)
|
||||
app.router.add_get("/v1/skills", adapter._handle_skills)
|
||||
app.router.add_get("/v1/toolsets", adapter._handle_toolsets)
|
||||
app.router.add_post("/v1/chat/completions", adapter._handle_chat_completions)
|
||||
app.router.add_post("/v1/responses", adapter._handle_responses)
|
||||
app.router.add_get("/v1/responses/{response_id}", adapter._handle_get_response)
|
||||
|
|
@ -657,6 +659,8 @@ class TestCapabilitiesEndpoint:
|
|||
assert data["features"]["run_events_sse"] is True
|
||||
assert data["features"]["session_continuity_header"] == "X-Hermes-Session-Id"
|
||||
assert data["endpoints"]["run_status"]["path"] == "/v1/runs/{run_id}"
|
||||
assert data["endpoints"]["skills"] == {"method": "GET", "path": "/v1/skills"}
|
||||
assert data["endpoints"]["toolsets"] == {"method": "GET", "path": "/v1/toolsets"}
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_capabilities_requires_auth_when_key_configured(self, auth_adapter):
|
||||
|
|
@ -674,6 +678,154 @@ class TestCapabilitiesEndpoint:
|
|||
assert data["auth"]["required"] is True
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# /v1/skills and /v1/toolsets endpoints
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestSkillsEndpoint:
|
||||
@pytest.mark.asyncio
|
||||
async def test_skills_returns_list_envelope(self, adapter):
|
||||
fake_skills = [
|
||||
{"name": "github", "description": "GitHub workflow skill", "category": "github"},
|
||||
{"name": "ascii-art", "description": "ASCII art generation", "category": "creative"},
|
||||
]
|
||||
with patch(
|
||||
"tools.skills_tool._find_all_skills",
|
||||
return_value=list(fake_skills),
|
||||
):
|
||||
app = _create_app(adapter)
|
||||
async with TestClient(TestServer(app)) as cli:
|
||||
resp = await cli.get("/v1/skills")
|
||||
assert resp.status == 200
|
||||
data = await resp.json()
|
||||
assert data["object"] == "list"
|
||||
names = sorted(s["name"] for s in data["data"])
|
||||
assert names == ["ascii-art", "github"]
|
||||
for entry in data["data"]:
|
||||
assert set(entry.keys()) >= {"name", "description", "category"}
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_skills_handles_enumeration_failure(self, adapter):
|
||||
with patch(
|
||||
"tools.skills_tool._find_all_skills",
|
||||
side_effect=RuntimeError("boom"),
|
||||
):
|
||||
app = _create_app(adapter)
|
||||
async with TestClient(TestServer(app)) as cli:
|
||||
resp = await cli.get("/v1/skills")
|
||||
assert resp.status == 500
|
||||
data = await resp.json()
|
||||
assert "error" in data
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_skills_requires_auth_when_key_configured(self, auth_adapter):
|
||||
with patch("tools.skills_tool._find_all_skills", return_value=[]):
|
||||
app = _create_app(auth_adapter)
|
||||
async with TestClient(TestServer(app)) as cli:
|
||||
resp = await cli.get("/v1/skills")
|
||||
assert resp.status == 401
|
||||
|
||||
authed = await cli.get(
|
||||
"/v1/skills",
|
||||
headers={"Authorization": "Bearer sk-secret"},
|
||||
)
|
||||
assert authed.status == 200
|
||||
|
||||
|
||||
class TestToolsetsEndpoint:
|
||||
@pytest.mark.asyncio
|
||||
async def test_toolsets_returns_resolved_tools(self, adapter):
|
||||
fake_toolsets = [
|
||||
("default", "Default Tools", "Core tools"),
|
||||
("web", "Web Tools", "Search and extract"),
|
||||
]
|
||||
with patch(
|
||||
"hermes_cli.tools_config._get_effective_configurable_toolsets",
|
||||
return_value=fake_toolsets,
|
||||
), patch(
|
||||
"hermes_cli.tools_config._get_platform_tools",
|
||||
return_value={"default"},
|
||||
), patch(
|
||||
"hermes_cli.tools_config._toolset_has_keys",
|
||||
return_value=True,
|
||||
), patch(
|
||||
"toolsets.resolve_toolset",
|
||||
side_effect=lambda name: {
|
||||
"default": ["terminal", "read_file"],
|
||||
"web": ["web_search"],
|
||||
}[name],
|
||||
):
|
||||
app = _create_app(adapter)
|
||||
async with TestClient(TestServer(app)) as cli:
|
||||
resp = await cli.get("/v1/toolsets")
|
||||
assert resp.status == 200
|
||||
data = await resp.json()
|
||||
assert data["object"] == "list"
|
||||
assert data["platform"] == "api_server"
|
||||
by_name = {ts["name"]: ts for ts in data["data"]}
|
||||
assert by_name["default"]["enabled"] is True
|
||||
assert by_name["default"]["tools"] == ["read_file", "terminal"]
|
||||
assert by_name["web"]["enabled"] is False
|
||||
assert by_name["web"]["tools"] == ["web_search"]
|
||||
assert by_name["default"]["configured"] is True
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_toolsets_handles_resolution_failure_per_toolset(self, adapter):
|
||||
"""If one toolset fails to resolve, others still appear with empty tools."""
|
||||
fake_toolsets = [
|
||||
("broken", "Broken", "fails"),
|
||||
("ok", "OK", "works"),
|
||||
]
|
||||
|
||||
def _resolve(name):
|
||||
if name == "broken":
|
||||
raise RuntimeError("nope")
|
||||
return ["some_tool"]
|
||||
|
||||
with patch(
|
||||
"hermes_cli.tools_config._get_effective_configurable_toolsets",
|
||||
return_value=fake_toolsets,
|
||||
), patch(
|
||||
"hermes_cli.tools_config._get_platform_tools",
|
||||
return_value=set(),
|
||||
), patch(
|
||||
"hermes_cli.tools_config._toolset_has_keys",
|
||||
return_value=False,
|
||||
), patch(
|
||||
"toolsets.resolve_toolset",
|
||||
side_effect=_resolve,
|
||||
):
|
||||
app = _create_app(adapter)
|
||||
async with TestClient(TestServer(app)) as cli:
|
||||
resp = await cli.get("/v1/toolsets")
|
||||
assert resp.status == 200
|
||||
data = await resp.json()
|
||||
by_name = {ts["name"]: ts for ts in data["data"]}
|
||||
assert by_name["broken"]["tools"] == []
|
||||
assert by_name["ok"]["tools"] == ["some_tool"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_toolsets_requires_auth_when_key_configured(self, auth_adapter):
|
||||
with patch(
|
||||
"hermes_cli.tools_config._get_effective_configurable_toolsets",
|
||||
return_value=[],
|
||||
), patch(
|
||||
"hermes_cli.tools_config._get_platform_tools",
|
||||
return_value=set(),
|
||||
):
|
||||
app = _create_app(auth_adapter)
|
||||
async with TestClient(TestServer(app)) as cli:
|
||||
resp = await cli.get("/v1/toolsets")
|
||||
assert resp.status == 401
|
||||
|
||||
authed = await cli.get(
|
||||
"/v1/toolsets",
|
||||
headers={"Authorization": "Bearer sk-secret"},
|
||||
)
|
||||
assert authed.status == 200
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# /v1/chat/completions endpoint
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue