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
1 change: 1 addition & 0 deletions contributors/emails/eman1369a@gmail.com
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
thelonewander3r
18 changes: 18 additions & 0 deletions gateway/platform_registry.py
Original file line number Diff line number Diff line change
Expand Up @@ -580,6 +580,24 @@ def plugin_entries(self) -> list[PlatformEntry]:
self._resolve_all()
return [e for e in self.all_entries() if e.source == "plugin"]

def registered_names(self) -> set[str]:
"""Return concrete and deferred platform names without loading adapters.

Mirrors ``is_registered()``'s scope semantics: names registered under
the current profile scope AND process-global names both count. Plugin
platforms register deferred loaders under a profile scope, so reading
only the global maps would miss every plugin platform.
"""
with self._lock:
scope = self.current_scope_key()
entries, deferred = self._scope_maps(scope)
return (
entries.keys()
| deferred.keys()
| self._entries.keys()
| self._deferred.keys()
)

def is_registered(self, name: str) -> bool:
# A deferred (not-yet-imported) platform still counts as registered --
# the loader will materialize it on first real use. This keeps cheap
Expand Down
165 changes: 163 additions & 2 deletions hermes_cli/plugins.py
Original file line number Diff line number Diff line change
Expand Up @@ -3454,6 +3454,14 @@ def __init__(self, scope_key: Optional[str] = None) -> None:
# symmetric force-reload lands.
self._ownership_ledger: Dict[str, List[PluginRegistration]] = {}
self._registration_order: List[PluginRegistration] = []
# Deferred platform plugins whose client tools were registered at
# discovery time (see _register_deferred_platform_tools). Keyed by
# plugin id: the already-imported package module, so materializing the
# adapter later doesn't re-execute it, and the tool names it
# contributed, so `hermes plugins list` still attributes them once the
# full plugin loads.
self._predeclared_modules: Dict[str, types.ModuleType] = {}
self._predeclared_tools: Dict[str, List[str]] = {}

# -----------------------------------------------------------------------
# Registration ledger internals
Expand Down Expand Up @@ -3717,6 +3725,8 @@ def _unload_scoped(
self._system_prompt_sections.clear()
self._approval_transports.clear()
self._slack_action_handlers.clear()
self._predeclared_modules.clear()
self._predeclared_tools.clear()
self._context_engine = None
self._discovered = False
else:
Expand Down Expand Up @@ -4507,6 +4517,131 @@ def _loader(_manifest: PluginManifest = manifest) -> None:
exc_info=True,
)
self._load_plugin(manifest)
return

self._register_deferred_platform_tools(manifest, loaded)

def _register_deferred_platform_tools(
self, manifest: PluginManifest, loaded: LoadedPlugin
) -> None:
"""Register a deferred platform's *client* tools without its adapter.

A platform plugin can ship two independent things: an inbound adapter
(heavy — it imports the platform SDK) and outbound client tools the
agent calls like any other tool. Deferring the plugin defers both, so
in a CLI/TUI process the client tools never register at all:
``resolve_toolset()`` returns ``[]``, the toolset is missing from the
``hermes tools`` checklist, and even an explicit ``platform_toolsets``
entry is dropped because the key is unknown. The same tools work in
gateway/web processes only because those materialize every platform at
startup (issue #78050).

Client tools that live in a dedicated ``tools`` submodule can be
registered at discovery time instead: importing ``<plugin>/tools.py``
does not import the adapter, so the SDK stays unloaded and startup
stays cheap. A plugin taking this path must therefore keep its package
``__init__`` import-light and pull the adapter in from inside
``register()`` (as ``plugins/platforms/a2a`` does).

Opting in is explicit: the manifest must declare ``provides_tools``
(the field the plugin list and web server already read to name a
plugin's tools, per #78538). Keying off the mere presence of a
``tools.py`` would opt a plugin in by accident — a platform is free to
put internal helpers there — and would leave the contract invisible to
anyone reading the manifest. ``tools.py`` remains where the code is
imported from; ``provides_tools`` is what asks for it. A platform that
does not declare the field is untouched and stays fully deferred.
"""
if not manifest.provides_tools:
return

lookup_key = manifest.key or manifest.name
plugin_dir = Path(manifest.path) if manifest.path else None
if plugin_dir is None or not (plugin_dir / "tools.py").is_file():
# Declared but undeliverable. Staying quiet here reproduces the
# exact symptom this path exists to fix — tools the manifest
# promises, silently absent from the session (#78050) — so say so.
logger.warning(
"Plugin '%s' declares provides_tools %s but has no tools.py; "
"those tools will not be available in CLI/TUI sessions.",
lookup_key,
list(manifest.provides_tools),
)
return

# Snapshotted outside the try so the failure path can tell which tools
# a partially-successful register_tools() left behind.
before = set(self._plugin_tool_names)
try:
module = self._load_directory_module(manifest)
# Record the module even if nothing below registers: the package
# body has already run, so materializing the adapter later must
# reuse it rather than execute it a second time.
loaded.module = module
self._predeclared_modules[lookup_key] = module

tools_module = importlib.import_module(f"{module.__name__}.tools")
register_tools = getattr(tools_module, "register_tools", None)
if register_tools is None:
logger.warning(
"Plugin '%s' declares provides_tools %s but its tools.py "
"has no register_tools(ctx); those tools will not be "
"available in CLI/TUI sessions.",
lookup_key,
list(manifest.provides_tools),
)
return

register_tools(PluginContext(manifest, self))
registered = [
t for t in self._plugin_tool_names if t not in before
]

loaded.tools_registered = registered
self._predeclared_tools[lookup_key] = registered
logger.debug(
"Deferred platform '%s': pre-registered %d client tool(s) %s",
lookup_key,
len(registered),
registered,
)
except Exception as exc:
# A register_tools() that registered some tools and THEN raised
# leaves those tools live in the registry. Credit them, or
# `hermes plugins list` under-reports what the process is actually
# carrying — and _load_plugin's own diff would miss them later
# too, since they are already in its "before" snapshot.
partial = [t for t in self._plugin_tool_names if t not in before]
if partial:
loaded.tools_registered = partial
self._predeclared_tools[lookup_key] = partial

# Never let a client-tool import break discovery — the platform
# stays deferred and behaves exactly as it did before. But a
# broken tools.py produces the #78050 symptom itself (declared
# tools missing from the session), so this has to be visible
# without turning on debug logging to find it.
#
# Where it failed is the first thing an operator needs: nothing
# registered points at the import or the module body, a partial
# run points at one tool's definition, and a full run that still
# raised points past the registrations entirely.
declared = len(manifest.provides_tools)
if not partial:
scope = f"before registering any of its {declared} declared tool(s)"
elif len(partial) >= declared:
scope = f"after registering all {declared} declared tool(s)"
else:
scope = f"after registering {len(partial)} of {declared} declared tool(s)"
logger.warning(
"Plugin '%s': client-tool pre-registration failed %s (%s).%s",
lookup_key,
scope,
exc,
"" if len(partial) >= declared else
" The remainder will be missing from CLI/TUI sessions.",
exc_info=_PLUGINS_DEBUG,
)

def _warn_python_dependencies(self, manifest: PluginManifest) -> None:
"""Surface declared pip dependencies (#64165).
Expand Down Expand Up @@ -4635,7 +4770,13 @@ def _load_plugin_scoped(self, manifest: PluginManifest) -> None:
policy_lease.dispose,
)
try:
if manifest.source in {"user", "project", "bundled"}:
# A deferred platform whose client tools were already registered at
# discovery time has its package imported too — reuse it so the
# module body doesn't execute twice (#78050).
preloaded = self._predeclared_modules.pop(plugin_key, None)
if preloaded is not None:
module = preloaded
elif manifest.source in {"user", "project", "bundled"}:
module = self._load_directory_module(
manifest, module_name=_module_name
)
Expand All @@ -4657,10 +4798,20 @@ def _load_plugin_scoped(self, manifest: PluginManifest) -> None:
for registration in self._registration_order[registration_start:]
if registration.plugin_key == plugin_key and registration.active
]
loaded.tools_registered = [
# Tools this plugin already contributed at discovery time were
# registered before ``registration_start``, so the ledger slice
# above cannot see them and `hermes plugins list` would
# under-report once the deferred adapter materializes (#78050).
# Credit them back to the plugin that actually registered them.
_predeclared = [
t for t in self._predeclared_tools.pop(plugin_key, [])
if t in self._plugin_tool_names
]
loaded.tools_registered = _predeclared + [
registration.key
for registration in registrations
if registration.kind == "tool"
and registration.key not in _predeclared
]
loaded.hooks_registered = [
registration.key
Expand Down Expand Up @@ -4713,6 +4864,16 @@ def _load_plugin_scoped(self, manifest: PluginManifest) -> None:
"Failed to load plugin '%s': %s",
manifest.name, exc, exc_info=_PLUGINS_DEBUG,
)
# A materialization that did NOT succeed has already had its
# discovery-time pre-registrations disposed: the failure path above
# sweeps the whole ownership ledger for this plugin key, not just the
# ``registration_start:`` slice, so nothing this plugin registered
# survives it. There is no live tool left to credit — attribution and
# the registry agree at zero. Only the success path pops
# _predeclared_tools, so drop the entry here rather than let the
# bookkeeping outlive the load attempt (#78050).
if not loaded.enabled:
self._predeclared_tools.pop(plugin_key, None)
self._plugins[manifest.key or manifest.name] = loaded

def _load_portable_plugin(
Expand Down
34 changes: 30 additions & 4 deletions hermes_cli/tools_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -2316,18 +2316,22 @@ def _get_platform_tools(
configurable_keys = {ts_key for ts_key, _, _ in CONFIGURABLE_TOOLSETS}
plugin_ts_keys = _get_plugin_toolset_keys()
platform_default_keys = {p["default_toolset"] for p in PLATFORMS.values()}
# Plugin-provided toolsets are first-class on a platform-toolsets list —
# explicit config like ``[hermes-cli, a2a]`` must survive filtering just
# like a built-in configurable toolset would. See issue #81163.
explicit_known_keys = configurable_keys | plugin_ts_keys

# If the saved list contains any configurable keys directly, the user
# has explicitly configured this platform — use direct membership.
# This avoids the subset-inference bug where composite toolsets like
# "hermes-cli" (which include all _HERMES_CORE_TOOLS) cause disabled
# toolsets to re-appear as enabled.
has_explicit_config = any(ts in configurable_keys for ts in toolset_names)
has_explicit_config = any(ts in explicit_known_keys for ts in toolset_names)

if has_explicit_config:
enabled_toolsets = {
ts for ts in toolset_names
if ts in configurable_keys and _toolset_allowed_for_platform(ts, platform)
if ts in explicit_known_keys and _toolset_allowed_for_platform(ts, platform)
}
# Mixed config: composite toolset alongside configurables (e.g.
# ``[hermes-cli, spotify]`` after enabling Spotify via ``hermes
Expand Down Expand Up @@ -5511,6 +5515,27 @@ def _print_tools_list(enabled_toolsets: set, mcp_servers: dict, platform: str =
_print_info(f"{srv_name} {color('all tools enabled', Colors.DIM)}")


def _known_tool_platforms() -> set[str]:
"""Return built-in plus discovered plugin platform names.

Plugin platforms are registered at runtime rather than in the static CLI
display registry. Tool introspection/configuration must recognize those
names too, otherwise an active plugin platform cannot audit its authority.
"""
known = set(PLATFORMS)
try:
from hermes_cli.plugins import discover_plugins
from gateway.platform_registry import platform_registry

discover_plugins() # idempotent
known.update(platform_registry.registered_names())
except Exception:
# Plugin discovery is optional. Preserve the built-in CLI path when a
# third-party plugin is malformed or its dependencies are unavailable.
pass
return known


def tools_disable_enable_command(args):
"""Enable, disable, or list tools for a platform.

Expand All @@ -5521,8 +5546,9 @@ def tools_disable_enable_command(args):
platform = getattr(args, "platform", "cli")
config = load_config()

if platform not in PLATFORMS:
_print_error(f"Unknown platform '{platform}'. Valid: {', '.join(PLATFORMS)}")
valid_platforms = _known_tool_platforms()
if platform not in valid_platforms:
_print_error(f"Unknown platform '{platform}'. Valid: {', '.join(sorted(valid_platforms))}")
return

if action == "list":
Expand Down
10 changes: 10 additions & 0 deletions plugins/platforms/a2a/plugin.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,16 @@ description: >

Pure stdlib transport (http.server + urllib) — no a2a-sdk dependency required.
author: Nous Research
# The outbound client tools. Declaring them here is what asks discovery to
# import `tools.py` in CLI/TUI processes, where the plugin is otherwise
# deferred and the tools would never register at all (#78050). The inbound
# adapter stays deferred either way — only this submodule is imported.
provides_tools:
- a2a_discover
- a2a_call
- a2a_list
- a2a_history
- a2a_orchestrate
# requires_env / optional_env are surfaced in the `hermes config` UI via the
# platform-plugin env var injector in hermes_cli/config.py.
requires_env: []
Expand Down
12 changes: 12 additions & 0 deletions tests/gateway/test_platform_registry.py
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,18 @@ def test_create_adapter_no_validate(self):
reg.register(entry)
assert reg.create_adapter("novalidate", MagicMock()) is mock_adapter

def test_registered_names_includes_deferred_without_materializing(self):
reg = PlatformRegistry()
entry, _ = self._make_entry("concrete")
loader = MagicMock()
reg.register(entry)
reg.register_deferred("deferred", loader)

assert reg.registered_names() == {"concrete", "deferred"}
loader.assert_not_called()
assert reg.get("concrete") is entry
assert reg.is_registered("deferred")


class TestEnsureDepsFn:
"""check_fn (PASSIVE probe) vs ensure_deps_fn (ACTIVE installer) split.
Expand Down
Loading
Loading