Remove the bootstrap handlers for surfaces current hermes-agent serves natively: sessions CRUD/messages/fork (/api/sessions*, upstream PR #33134) and the legacy read-only GET /api/skills list (superseded by /v1/skills + /v1/toolsets, PR #33016). No pre-#33134 fallback remains; older core builds degrade via the client capability probe to /v1/chat/completions or /v1/runs. The bootstrap now injects only genuine compatibility gaps with no native API-server replacement: GET /api/sessions/search, memory CRUD, legacy skill detail (/api/skills/{name}) + the 501 toggle stub, config, available-models, and the slash-command middleware. Registration stays method/path-aware so native routes still win if any remaining surface lands in core. Tests assert the split both ways: retired surfaces are never injected, kept surfaces are, and native routes still win for kept surfaces. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
267 lines
9.9 KiB
Python
267 lines
9.9 KiB
Python
"""Import-hook plumbing + Application monkey-patch.
|
|
|
|
When `aiohttp.web` is imported anywhere in the running interpreter, our
|
|
`_AioHttpWebFinder` intercepts the import, lets the real loader finish,
|
|
then swaps `web.Application` for a `_PatchedApplication` subclass.
|
|
|
|
The subclass overrides `__setitem__` so we can detect the moment hermes-agent's
|
|
`APIServerAdapter` does `self._app["api_server_adapter"] = self` — that's the
|
|
single line in the upstream `connect()` method that gives us a reference to
|
|
the adapter while the app is still being built (router not yet frozen).
|
|
|
|
At that point, we call `_register_routes()` to bind any missing compatibility
|
|
handlers to the same router the gateway is in the middle of populating. Route
|
|
registration is granular: native upstream routes win per method/path, and the
|
|
bootstrap only fills gaps that still do not exist. The injected set is
|
|
compatibility-only — session search, memory, legacy skill detail/toggle,
|
|
config, and available-models. Sessions CRUD/messages/fork and the legacy
|
|
skill list are retired: native upstream owns them (PR #33134 / #33016) and
|
|
the bootstrap no longer carries handlers for them. We also install the
|
|
slash-command middleware that intercepts
|
|
``/v1/chat/completions`` and ``/v1/runs`` to handle gateway commands like
|
|
``/help``, ``/commands``, ``/profile``, and ``/provider`` without forwarding
|
|
them to the LLM. The gateway then continues with its own route registrations
|
|
and starts the server normally.
|
|
|
|
Failure modes are silent-but-logged: if anything in this chain breaks
|
|
(unexpected upstream refactor, aiohttp version incompatibility, missing
|
|
SessionDB methods, etc.), we log a warning and let the gateway start without
|
|
the injected routes. The Android client's capability detection will fall back
|
|
to the standard upstream endpoints automatically.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
import sys
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
# Compatibility paths that `_handlers.register_routes()` can fill when native
|
|
# upstream support is absent. Registration is method-aware in `_handlers.py`;
|
|
# this set is only used for logging/cheap diagnostics in the import hook.
|
|
# Retired surfaces (sessions CRUD/messages/fork, legacy /api/skills list) are
|
|
# deliberately absent — native upstream owns them (PR #33134 / #33016).
|
|
_COMPATIBILITY_PATHS: frozenset[str] = frozenset({
|
|
"/api/sessions/search",
|
|
"/api/memory",
|
|
"/api/skills/{name}",
|
|
"/api/skills/toggle",
|
|
"/api/config",
|
|
"/api/available-models",
|
|
})
|
|
|
|
|
|
class _AioHttpWebFinder:
|
|
"""sys.meta_path finder that wraps the loader for `aiohttp.web`.
|
|
|
|
Removes itself from `sys.meta_path` after firing once, so subsequent
|
|
imports of unrelated modules don't pay the find-spec cost.
|
|
"""
|
|
|
|
def find_spec(self, fullname, path, target=None):
|
|
if fullname != "aiohttp.web":
|
|
return None
|
|
|
|
# Remove ourselves so this only runs on the first import.
|
|
try:
|
|
sys.meta_path.remove(self)
|
|
except ValueError:
|
|
pass
|
|
|
|
# Resolve the real spec via the remaining finders. We can't recurse
|
|
# back into our own find_spec because we just removed ourselves.
|
|
for finder in sys.meta_path:
|
|
if not hasattr(finder, "find_spec"):
|
|
continue
|
|
spec = finder.find_spec(fullname, path, target)
|
|
if spec is None:
|
|
continue
|
|
|
|
original_loader = spec.loader
|
|
spec.loader = _PatchingLoader(original_loader)
|
|
return spec
|
|
|
|
# No finder could resolve aiohttp.web — fall through, normal import
|
|
# error will surface.
|
|
return None
|
|
|
|
|
|
class _PatchingLoader:
|
|
"""Loader wrapper that runs `_apply_patch()` after the module is exec'd."""
|
|
|
|
def __init__(self, wrapped):
|
|
self._wrapped = wrapped
|
|
|
|
def create_module(self, spec):
|
|
if hasattr(self._wrapped, "create_module"):
|
|
return self._wrapped.create_module(spec)
|
|
return None
|
|
|
|
def exec_module(self, module):
|
|
self._wrapped.exec_module(module)
|
|
try:
|
|
_apply_patch(module)
|
|
except Exception as exc:
|
|
logger.warning(
|
|
"hermes_relay_bootstrap: failed to install Application patch: %s",
|
|
exc,
|
|
)
|
|
|
|
|
|
def install_finder() -> None:
|
|
"""Insert the import-hook finder at the front of `sys.meta_path`."""
|
|
finder = _AioHttpWebFinder()
|
|
sys.meta_path.insert(0, finder)
|
|
|
|
|
|
def _apply_patch(web_module) -> None:
|
|
"""Replace `web_module.Application` with `_PatchedApplication`.
|
|
|
|
No-op if `web_module` lacks the expected `Application` attribute (e.g.
|
|
aiohttp version skew or stripped-down build).
|
|
"""
|
|
original_application = getattr(web_module, "Application", None)
|
|
if original_application is None:
|
|
logger.warning(
|
|
"hermes_relay_bootstrap: aiohttp.web has no Application attribute "
|
|
"— version mismatch? skipping injection."
|
|
)
|
|
return
|
|
|
|
if getattr(original_application, "_hermes_relay_patched", False):
|
|
return # Idempotent — already patched in a previous import.
|
|
|
|
class _PatchedApplication(original_application):
|
|
"""Subclass of aiohttp.web.Application with adapter-detection hook.
|
|
|
|
Hermes-agent's `APIServerAdapter.connect()` does
|
|
`self._app["api_server_adapter"] = self` immediately after building
|
|
the application. We catch that and use the adapter reference to
|
|
register our extra routes on `self.router` while it's still mutable.
|
|
"""
|
|
|
|
_hermes_relay_patched = True
|
|
|
|
def __setitem__(self, key, value):
|
|
super().__setitem__(key, value)
|
|
if key != "api_server_adapter":
|
|
return
|
|
try:
|
|
_maybe_register_routes(self, value)
|
|
except Exception as exc: # pragma: no cover - defensive logging
|
|
logger.warning(
|
|
"hermes_relay_bootstrap: route injection failed: %s",
|
|
exc,
|
|
)
|
|
try:
|
|
_maybe_install_command_middleware(self, value)
|
|
except Exception as exc: # pragma: no cover - defensive logging
|
|
logger.warning(
|
|
"hermes_relay_bootstrap: command middleware "
|
|
"installation failed: %s",
|
|
exc,
|
|
)
|
|
|
|
web_module.Application = _PatchedApplication
|
|
|
|
|
|
def _maybe_register_routes(app, adapter) -> None:
|
|
"""Register missing compatibility routes without shadowing upstream.
|
|
|
|
The registrable set is compatibility-only: session search, memory, legacy
|
|
skill detail/toggle, config, and available-models. Native `/api/sessions/*`
|
|
CRUD and `/v1/skills`/`/v1/toolsets` are upstream's alone — the bootstrap
|
|
carries no handlers for them anymore. Detection stays per method/path (not
|
|
all-or-nothing) so any remaining surface that later lands in core is
|
|
skipped automatically.
|
|
"""
|
|
existing_paths: set[str] = set()
|
|
try:
|
|
for resource in app.router.resources():
|
|
canonical = getattr(resource, "canonical", None)
|
|
if canonical:
|
|
existing_paths.add(canonical)
|
|
except Exception:
|
|
# If router introspection fails, err on the side of NOT injecting —
|
|
# double-registration would crash the whole gateway startup.
|
|
logger.warning(
|
|
"hermes_relay_bootstrap: cannot inspect existing routes; "
|
|
"skipping injection (gateway will run with whatever routes "
|
|
"it natively provides)."
|
|
)
|
|
return
|
|
|
|
if existing_paths & _COMPATIBILITY_PATHS:
|
|
logger.info(
|
|
"hermes_relay_bootstrap: detected native /api/* routes; "
|
|
"will inject only missing compatibility routes."
|
|
)
|
|
|
|
# Defer the heavy import until we're actually going to register. This
|
|
# keeps the bootstrap cheap for `python -c "1+1"` style invocations
|
|
# that never use aiohttp meaningfully.
|
|
try:
|
|
from . import _handlers
|
|
except Exception as exc:
|
|
logger.warning(
|
|
"hermes_relay_bootstrap: cannot import _handlers (%s); "
|
|
"skipping injection.",
|
|
exc,
|
|
)
|
|
return
|
|
|
|
try:
|
|
injected = _handlers.register_routes(app, adapter)
|
|
except Exception as exc:
|
|
logger.warning(
|
|
"hermes_relay_bootstrap: register_routes raised %s; "
|
|
"the gateway will run without injected compatibility API.",
|
|
exc,
|
|
)
|
|
return
|
|
|
|
if injected:
|
|
logger.info(
|
|
"hermes_relay_bootstrap: injected %d missing /api/* "
|
|
"compatibility routes onto upstream hermes-agent gateway",
|
|
injected,
|
|
)
|
|
else:
|
|
logger.info(
|
|
"hermes_relay_bootstrap: all compatibility /api/* routes are "
|
|
"already native; no route injection needed."
|
|
)
|
|
|
|
|
|
def _maybe_install_command_middleware(app, adapter) -> None:
|
|
"""Install the slash-command middleware on the app if not already present.
|
|
|
|
The middleware intercepts ``/v1/chat/completions`` and ``/v1/runs``
|
|
to handle gateway commands (``/help``, ``/commands``, ``/profile``,
|
|
``/provider``) and return decline notices for stateful commands,
|
|
preventing the LLM from hallucinating responses for them.
|
|
|
|
If the upstream ``api_server_slash`` module already exists (meaning
|
|
the PR has been merged or the fork is deployed), the middleware is
|
|
skipped — the native handler handles interception.
|
|
"""
|
|
try:
|
|
from . import _command_middleware
|
|
except Exception as exc:
|
|
logger.warning(
|
|
"hermes_relay_bootstrap: cannot import _command_middleware (%s); "
|
|
"skipping middleware installation.",
|
|
exc,
|
|
)
|
|
return
|
|
|
|
try:
|
|
_command_middleware.maybe_install_middleware(app, adapter)
|
|
except Exception as exc:
|
|
logger.warning(
|
|
"hermes_relay_bootstrap: maybe_install_middleware raised %s; "
|
|
"slash commands will fall through to the LLM.",
|
|
exc,
|
|
)
|