Skip to content

feat(gateway): platform adapter plugins via PluginContext.register_platform_adapter - #18775

Closed
HongmingWang-Rabbit wants to merge 6 commits into
NousResearch:mainfrom
HongmingWang-Rabbit:feat/platform-adapter-plugins
Closed

feat(gateway): platform adapter plugins via PluginContext.register_platform_adapter#18775
HongmingWang-Rabbit wants to merge 6 commits into
NousResearch:mainfrom
HongmingWang-Rabbit:feat/platform-adapter-plugins

Conversation

@HongmingWang-Rabbit

Copy link
Copy Markdown

What

Adds platform adapters to the set of plugin-extensible surfaces in hermes_cli/plugins.py. Today the existing PluginContext collector pattern lets external plugins (loaded from ~/.hermes/plugins/, ./.hermes/plugins/, or pip entry_points group hermes_agent.plugins) contribute tools, hooks, CLI commands, slash commands, context engines, and skills — but platform adapters are the only plugin type still hardcoded in gateway/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-a2a package 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:

  1. feat(gateway): platform adapter plugins via PluginContext.register_platform_adapter — adds the registration surface. New method on PluginContext, new dict on PluginManager, module-level get_plugin_platform_adapter(name) accessor. Rejects names that shadow built-in Platform enum members. Rejects duplicate registrations (warns + skips, doesn't replace).

  2. feat(gateway): wire register_platform_adapter into config + boot pathGatewayConfig.plugin_platforms: Dict[str, PlatformConfig] sibling to existing platforms: Dict[Platform, PlatformConfig]. Held separately so the closed Platform enum stays closed. from_dict triggers plugin discovery before parsing platform names, then routes unknown-but-claimed names into plugin_platforms. New GatewayRunner._create_plugin_adapter(name, config) keeps the in-tree if/elif chain free of plugin concerns.

  3. feat(gateway): add PluginPlatformIdentifier helper for plugin adaptersBasePlatformAdapter.__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.

  4. 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_dict calls Platform(value) which raises ValueError for any name not in the enum. New resolve_platform_id() helper falls back to PluginPlatformIdentifier only when a currently-loaded plugin actually claims the name — so bare typos and corrupted state still raise loudly. Wired into the three known from_dict call sites: SessionSource, SessionEntry, HomeChannel.

Total: ~120 LOC added, 6 LOC changed.

Backward compatibility

  • Platform enum unchanged. No new members, no synthetic values.
  • Existing from_dict deserializers reject unknown strings exactly as before — the fallback only kicks in when a plugin claims the name.
  • In-tree adapters take precedence over plugin claims at registration time (same name = registration rejected with a warning).
  • GatewayConfig.plugin_platforms defaults 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, including test_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):

  • 11/11 unit tests on adapter lifecycle, inbound HTTP auth, outbound POST routing, plugin entry-point shape.
  • 8/8 production-path E2E checkpoints: pip entry_points discovery → platform registry → GatewayConfig.from_dict routing → _create_plugin_adapter instantiation → live HTTP listener → MessageEvent(internal=True) dispatch → callback POST roundtrip → SessionSource to_dict/from_dict round-trip.
  • 9/9 user-dir-discovery validation against the patched 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_output assert len(list_plugins()) == N after creating N test plugins in tmp_path. They fail on any dev box that has a hermes plugin pip-installed because discover_and_load() always scans entry_points (global, not isolated by HERMES_HOME). These three tests would benefit from either filtering entry-point plugins out, or a discover_only_user_dir=True test 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

  1. Naming. register_platform_adapter matches existing register_* collector methods. Short forms (register_platform, register_channel) are also reasonable. Defaulting to the long form for consistency.
  2. PluginPlatformIdentifier vs Platform.from_string(). I went with a dedicated class so the closed enum stays closed. An alternative would be a Platform.from_string() classmethod returning either a real member or a synthetic one — also workable, but it changes what isinstance(x, Platform) means. Open to either direction.
  3. Bundled example plugin. tests/plugins/ has fixture-only plugins. Would maintainers prefer a real bundled example adapter (matching how plugins/memory/<name>/ ships real implementations), or is the fixture-only approach the right shape here?
  4. Multi-account plugins. Built-ins (Telegram, Slack) support multi-account via the extra config dict. Plugin-registered adapters can do the same, but no convention is enforced. Worth a docs note?

Out of scope

  • Migrating plugins/memory/__init__.py to use the unified discovery path. Orthogonal cleanup.
  • A "Plugins Hub" analogous to Skills Hub. Useful but separate proposal — ship the contract first.

Happy to split into smaller PRs if preferred — each commit is independently reviewable.

Hongming Wang and others added 4 commits May 2, 2026 02:30
…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).
@alt-glitch alt-glitch added P3 Low — cosmetic, nice to have type/feature New feature or request comp/gateway Gateway runner, session dispatch, delivery comp/plugins Plugin system and bundled plugins labels May 2, 2026
@alt-glitch

Copy link
Copy Markdown
Collaborator

Related to #17751 (merged), #17664, #7942 — earlier iterations of pluggable platform adapters. This appears to be a follow-up or alternative implementation.

@alt-glitch

Copy link
Copy Markdown
Collaborator

Related to #17751 (merged), #17664, #7942 — earlier iterations of pluggable platform adapters.

….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).
HongmingWang-Rabbit pushed a commit to Molecule-AI/hermes-platform-molecule-a2a that referenced this pull request May 2, 2026
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.
@HongmingWang-Rabbit

Copy link
Copy Markdown
Author

Update: pushed ece9e34e — fixes a real integration bug caught while validating against a real hermes gateway run subprocess.

Previously the plugin-platform boot loop did self.adapters[plugin_name] = adapter (string key), while built-in adapters use a Platform enum key. Mismatch escaped the in-process tests because they don't exercise GatewayRunner.start() — only _create_plugin_adapter directly. A real subprocess test caught it on the first run:

File "gateway/run.py", line 2027, in <listcomp>
    "platforms": [p.value for p in self.adapters.keys()],
AttributeError: 'str' object has no attribute 'value'

Fix: self.adapters[adapter.platform] = adapteradapter.platform is the PluginPlatformIdentifier instance, which has the .value attribute every downstream consumer needs. This is the original reason PluginPlatformIdentifier exists; commit #2 just wasn't using it consistently.

After the fix, a fresh hermes gateway run subprocess on a tmp HERMES_HOME with platforms.molecule-a2a enabled passes 4/4 checkpoints:

  • gateway boots and stays up
  • /a2a/health returns 200
  • /a2a/inbound POST returns 200 with the queued ack
  • clean shutdown on SIGTERM

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>
@HongmingWang-Rabbit

Copy link
Copy Markdown
Author

Closing as superseded by #17751 (merged 2026-04-30 — feat: pluggable gateway platforms — drop-in messaging adapters (salvage of #17664)).

#17751 ships a more comprehensive pluggable-platform system with:

  • ctx.register_platform(name, label, adapter_factory, check_fn, ...) (richer signature than my register_platform_adapter — supports labels, install hints, optional validate_config / setup_fn)
  • Open Platform enum via _missing_() (cleaner than my PluginPlatformIdentifier)
  • Dedicated gateway/platform_registry.py with PlatformEntry dataclass

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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

comp/gateway Gateway runner, session dispatch, delivery comp/plugins Plugin system and bundled plugins P3 Low — cosmetic, nice to have type/feature New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants