mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-04-25 00:51:20 +00:00
refactor(spotify): convert to built-in bundled plugin under plugins/spotify (#15174)
Moves the Spotify integration from tools/ into plugins/spotify/,
matching the existing pattern established by plugins/image_gen/ for
third-party service integrations.
Why:
- tools/ should be reserved for foundational capabilities (terminal,
read_file, web_search, etc.). tools/providers/ was a one-off
directory created solely for spotify_client.py.
- plugins/ is already the home for image_gen backends, memory
providers, context engines, and standalone hook-based plugins.
Spotify is a third-party service integration and belongs alongside
those, not in tools/.
- Future service integrations (eventually: Deezer, Apple Music, etc.)
now have a pattern to copy.
Changes:
- tools/spotify_tool.py → plugins/spotify/tools.py (handlers + schemas)
- tools/providers/spotify_client.py → plugins/spotify/client.py
- tools/providers/ removed (was only used for Spotify)
- New plugins/spotify/__init__.py with register(ctx) calling
ctx.register_tool() × 7. The handler/check_fn wiring is unchanged.
- New plugins/spotify/plugin.yaml (kind: backend, bundled, auto-load).
- tests/tools/test_spotify_client.py: import paths updated.
tools_config fix — _DEFAULT_OFF_TOOLSETS now wins over plugin auto-enable:
- _get_platform_tools() previously auto-enabled unknown plugin
toolsets for new platforms. That was fine for image_gen (which has
no toolset of its own) but bad for Spotify, which explicitly
requires opt-in (don't ship 7 tool schemas to users who don't use
it). Added a check: if a plugin toolset is in _DEFAULT_OFF_TOOLSETS,
it stays off until the user picks it in 'hermes tools'.
Pre-existing test bug fix:
- tests/hermes_cli/test_plugins.py::test_list_returns_sorted
asserted names were sorted, but list_plugins() sorts by key
(path-derived, e.g. image_gen/openai). With only image_gen plugins
bundled, name and key order happened to agree. Adding plugins/spotify
broke that coincidence (spotify sorts between openai-codex and xai
by name but after xai by key). Updated test to assert key order,
which is what the code actually documents.
Validation:
- scripts/run_tests.sh tests/hermes_cli/test_plugins.py \
tests/hermes_cli/test_tools_config.py \
tests/hermes_cli/test_spotify_auth.py \
tests/tools/test_spotify_client.py \
tests/tools/test_registry.py
→ 143 passed
- E2E plugin load: 'spotify' appears in loaded plugins, all 7 tools
register into the spotify toolset, check_fn gating intact.
This commit is contained in:
parent
e5d41f05d4
commit
8d12fb1e6b
8 changed files with 96 additions and 19 deletions
|
|
@ -607,7 +607,10 @@ def _get_platform_tools(
|
||||||
default_off.remove(platform)
|
default_off.remove(platform)
|
||||||
enabled_toolsets -= default_off
|
enabled_toolsets -= default_off
|
||||||
|
|
||||||
# Plugin toolsets: enabled by default unless explicitly disabled.
|
# Plugin toolsets: enabled by default unless explicitly disabled, or
|
||||||
|
# unless the toolset is in _DEFAULT_OFF_TOOLSETS (e.g. spotify —
|
||||||
|
# shipped as a bundled plugin but user must opt in via `hermes tools`
|
||||||
|
# so we don't ship 7 Spotify tool schemas to users who don't use it).
|
||||||
# A plugin toolset is "known" for a platform once `hermes tools`
|
# A plugin toolset is "known" for a platform once `hermes tools`
|
||||||
# has been saved for that platform (tracked via known_plugin_toolsets).
|
# has been saved for that platform (tracked via known_plugin_toolsets).
|
||||||
# Unknown plugins default to enabled; known-but-absent = disabled.
|
# Unknown plugins default to enabled; known-but-absent = disabled.
|
||||||
|
|
@ -619,6 +622,9 @@ def _get_platform_tools(
|
||||||
if pts in toolset_names:
|
if pts in toolset_names:
|
||||||
# Explicitly listed in config — enabled
|
# Explicitly listed in config — enabled
|
||||||
enabled_toolsets.add(pts)
|
enabled_toolsets.add(pts)
|
||||||
|
elif pts in _DEFAULT_OFF_TOOLSETS:
|
||||||
|
# Opt-in plugin toolset — stay off until user picks it
|
||||||
|
continue
|
||||||
elif pts not in known_for_platform:
|
elif pts not in known_for_platform:
|
||||||
# New plugin not yet seen by hermes tools — default enabled
|
# New plugin not yet seen by hermes tools — default enabled
|
||||||
enabled_toolsets.add(pts)
|
enabled_toolsets.add(pts)
|
||||||
|
|
|
||||||
66
plugins/spotify/__init__.py
Normal file
66
plugins/spotify/__init__.py
Normal file
|
|
@ -0,0 +1,66 @@
|
||||||
|
"""Spotify integration plugin — bundled, auto-loaded.
|
||||||
|
|
||||||
|
Registers 7 tools (playback, devices, queue, search, playlists, albums,
|
||||||
|
library) into the ``spotify`` toolset. Each tool's handler is gated by
|
||||||
|
``_check_spotify_available()`` — when the user has not run ``hermes auth
|
||||||
|
spotify``, the tools remain registered (so they appear in ``hermes
|
||||||
|
tools``) but the runtime check prevents dispatch.
|
||||||
|
|
||||||
|
Why a plugin instead of a top-level ``tools/`` file?
|
||||||
|
|
||||||
|
- ``plugins/`` is where third-party service integrations live (see
|
||||||
|
``plugins/image_gen/`` for the backend-provider pattern, ``plugins/
|
||||||
|
disk-cleanup/`` for the standalone pattern). ``tools/`` is reserved
|
||||||
|
for foundational capabilities (terminal, read_file, web_search, etc.).
|
||||||
|
- Mirroring the image_gen plugin layout (``plugins/<category>/<backend>/``
|
||||||
|
for categories, flat ``plugins/<name>/`` for standalones) makes new
|
||||||
|
service integrations a pattern contributors can copy.
|
||||||
|
- Bundled + ``kind: backend`` auto-loads on startup just like image_gen
|
||||||
|
backends — no user opt-in needed, no ``plugins.enabled`` config.
|
||||||
|
|
||||||
|
The Spotify auth flow (``hermes auth spotify``), CLI plumbing, and docs
|
||||||
|
are unchanged. This move is purely structural.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from plugins.spotify.tools import (
|
||||||
|
SPOTIFY_ALBUMS_SCHEMA,
|
||||||
|
SPOTIFY_DEVICES_SCHEMA,
|
||||||
|
SPOTIFY_LIBRARY_SCHEMA,
|
||||||
|
SPOTIFY_PLAYBACK_SCHEMA,
|
||||||
|
SPOTIFY_PLAYLISTS_SCHEMA,
|
||||||
|
SPOTIFY_QUEUE_SCHEMA,
|
||||||
|
SPOTIFY_SEARCH_SCHEMA,
|
||||||
|
_check_spotify_available,
|
||||||
|
_handle_spotify_albums,
|
||||||
|
_handle_spotify_devices,
|
||||||
|
_handle_spotify_library,
|
||||||
|
_handle_spotify_playback,
|
||||||
|
_handle_spotify_playlists,
|
||||||
|
_handle_spotify_queue,
|
||||||
|
_handle_spotify_search,
|
||||||
|
)
|
||||||
|
|
||||||
|
_TOOLS = (
|
||||||
|
("spotify_playback", SPOTIFY_PLAYBACK_SCHEMA, _handle_spotify_playback, "🎵"),
|
||||||
|
("spotify_devices", SPOTIFY_DEVICES_SCHEMA, _handle_spotify_devices, "🔈"),
|
||||||
|
("spotify_queue", SPOTIFY_QUEUE_SCHEMA, _handle_spotify_queue, "📻"),
|
||||||
|
("spotify_search", SPOTIFY_SEARCH_SCHEMA, _handle_spotify_search, "🔎"),
|
||||||
|
("spotify_playlists", SPOTIFY_PLAYLISTS_SCHEMA, _handle_spotify_playlists, "📚"),
|
||||||
|
("spotify_albums", SPOTIFY_ALBUMS_SCHEMA, _handle_spotify_albums, "💿"),
|
||||||
|
("spotify_library", SPOTIFY_LIBRARY_SCHEMA, _handle_spotify_library, "❤️"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def register(ctx) -> None:
|
||||||
|
"""Register all Spotify tools. Called once by the plugin loader."""
|
||||||
|
for name, schema, handler, emoji in _TOOLS:
|
||||||
|
ctx.register_tool(
|
||||||
|
name=name,
|
||||||
|
toolset="spotify",
|
||||||
|
schema=schema,
|
||||||
|
handler=handler,
|
||||||
|
check_fn=_check_spotify_available,
|
||||||
|
emoji=emoji,
|
||||||
|
)
|
||||||
13
plugins/spotify/plugin.yaml
Normal file
13
plugins/spotify/plugin.yaml
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
name: spotify
|
||||||
|
version: 1.0.0
|
||||||
|
description: "Native Spotify integration — 7 tools (playback, devices, queue, search, playlists, albums, library) using Spotify Web API + PKCE OAuth. Auth via `hermes auth spotify`. Tools gate on `providers.spotify` in ~/.hermes/auth.json."
|
||||||
|
author: NousResearch
|
||||||
|
kind: backend
|
||||||
|
provides_tools:
|
||||||
|
- spotify_playback
|
||||||
|
- spotify_devices
|
||||||
|
- spotify_queue
|
||||||
|
- spotify_search
|
||||||
|
- spotify_playlists
|
||||||
|
- spotify_albums
|
||||||
|
- spotify_library
|
||||||
|
|
@ -1,11 +1,11 @@
|
||||||
"""Native Spotify tools for Hermes."""
|
"""Native Spotify tools for Hermes (registered via plugins/spotify)."""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from typing import Any, Dict, List
|
from typing import Any, Dict, List
|
||||||
|
|
||||||
from hermes_cli.auth import get_auth_status
|
from hermes_cli.auth import get_auth_status
|
||||||
from tools.providers.spotify_client import (
|
from plugins.spotify.client import (
|
||||||
SpotifyAPIError,
|
SpotifyAPIError,
|
||||||
SpotifyAuthRequiredError,
|
SpotifyAuthRequiredError,
|
||||||
SpotifyClient,
|
SpotifyClient,
|
||||||
|
|
@ -14,7 +14,7 @@ from tools.providers.spotify_client import (
|
||||||
normalize_spotify_uri,
|
normalize_spotify_uri,
|
||||||
normalize_spotify_uris,
|
normalize_spotify_uris,
|
||||||
)
|
)
|
||||||
from tools.registry import registry, tool_error, tool_result
|
from tools.registry import tool_error, tool_result
|
||||||
|
|
||||||
|
|
||||||
def _check_spotify_available() -> bool:
|
def _check_spotify_available() -> bool:
|
||||||
|
|
@ -452,12 +452,3 @@ SPOTIFY_LIBRARY_SCHEMA = {
|
||||||
"required": ["kind", "action"],
|
"required": ["kind", "action"],
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
registry.register(name="spotify_playback", toolset="spotify", schema=SPOTIFY_PLAYBACK_SCHEMA, handler=_handle_spotify_playback, check_fn=_check_spotify_available, emoji="🎵")
|
|
||||||
registry.register(name="spotify_devices", toolset="spotify", schema=SPOTIFY_DEVICES_SCHEMA, handler=_handle_spotify_devices, check_fn=_check_spotify_available, emoji="🔈")
|
|
||||||
registry.register(name="spotify_queue", toolset="spotify", schema=SPOTIFY_QUEUE_SCHEMA, handler=_handle_spotify_queue, check_fn=_check_spotify_available, emoji="📻")
|
|
||||||
registry.register(name="spotify_search", toolset="spotify", schema=SPOTIFY_SEARCH_SCHEMA, handler=_handle_spotify_search, check_fn=_check_spotify_available, emoji="🔎")
|
|
||||||
registry.register(name="spotify_playlists", toolset="spotify", schema=SPOTIFY_PLAYLISTS_SCHEMA, handler=_handle_spotify_playlists, check_fn=_check_spotify_available, emoji="📚")
|
|
||||||
registry.register(name="spotify_albums", toolset="spotify", schema=SPOTIFY_ALBUMS_SCHEMA, handler=_handle_spotify_albums, check_fn=_check_spotify_available, emoji="💿")
|
|
||||||
registry.register(name="spotify_library", toolset="spotify", schema=SPOTIFY_LIBRARY_SCHEMA, handler=_handle_spotify_library, check_fn=_check_spotify_available, emoji="❤️")
|
|
||||||
|
|
@ -635,7 +635,7 @@ class TestPluginManagerList:
|
||||||
assert mgr.list_plugins() == []
|
assert mgr.list_plugins() == []
|
||||||
|
|
||||||
def test_list_returns_sorted(self, tmp_path, monkeypatch):
|
def test_list_returns_sorted(self, tmp_path, monkeypatch):
|
||||||
"""list_plugins() returns results sorted by name."""
|
"""list_plugins() returns results sorted by key."""
|
||||||
plugins_dir = tmp_path / "hermes_test" / "plugins"
|
plugins_dir = tmp_path / "hermes_test" / "plugins"
|
||||||
_make_plugin_dir(plugins_dir, "zulu")
|
_make_plugin_dir(plugins_dir, "zulu")
|
||||||
_make_plugin_dir(plugins_dir, "alpha")
|
_make_plugin_dir(plugins_dir, "alpha")
|
||||||
|
|
@ -645,8 +645,10 @@ class TestPluginManagerList:
|
||||||
mgr.discover_and_load()
|
mgr.discover_and_load()
|
||||||
|
|
||||||
listing = mgr.list_plugins()
|
listing = mgr.list_plugins()
|
||||||
names = [p["name"] for p in listing]
|
# list_plugins sorts by key (path-derived, e.g. ``image_gen/openai``),
|
||||||
assert names == sorted(names)
|
# not by display name, so that category plugins group together.
|
||||||
|
keys = [p["key"] for p in listing]
|
||||||
|
assert keys == sorted(keys)
|
||||||
|
|
||||||
def test_list_with_plugins(self, tmp_path, monkeypatch):
|
def test_list_with_plugins(self, tmp_path, monkeypatch):
|
||||||
"""list_plugins() returns info dicts for each discovered plugin."""
|
"""list_plugins() returns info dicts for each discovered plugin."""
|
||||||
|
|
|
||||||
|
|
@ -4,8 +4,8 @@ import json
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from tools.providers import spotify_client as spotify_mod
|
from plugins.spotify import client as spotify_mod
|
||||||
from tools import spotify_tool
|
from plugins.spotify import tools as spotify_tool
|
||||||
|
|
||||||
|
|
||||||
class _FakeResponse:
|
class _FakeResponse:
|
||||||
|
|
|
||||||
|
|
@ -1 +0,0 @@
|
||||||
"""Provider-specific native tool clients."""
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue