hermes - ✅(Solved) Fix refactor(plugins): add apply_yaml_config_fn registry hook to let plugins own their YAML→env config bridges [1 pull requests, 1 participants]

Official PRs (…)
ON THIS PAGE

GitHub issue graph ai analysis

Paste a GitHub issue URL. We fetch that issue, discover linked issues from bodies/comments/timeline, collect linked pull requests, and produce a structured English report.

The report is written in English Markdown for sharing and archival.

Helpful · Quick feedback

Loading…
GitHub stats
NousResearch/hermes-agent#24836Fetched 2026-05-14 03:51:21
View on GitHub
Comments
0
Participants
1
Timeline
6
Reactions
0
Participants
Timeline (top)
labeled ×4cross-referenced ×1referenced ×1

Error Message

After the existing per-Platform shared-key loop:

for entry in platform_registry.all_entries(): if entry.apply_yaml_config_fn is None: continue platform_cfg = yaml_cfg.get(entry.name, {}) if not isinstance(platform_cfg, dict): continue try: seeded = entry.apply_yaml_config_fn(yaml_cfg, platform_cfg) except Exception as e: logger.debug("apply_yaml_config_fn for %s raised: %s", entry.name, e) continue if isinstance(seeded, dict) and seeded: try: plat = Platform(entry.name) if plat in config.platforms: config.platforms[plat].extra.update(seeded) except ValueError: pass # plugin platform not in Platform enum

Root Cause

Eliminates the YAML→env round-trip entirely — the adapter just reads extra["require_mention"] directly. This is what Teams does (no YAML bridge needed because the adapter doesn't read env vars for runtime config; it reads extra).

Fix Action

Fix / Workaround

  • The adapter still reads its config via os.getenv(...) rather than from PlatformConfig.extra, even though it now lives in plugins/platforms/<name>/.
  • A core file (gateway/config.py) needs to know about every platform's YAML schema — exactly the per-platform-boilerplate tax #3823 is trying to eliminate.
  • Third-party plugins under ~/.hermes/plugins/ cannot bridge YAML keys to env at all unless they monkey-patch load_gateway_config (community Xiaomi/Sidekick adapters reported similar friction in #3823).
@dataclass
class PlatformEntry:
    ...
    # Optional: given the raw `yaml_cfg` dict (parsed config.yaml top-level)
    # and the platform's own sub-dict, translate YAML keys into env vars
    # and/or return a `dict` to merge into `PlatformConfig.extra`.
    #
    # Signature: (yaml_cfg: dict, platform_cfg: dict) -> Optional[dict]
    #
    # - Mutating os.environ is allowed (env-precedence semantics preserved).
    # - Returned dict is merged into PlatformConfig.extra at this platform's
    #   slot (None or empty dict = no-op).
    # - Called during load_gateway_config(), after the generic shared-key
    #   loop at line 758, before _apply_env_overrides().
    apply_yaml_config_fn: Optional[Callable[[dict, dict], Optional[dict]]] = None
  1. Add the hook to PlatformEntry + the dispatch loop in load_gateway_config. Pure addition, no behavior change. Single small PR.
  2. Migrate platforms one at a time — each platform's bridge moves from gateway/config.py into its plugin (or, for still-in-gateway/platforms/ adapters, into a sibling helper imported and registered via the gateway's existing platform registration). One PR per platform. Discord is the natural first migration target since it just landed as a plugin in #24356.
  3. Eventually delete the per-platform blocks at gateway/config.py:828–1080 once every platform has a plugin or self-registered hook. The generic shared-key loop stays.

PR fix notes

PR #24849: refactor(plugins): add apply_yaml_config_fn registry hook for YAML→env config bridges

Description (problem / solution / changelog)

Summary

Adds apply_yaml_config_fn to PlatformEntry so platform plugins can own their YAML→env config bridges instead of forcing core gateway/config.py to know every platform's schema. Pure addition — no behavior change for existing platforms.

Closes #24836. Refs #3823, #24356.

Why

gateway/config.py currently has hardcoded YAML→env bridges for 8 platforms (discord, telegram, whatsapp, slack, dingtalk, mattermost, matrix, feishu, ~252 LOC). When a platform migrates to the bundled plugin system (e.g. Discord in #24356), its bridge stays stuck in core. Third-party plugin platforms can't bridge YAML keys to env at all without monkey-patching load_gateway_config. The existing env_enablement_fn hook (#21306, #21331) handles env→extras seeding but not YAML→env, which is the symmetric case this hook covers.

What changed

FileChange
gateway/platform_registry.pyNew optional apply_yaml_config_fn: Optional[Callable[[dict, dict], Optional[dict]]] = None field on PlatformEntry.
gateway/config.pyDispatch loop in load_gateway_config() immediately after the generic shared-key loop. Iterates registry entries, calls hook with (yaml_cfg, platform_cfg), swallows exceptions to debug log (matches env_enablement_fn precedent), merges any returned dict into the platform's extra via platforms_data so it survives from_dict.
tests/gateway/test_platform_registry.py10 new tests: field default, callable acceptance, env mutation, extras merge, both signature args received, exception handling (loop continues after a bad hook), missing/non-dict YAML sections skipped, env > YAML precedence preserved when hook uses not os.getenv(...) guards.
gateway/platforms/ADDING_A_PLATFORM.mdDocument the new hook alongside env_enablement_fn.
website/docs/developer-guide/adding-platform-adapters.mdNew "YAML→env Config Bridge" section + entry in the integration-points table.

hermes_cli/plugins.py::register_platform() already forwards **entry_kwargs to PlatformEntry, so plugins can pass apply_yaml_config_fn= immediately with no plugin-side core change.

Order of operations

load_gateway_config():
  1. Generic shared-key loop (unchanged)         — handles unauthorized_dm_behavior,
                                                    notice_delivery, reply_prefix,
                                                    require_mention, etc. across all
                                                    Platform enum members.
  2. apply_yaml_config_fn dispatch (NEW)         — platform-specific YAML keys.
  3. Legacy per-platform hardcoded blocks        — still run; their `not os.getenv(...)`
                                                    guards make them no-ops for any env
                                                    var the hook already set, so env
                                                    > YAML precedence is preserved.
  4. GatewayConfig.from_dict + _apply_env_overrides

This order means each of the 8 hardcoded platforms can migrate independently in follow-up PRs without breaking anything, and migration is net-negative LOC per platform.

Example usage (for a future plugin migration PR)

import os

def _apply_yaml_config(yaml_cfg: dict, platform_cfg: dict) -> dict | None:
    if "require_mention" in platform_cfg and not os.getenv("MY_PLATFORM_REQUIRE_MENTION"):
        os.environ["MY_PLATFORM_REQUIRE_MENTION"] = str(platform_cfg["require_mention"]).lower()
    allowed = platform_cfg.get("allowed_channels")
    if allowed is not None and not os.getenv("MY_PLATFORM_ALLOWED_CHANNELS"):
        if isinstance(allowed, list):
            allowed = ",".join(str(v) for v in allowed)
        os.environ["MY_PLATFORM_ALLOWED_CHANNELS"] = str(allowed)
    return None  # nothing extra to seed into PlatformConfig.extra

def register(ctx):
    ctx.register_platform(
        name="my_platform",
        ...,
        apply_yaml_config_fn=_apply_yaml_config,
    )

Test plan

  • bash scripts/run_tests.sh tests/gateway/test_platform_registry.py42 passed (32 existing + 10 new).
  • bash scripts/run_tests.sh tests/gateway/test_discord_reply_mode.py tests/gateway/test_slack_mention.py tests/gateway/test_whatsapp_reply_prefix.py tests/gateway/test_whatsapp_group_gating.py118 passed (every YAML-loading test in the gateway suite).
  • Full tests/gateway/ → 5367 passed, 9 failures all pre-existing on origin/main (Discord document-handling + Matrix E2EE — verified by stashing this PR and re-running on stock main).
  • E2E with real load_gateway_config(): registered a temporary plugin platform with a YAML→env hook in a tmp HERMES_HOME, wrote a config.yaml exercising both the new hook AND the existing Discord hardcoded block, and verified:
    • Discord legacy block still bridges discord.require_mention and discord.allowed_channels to env vars.
    • Plugin hook successfully sets its own env vars and seeds PlatformConfig.extra.
    • Both coexist cleanly.

Out of scope (future PRs)

  • Migrating any specific platform's hardcoded bridge to use the hook — each is independent and net-negative LOC. Discord first, riding on #24356.
  • The other Discord-shaped concerns called out in #24356 (Platform.DISCORD enum, allowlist maps, _UPDATE_ALLOWED_PLATFORMS, voice-mode adapter access) — independent generic-registry concerns deserving their own scoping issues.

Changed files

  • gateway/config.py (modified, +65/-9)
  • gateway/platform_registry.py (modified, +16/-0)
  • gateway/platforms/ADDING_A_PLATFORM.md (modified, +8/-0)
  • tests/gateway/test_platform_registry.py (modified, +314/-0)
  • website/docs/developer-guide/adding-platform-adapters.md (modified, +41/-0)

Code Example

@dataclass
class PlatformEntry:
    ...
    # Optional: given the raw `yaml_cfg` dict (parsed config.yaml top-level)
    # and the platform's own sub-dict, translate YAML keys into env vars
    # and/or return a `dict` to merge into `PlatformConfig.extra`.
    #
    # Signature: (yaml_cfg: dict, platform_cfg: dict) -> Optional[dict]
    #
    # - Mutating os.environ is allowed (env-precedence semantics preserved).
    # - Returned dict is merged into PlatformConfig.extra at this platform's
    #   slot (None or empty dict = no-op).
    # - Called during load_gateway_config(), after the generic shared-key
    #   loop at line 758, before _apply_env_overrides().
    apply_yaml_config_fn: Optional[Callable[[dict, dict], Optional[dict]]] = None

---

# After the existing per-Platform shared-key loop:
for entry in platform_registry.all_entries():
    if entry.apply_yaml_config_fn is None:
        continue
    platform_cfg = yaml_cfg.get(entry.name, {})
    if not isinstance(platform_cfg, dict):
        continue
    try:
        seeded = entry.apply_yaml_config_fn(yaml_cfg, platform_cfg)
    except Exception as e:
        logger.debug("apply_yaml_config_fn for %s raised: %s", entry.name, e)
        continue
    if isinstance(seeded, dict) and seeded:
        try:
            plat = Platform(entry.name)
            if plat in config.platforms:
                config.platforms[plat].extra.update(seeded)
        except ValueError:
            pass  # plugin platform not in Platform enum

---

# plugins/platforms/discord/adapter.py
def _apply_yaml_config(yaml_cfg, discord_cfg) -> dict | None:
    if "require_mention" in discord_cfg and not os.getenv("DISCORD_REQUIRE_MENTION"):
        os.environ["DISCORD_REQUIRE_MENTION"] = str(discord_cfg["require_mention"]).lower()
    # ... 60 more lines of Discord-specific YAML bridging
    return None  # no extras to seed; everything goes via env

def register(ctx):
    ctx.register_platform(
        ...,
        apply_yaml_config_fn=_apply_yaml_config,
    )
RAW_BUFFERClick to expand / collapse

Generic registry hook for YAML→env config bridges (apply_yaml_config_fn)

Background

Eight platform adapters in gateway/platforms/ rely on platform-specific YAML→env bridges hardcoded in gateway/config.py::load_gateway_config(). Each block translates config.yaml keys into environment variables that the adapter later reads via os.getenv():

PlatformLinesLOC
discord851–92069
telegram920–98767
whatsapp987–101427
dingtalk1014–103824
slack828–85123
matrix1055–107621
mattermost1038–105517
feishu1076–10804
Total~252 LOC

These coexist with a generic loop at gateway/config.py:758–826 that already handles platform-shared keys (unauthorized_dm_behavior, notice_delivery, reply_prefix, reply_in_thread, require_mention) by iterating over Platform enum values and writing into PlatformConfig.extra. The per-platform blocks layer adapter-specific keys on top of that.

The problem

When a built-in platform migrates to the bundled plugin system (e.g. Discord in #24356), its YAML→env bridge is still stuck in gateway/config.py. The plugin can't own its own config translation, so:

  • The adapter still reads its config via os.getenv(...) rather than from PlatformConfig.extra, even though it now lives in plugins/platforms/<name>/.
  • A core file (gateway/config.py) needs to know about every platform's YAML schema — exactly the per-platform-boilerplate tax #3823 is trying to eliminate.
  • Third-party plugins under ~/.hermes/plugins/ cannot bridge YAML keys to env at all unless they monkey-patch load_gateway_config (community Xiaomi/Sidekick adapters reported similar friction in #3823).

The existing registry hooks (env_enablement_fn, setup_fn, standalone_sender_fn, etc.) don't cover this case:

  • env_enablement_fn is called too late (after env-only enablement) and only seeds PlatformConfig.extra from env vars — it doesn't help with YAML→env direction.
  • validate_config and is_connected are read-only inspection hooks, not transformations.

Proposal: apply_yaml_config_fn

Add an optional hook on PlatformEntry:

@dataclass
class PlatformEntry:
    ...
    # Optional: given the raw `yaml_cfg` dict (parsed config.yaml top-level)
    # and the platform's own sub-dict, translate YAML keys into env vars
    # and/or return a `dict` to merge into `PlatformConfig.extra`.
    #
    # Signature: (yaml_cfg: dict, platform_cfg: dict) -> Optional[dict]
    #
    # - Mutating os.environ is allowed (env-precedence semantics preserved).
    # - Returned dict is merged into PlatformConfig.extra at this platform's
    #   slot (None or empty dict = no-op).
    # - Called during load_gateway_config(), after the generic shared-key
    #   loop at line 758, before _apply_env_overrides().
    apply_yaml_config_fn: Optional[Callable[[dict, dict], Optional[dict]]] = None

gateway/config.py::load_gateway_config() invokes registered hooks after the existing generic loop:

# After the existing per-Platform shared-key loop:
for entry in platform_registry.all_entries():
    if entry.apply_yaml_config_fn is None:
        continue
    platform_cfg = yaml_cfg.get(entry.name, {})
    if not isinstance(platform_cfg, dict):
        continue
    try:
        seeded = entry.apply_yaml_config_fn(yaml_cfg, platform_cfg)
    except Exception as e:
        logger.debug("apply_yaml_config_fn for %s raised: %s", entry.name, e)
        continue
    if isinstance(seeded, dict) and seeded:
        try:
            plat = Platform(entry.name)
            if plat in config.platforms:
                config.platforms[plat].extra.update(seeded)
        except ValueError:
            pass  # plugin platform not in Platform enum

This lets each plugin own its YAML→env bridge as part of its register() block:

# plugins/platforms/discord/adapter.py
def _apply_yaml_config(yaml_cfg, discord_cfg) -> dict | None:
    if "require_mention" in discord_cfg and not os.getenv("DISCORD_REQUIRE_MENTION"):
        os.environ["DISCORD_REQUIRE_MENTION"] = str(discord_cfg["require_mention"]).lower()
    # ... 60 more lines of Discord-specific YAML bridging
    return None  # no extras to seed; everything goes via env

def register(ctx):
    ctx.register_platform(
        ...,
        apply_yaml_config_fn=_apply_yaml_config,
    )

Migration path

  1. Add the hook to PlatformEntry + the dispatch loop in load_gateway_config. Pure addition, no behavior change. Single small PR.
  2. Migrate platforms one at a time — each platform's bridge moves from gateway/config.py into its plugin (or, for still-in-gateway/platforms/ adapters, into a sibling helper imported and registered via the gateway's existing platform registration). One PR per platform. Discord is the natural first migration target since it just landed as a plugin in #24356.
  3. Eventually delete the per-platform blocks at gateway/config.py:828–1080 once every platform has a plugin or self-registered hook. The generic shared-key loop stays.

Each migration step is independently mergeable and net-negative LOC.

Alternatives considered

Alternative A: refactor adapters to read from PlatformConfig.extra instead of os.getenv

Eliminates the YAML→env round-trip entirely — the adapter just reads extra["require_mention"] directly. This is what Teams does (no YAML bridge needed because the adapter doesn't read env vars for runtime config; it reads extra).

Trade-off: Discord's adapter has ~50 os.getenv call sites for runtime config. Migrating all of them is a separate, much larger refactor that would touch the adapter's hot paths. The hook approach lets the migration land incrementally without rewriting adapter internals.

Alternative B: keep the bridges in gateway/config.py forever

Status quo. Costs:

  • Per-platform boilerplate stays in core (~252 LOC across 8 platforms today).
  • Issue #3823's "adding a new platform requires touching 17 files" tax is still real for every YAML-keyed adapter.
  • Third-party platform plugins can't expose YAML config without monkey-patching.

Alternative C: drop YAML config support; require env-only configuration

Breaking change for every existing user with a platforms.discord: (or .slack, .telegram, etc.) section in config.yaml. Off the table.

Out of scope for this issue

  • The other Discord-shaped concerns called out in #24356 (Platform.DISCORD enum, _is_user_authorized allowlist maps, _UPDATE_ALLOWED_PLATFORMS frozenset, voice-mode adapter access) — those are independent generic-registry concerns and deserve their own scoping issues.
  • Migrating any specific platform to the new hook — this issue is just about adding the hook itself. Each per-platform migration is a follow-up.

Asks

  1. Is apply_yaml_config_fn the right name / signature? Open to alternatives — could be bridge_yaml_to_env_fn, read_yaml_config_fn, etc.
  2. Is the dispatch order correct (after the generic shared-key loop, before _apply_env_overrides)?
  3. Any preference between Alternative A (full adapter refactor) and this hook approach?

Happy to open a PR for the hook itself once the design is acked.

Refs #3823, #24356.

Vote matrix · Quick signals

Works
Did the solution work? Tap to confirm.
Easy Fix
Was it a quick fix?
Time Saver
Did it save you time?
Blocking
Was it severely blocking?
Common Issue
Are others likely hitting this too?
Flaky / Intermittent
Is it intermittent?
Verified / Reproducible
Can you reproduce it reliably?
Loading…

Still need to ship something?

×6

Another batch ranked right after the header list — different links, same matching logic.

Back to top recommendations

TRENDING