feat(gateway): platform adapter plugins via PluginContext.register_platform_adapter - #18775
Conversation
…atform_adapter Adds register_platform_adapter to PluginContext so plugins discovered via ~/.hermes/plugins/, ./.hermes/plugins/, or pip entry_points (group hermes_agent.plugins) can register custom messaging platform adapters the same way they register tools, hooks, CLI commands, and skills. Until now, messaging platforms were the only PluginContext capability still hardcoded — gateway/run.py:_create_adapter is a 19-branch if/elif chain with no extension point. This commit adds the registration surface but does NOT yet wire it into _create_adapter — that's a separate commit so the patch lands incrementally. Validated via .hermes-validation/test_register_platform_adapter.py in molecule-core: plugin discovery + registration + duplicate rejection + in-tree non-shadowing all pass. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
GatewayConfig: - Adds plugin_platforms: Dict[str, PlatformConfig] sibling to the existing platforms: Dict[Platform, PlatformConfig]. Held separately so the closed Platform enum stays closed (no synthetic enum members, no string-vs-enum sniffing across the codebase). - from_dict triggers plugin discovery before parsing platform names, then routes unknown-but-claimed names into plugin_platforms. Unclaimed unknown names continue to be silently skipped (preserves the original behavior). GatewayRunner: - New _create_plugin_adapter(name, config) — looks up the registered (adapter_class, requirements_check) tuple via hermes_cli.plugins.get_plugin_platform_adapter, validates requirements, instantiates. Distinct from _create_adapter so the in-tree if/elif chain stays free of plugin concerns. - Boot loop in _run_async now iterates self.config.plugin_platforms after the in-tree platforms loop. Plugin platforms get the same message_handler / fatal_error_handler / session_store / busy_session_handler wiring as in-tree adapters; reconnection-queue + status-tracking is intentionally simpler for v1 (no retry loop). Validation: 9/9 checks pass in .hermes-validation/test_register_platform_adapter.py covering registration, discovery, in-tree precedence, duplicate rejection, config routing, and adapter instantiation. 48/48 hermes plugin tests pass; gateway test failures in this branch are env-related (aiohttp, websocket) and don't touch the patched code paths. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Plugin adapters can't extend the closed Platform enum but
BasePlatformAdapter.__init__ requires a Platform-shaped second arg.
This helper quacks like a Platform enum value (.value, hashable,
equality) so plugin adapters can subclass BasePlatformAdapter cleanly:
super().__init__(config, PluginPlatformIdentifier("my-platform"))
Distinct hash bucket prevents accidental collision with built-in
Platform members in dict keys (defense-in-depth — registration also
rejects names that shadow Platform values).
…orm_id Without this, plugin-registered platforms (like molecule-a2a) survive the runtime hot path but die on daemon restart: SessionSource.from_dict calls Platform(value) directly, which raises ValueError for any name not in the closed enum. Restored sessions with plugin-platform origins either crash the restore loop or silently drop the platform field. Add resolve_platform_id() — Platform-first, falls back to PluginPlatformIdentifier ONLY when a currently-loaded plugin actually claims the name. Bare typos and corrupted state still raise so silent state-corruption can't slip past restore. Wire it into the three known from_dict call sites: - gateway/session.py:SessionSource.from_dict - gateway/session.py:SessionEntry.from_dict (drops the silent debug-log swallow that left platform=None on unknown values) - gateway/config.py:HomeChannel.from_dict All existing gateway/test_session.py round-trip tests still pass, including test_invalid_platform_raises (the narrow fallback preserves loud-fail semantics for genuinely unknown names).
….adapters
Previously the plugin-platform boot loop did self.adapters[plugin_name]
= adapter (string key), while built-in adapters use a Platform enum
key. The mismatch escaped review because the in-process unit + E2E
tests don't exercise GatewayRunner.start() — they only call
_create_plugin_adapter directly.
Caught by a real `hermes gateway run` subprocess test:
File "gateway/run.py", line 2027, in <listcomp>
"platforms": [p.value for p in self.adapters.keys()],
AttributeError: 'str' object has no attribute 'value'
Switching to self.adapters[adapter.platform] = adapter — adapter.platform
is the PluginPlatformIdentifier passed to BasePlatformAdapter.__init__,
which has the .value attribute every downstream consumer needs.
After the fix the same subprocess test passes 4/4 checkpoints (config
parse → plugin boot → /a2a/health 200 → /a2a/inbound 200).
scripts/e2e_validate.py — extended with the SessionSource to_dict/from_dict round-trip check, which proved out commit #4 of the upstream patch series (plugin-platform-safe deserialization). 8/8 checkpoints pass. scripts/e2e_real_hermes_subprocess.py — NEW. Spawns a real `hermes gateway run` subprocess against a tmp HERMES_HOME with the plugin platform enabled, polls until /a2a/health responds, then POSTs into /a2a/inbound and asserts a 200 ack. This is the closest reproduction of production-shape boot we can do without provisioning a workspace + holding LLM provider creds + having a peer agent online. The subprocess test caught a real integration bug — self.adapters dict keyed by mixed types (Platform enum for built-ins, string for plugins) crashed downstream consumers doing .value. Fix landed upstream: NousResearch/hermes-agent#18775 commit ece9e34e.
|
Update: pushed Previously the plugin-platform boot loop did Fix: After the fix, a fresh
The validation script lives at https://github.com/Molecule-AI/hermes-platform-molecule-a2a/blob/main/scripts/e2e_real_hermes_subprocess.py and runs the real binary in ~7s on macOS. |
The static PLATFORMS registry only contains built-in platforms (slack, discord, etc.). Plugin platforms registered via PluginContext.register_platform_adapter aren't in PLATFORMS, so the existing PLATFORMS[platform]["default_toolset"] lookup raised KeyError during agent loop init for any plugin-platform message. Caught by an end-to-end test that spawns hermes gateway run + a stub OpenAI-compat LLM and routes a real message through a plugin platform (molecule-a2a). Fall back to "hermes-cli" (most permissive in-tree toolset) for plugin platforms; operators can override per-platform via config.platform_toolsets. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
Closing as superseded by #17751 (merged 2026-04-30 — #17751 ships a more comprehensive pluggable-platform system with:
The downstream consumer plugin I built against my API (Molecule-AI/hermes-channel-molecule) has migrated to the #17751 API in its 754d162 commit, with a dual-mode fallback for installations still on pre-#17751 forks. No outstanding need for #18775's narrower shape. Thanks @alt-glitch for the pointer to the predecessor PRs. |
What
Adds platform adapters to the set of plugin-extensible surfaces in
hermes_cli/plugins.py. Today the existingPluginContextcollector pattern lets external plugins (loaded from~/.hermes/plugins/,./.hermes/plugins/, or pipentry_pointsgrouphermes_agent.plugins) contribute tools, hooks, CLI commands, slash commands, context engines, and skills — but platform adapters are the only plugin type still hardcoded ingateway/run.py:_create_adapter. This PR closes that gap with the smallest possible upstream change.Why
Anyone shipping a hermes integration for a non-built-in platform (custom A2A protocol, internal chat system, IoT bus, etc.) currently has to either (a) maintain a fork or (b) get their adapter merged upstream. Both are friction. Every other plugin type already has a clean external path; platform adapters should too.
Concrete consumer: an
hermes-platform-molecule-a2apackage currently shipping privately, which we'd like to make pip-installable once this lands. Built + validated locally against this branch (see Test plan).How
Four commits, each scoped to one concern:
feat(gateway): platform adapter plugins via PluginContext.register_platform_adapter— adds the registration surface. New method onPluginContext, new dict onPluginManager, module-levelget_plugin_platform_adapter(name)accessor. Rejects names that shadow built-inPlatformenum members. Rejects duplicate registrations (warns + skips, doesn't replace).feat(gateway): wire register_platform_adapter into config + boot path—GatewayConfig.plugin_platforms: Dict[str, PlatformConfig]sibling to existingplatforms: Dict[Platform, PlatformConfig]. Held separately so the closedPlatformenum stays closed.from_dicttriggers plugin discovery before parsing platform names, then routes unknown-but-claimed names intoplugin_platforms. NewGatewayRunner._create_plugin_adapter(name, config)keeps the in-tree if/elif chain free of plugin concerns.feat(gateway): add PluginPlatformIdentifier helper for plugin adapters—BasePlatformAdapter.__init__(config, platform: Platform)requires a Platform-shaped second arg. Plugin adapters can't extend the closed enum, so this helper class quacks like a Platform value (.value, hashable, equality). Distinct hash bucket prevents accidental collision with built-in members.feat(gateway): plugin-platform-safe deserialization via resolve_platform_id— without this, plugin platforms survive the runtime hot path but die on daemon restart:SessionSource.from_dictcallsPlatform(value)which raisesValueErrorfor any name not in the enum. Newresolve_platform_id()helper falls back toPluginPlatformIdentifieronly when a currently-loaded plugin actually claims the name — so bare typos and corrupted state still raise loudly. Wired into the three knownfrom_dictcall sites:SessionSource,SessionEntry,HomeChannel.Total: ~120 LOC added, 6 LOC changed.
Backward compatibility
Platformenum unchanged. No new members, no synthetic values.from_dictdeserializers reject unknown strings exactly as before — the fallback only kicks in when a plugin claims the name.GatewayConfig.plugin_platformsdefaults to empty; configs that don't enable any plugin platform see zero behavior change.Test plan
Existing suite still green (pre-existing isolation failures noted below):
pytest tests/gateway/test_session.py— 62/62 pass, includingtest_invalid_platform_raises(the narrow fallback preserves loud-fail semantics).pytest tests/hermes_cli/test_plugins.py tests/hermes_cli/test_plugins_cmd.py tests/test_plugin_skills.py— 132/132 pass on a clean dev box (no entry-point plugins installed).New validation against an external consumer (
hermes-platform-molecule-a2a):entry_pointsdiscovery → platform registry →GatewayConfig.from_dictrouting →_create_plugin_adapterinstantiation → live HTTP listener →MessageEvent(internal=True)dispatch → callback POST roundtrip →SessionSourceto_dict/from_dict round-trip.PluginContext/PluginManager.Pre-existing test isolation issue (independent of this PR, surfaced during validation):
tests/hermes_cli/test_plugins.py::test_discover_is_idempotent,::test_discover_skips_dir_without_manifest, and::test_commands_in_list_plugins_outputassertlen(list_plugins()) == Nafter creating N test plugins intmp_path. They fail on any dev box that has a hermes plugin pip-installed becausediscover_and_load()always scansentry_points(global, not isolated byHERMES_HOME). These three tests would benefit from either filtering entry-point plugins out, or adiscover_only_user_dir=Truetest hook. Happy to follow up in a separate PR — the fix is unrelated to this change but caught while validating.Tested on
macOS arm64, Python 3.11. Behavior is pure-Python with no platform-specific syscalls; should work identically on Linux.
Open questions for review
register_platform_adaptermatches existingregister_*collector methods. Short forms (register_platform,register_channel) are also reasonable. Defaulting to the long form for consistency.PluginPlatformIdentifiervsPlatform.from_string(). I went with a dedicated class so the closed enum stays closed. An alternative would be aPlatform.from_string()classmethod returning either a real member or a synthetic one — also workable, but it changes whatisinstance(x, Platform)means. Open to either direction.tests/plugins/has fixture-only plugins. Would maintainers prefer a real bundled example adapter (matching howplugins/memory/<name>/ships real implementations), or is the fixture-only approach the right shape here?extraconfig dict. Plugin-registered adapters can do the same, but no convention is enforced. Worth a docs note?Out of scope
plugins/memory/__init__.pyto use the unified discovery path. Orthogonal cleanup.Happy to split into smaller PRs if preferred — each commit is independently reviewable.