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
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
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
Loading