Skip to content

feat(providers): registration seam — a provider can ship its own client from outside the tree (part 2 of the #74743 split) - #40

Open
Alexander Prendota (AlexanderPrendota) wants to merge 3 commits into
base/acp-seamfrom
acp-registration-seam
Open

feat(providers): registration seam — a provider can ship its own client from outside the tree (part 2 of the #74743 split)#40
Alexander Prendota (AlexanderPrendota) wants to merge 3 commits into
base/acp-seamfrom
acp-registration-seam

Conversation

@AlexanderPrendota

Copy link
Copy Markdown
Collaborator

Internal review PR. Base is base/acp-seam, pinned at NousResearch/hermes-agent@9dfbde19db, so the diff here is exactly the two commits and nothing from the 3.5k-commit gap between our main and upstream. Once reviewed, this goes upstream as part 2 of the NousResearch#74743 split.

TL;DR

This is the last piece of core work between us and "Junie installs into stock Hermes with pip install". It adds no Junie code and no vendor names — it removes the three places where core hardcodes one vendor's ACP provider, so any provider can be registered from outside the tree.

copilot-acp is migrated onto the new seam as the in-tree proof and its behaviour is unchanged, asserted step by step.

Background — why this exists

Upstream rejected the Junie integration twice (NousResearch#69207 closed, NousResearch#74743 blocked), both times for the same reason: AGENTS.md's June 2026 policy forbids new third-party-product plugins in the tree. copilot-acp predates that policy (March 2026) and is grandfathered; Junie is not. The agreed path (teknium1 on NousResearch#74743) is a three-part split:

  1. Provider-neutral ACP core — merged upstream 2026-08-26 via ACP core is provider-neutral: shared bridge, scheme rails, review guard (salvage #88470, part 1 of #74743 split) NousResearch/hermes-agent#95679 (our refactor(acp): provider-neutral ACP core — shared OpenAI bridge, agent-as-provider projection, review-fork capability guard (part 1 of the #74743 split) NousResearch/hermes-agent#88470, four commits, authorship preserved).
  2. Registration seamthis PR.
  3. Junie ships standalone against the seam, then gets promoted by Nous.

What was actually broken

I built a throwaway out-of-tree ACP provider (acme-acp) in ~/.hermes/plugins/model-providers/ and walked it forward until it broke, three times. Each fix is keyed on a capability, never on a name.

1. There was nowhere to put a client. create_openai_client() in agent/agent_runtime_helpers.py is a hardcoded if-ladder — copilot-acp → ACP stdio shim, gemini → native client, everything else → openai.OpenAI. No extension point. This is the blocker: providers/__init__.py has discovered out-of-tree profiles from ~/.hermes/plugins/ and pip entry points for a while, but a discovered profile could never supply a transport.

ProviderProfile.create_client(**client_kwargs), returning None by default. Everything that wants the standard client is untouched and the existing ladder stays as the fallback. Resolution is by provider name first, then by base_url prefix, matching what the replaced startswith("acp://copilot") branch did. A profile that raises is logged and skipped — a third-party plugin can fail to provide a client, it cannot take the turn down.

2. Unknown provider before anything was built. resolve_provider() gates on PROVIDER_REGISTRY in hermes_cli/auth.py. That registry already auto-extends itself from providers/ — but only for auth_type == "api_key" providers with env vars. An external-process profile has neither, so it was skipped and hermes -m <provider> died at the gate.

→ Auto-extend now also absorbs external_process profiles, aliases included.

3. The provider was handed someone else's CLI. resolve_external_process_provider_credentials() hardcoded the binary (copilot), the argv (--acp --stdio), the env var names (HERMES_COPILOT_ACP_*), the api_key placeholder and the error text. Separately, resolve_runtime_provider() keyed its external-process branch on the literal "copilot-acp", so any other provider fell through to the OpenRouter default instead of its own runtime.

→ The profile now carries what only the provider knows — process_command, process_args, process_command_env_vars, process_args_env_var — and both core paths key on auth_type == "external_process". copilot's values moved into its profile verbatim.

Bonus: two isinstance(CopilotACPClient) checks in agent/auxiliary_client.py meaning "this client is complete, don't wrap it" became capability flags the client class declares — HERMES_SKIP_TRANSPORT_WRAP and HERMES_SKIP_ASYNC_WRAP, mirroring SUPPORTS_HERMES_TOOL_CALLS from part 1. Two in-tree consumers (the ACP shim and GeminiNativeClient), an out-of-tree client is covered by the same declaration, and the hot path no longer imports those modules just to type-test.

What this means for Junie

Today Junie-over-ACP only runs for someone who installs our fork. That is the whole distribution problem: upstream is at 237k stars and ~7k commits/month, our junie-acp-v2 branch is 6248 commits behind and drifting, and every week of that gap is rebase debt we own forever.

After this merges upstream, Junie's entire footprint in Hermes core becomes zero lines, and the whole integration is this, in our own repository:

class JunieACPProfile(ProviderProfile):
    def create_client(self, **kw):
        from junie_hermes.client import JunieACPClient
        return JunieACPClient(**kw)

    def fetch_models(self, **kw):
        return None


register_provider(JunieACPProfile(
    name="junie-acp",
    aliases=("junie", "jetbrains-junie-acp"),
    base_url="acp://junie",
    auth_type="external_process",
    process_command="junie",
    process_args=("--acp=true",),
    process_command_env_vars=("HERMES_JUNIE_ACP_COMMAND", "JUNIE_CLI_PATH"),
    process_args_env_var="HERMES_JUNIE_ACP_ARGS",
))

Concretely that buys us:

  • pip install onto stock Hermes. No fork, no patched core.
  • No rebase treadmill. The plugin touches no core file, so there is nothing to rebase against 7k commits/month.
  • Our release cadence. A change in Junie's ACP surface ships the same day instead of waiting for a Nous merge and release.
  • A listing in the plugin index. hermes_cli/plugin_index.py + hermes_cli/data/plugin_index.json is the real promotion surface — five entries today, no providers among them. Junie would be the first, and teknium1 already committed to promoting it.

Compare with NousResearch#74743, where the same integration meant a junie-acp literal pasted next to the copilot-acp literal in 11 hermes_cli files — which is precisely what got it blocked.

Still to do after this, none of it blocked by upstream: the client loses the ~470 lines that moved into core in part 1, and the three findings from the first review that were never about shape still apply to the standalone package — hermes_subprocess_env(inherit_credentials=True) instead of os.environ.copy(), behavioural settings in config.yaml instead of HERMES_JUNIE_ACP_*, and SKILL.md under their format standards.

Verification

End to end, out of tree. A provider registered from ~/.hermes/plugins/model-providers/ with zero core edits, walked through the real resolution path:

discovery : profile found, alias "acme" resolves
gate      : PROVIDER_REGISTRY["acme-acp"].auth_type == "external_process"
creds     : {'command': '.../acme-cli', 'args': ['--acp'], 'api_key': 'acme-acp', ...}
runtime   : {'provider': 'acme-acp', 'api_mode': 'chat_completions', 'base_url': 'acp://acme'}
client    : _AcmeClient   (via create_openai_client, not just the helper)

29 new tests, and copilot-acp is asserted alongside the out-of-tree case at every step — same binary, same argv, same api_key placeholder, same env vars, same client class.

Mutation-checked. Reverting each production change fails the corresponding test. This caught a real gap on the first pass: my tests exercised the helper rather than create_openai_client itself, so disabling the call site stayed green. Fixed by adding tests against the real entry point.

Regression: scripts/run_tests.sh tests/hermes_cli/ tests/agent/ tests/run_agent/ tests/providers/14793 passed, 5 failed. All five reproduce identically on a clean upstream/main worktree (port 9119 occupied on this machine, launchd/Linux-desktop specifics, a tmpdir-cleanup flake in test_codex_app_server_persist). No test fails here that does not fail on main.

Review notes — where I'd push back on myself

  • _profile_for_base_url scans every registered profile. Only reached when the provider name did not resolve, so it is off the hot path, but it is O(providers) with no cache. Worth a look.
  • create_client as a method vs a client_factory field. teknium1's wording was "client_factory / provider-registration hook". I chose a method because fetch_models next to it is a method and every profile is written as a subclass. If upstream wants the field form, it is a small change — flagging it before they do.
  • Aliases in PROVIDER_REGISTRY are stored by reference (PROVIDER_REGISTRY[alias] is PROVIDER_REGISTRY[name]). That mirrors the existing api-key branch directly above, so it is consistent, but it does mean a mutation through an alias is visible through the canonical name.
  • HERMES_SKIP_* naming. Negative-sense flags. SUPPORTS_HERMES_TOOL_CALLS from part 1 is positive-sense. Consistent with neither perfectly; I picked the phrasing that reads correctly at the call site.

@github-actions

github-actions Bot commented Aug 28, 2026

Copy link
Copy Markdown

૮ >ﻌ< ა ci review

running on 6bfcf62 — fix(providers): discover a provider plugin installed by `her


Still running 2 jobs: OS-specific tests / Windows-only tests, Python tests / Run tests

❌ Job failures

Check contributors / check-attribution · View job

Job Check contributors / check-attribution failed.


⚠️ Action required

Unmapped contributor email(s) · View job

New contributor email(s) are not in AUTHOR_MAP.

agent@Agents-Mac-mini.local (Agent)
evan-bradford@users.noreply.github.com (evan-bradford)
stiraspo@gmail.com (Sergey Tiraspolsky)
witcheer.eth@gmail.com (witcheer)

How to fix:

Run from the PR branch:

python3 scripts/audit_pr_attribution.py --fix
git add contributors && git commit -m "chore: map contributor emails" && git push

Or map one email manually (do NOT edit AUTHOR_MAP in release.py):

python3 scripts/add_contributor.py <email> <github-username>

To find the GitHub username for an email:

gh api 'search/users?q=EMAIL+in:email' --jq '.items[0].login'

⚠️ Warnings

OSV vulnerability scan · View job

13 known vulnerabilities found in pinned dependencies.

How to fix:

Review the findings in the Security tab. Update the affected dependencies if a patched version is available.

@AlexanderPrendota

Copy link
Copy Markdown
Collaborator Author

Added a third commit — ea4075eda7.

While checking how the plugin index actually installs things I found that the two parts of the documented path don't meet:

  • hermes plugins install owner/repo clones into ~/.hermes/plugins/<name>/ — flat, one dir per plugin
  • provider discovery only ever scanned ~/.hermes/plugins/**model-providers**/<name>/

and PluginManager doesn't bridge them either: it classifies kind: model-provider and deliberately skips importing it, because provider lifecycle belongs to providers/__init__.py — which wasn't looking where the installer writes.

Verified before the fix:

~/.hermes/plugins/acme-acp/                 -> NOT FOUND
~/.hermes/plugins/model-providers/acme-acp/ -> DISCOVERED

So hermes plugins install reported success, wrote its install metadata, and the provider silently did not exist — hermes -m <it> answered "Unknown provider".

That matters here beyond correctness: an index listing plus one-command install is exactly the distribution path this whole split is for. Without this commit, parts 1 and 2 land and Junie still can't be installed the documented way.

12 new tests. Discovery imports only kind: model-provider from the flat directory; everything else there stays PluginManager's. The negative tests use fixtures that register a provider on import rather than raising — a raising fixture gets swallowed by _import_plugin_dir's except and proves nothing. Caught that on the mutation pass: removing the kind check initially left the suite green.

Regression: tests/providers/ tests/hermes_cli/ tests/agent/ → 12954 passed, 5 failed, all five reproducing on clean upstream (occupied port, launchd/Linux specifics, LSP timing flake).

``create_openai_client`` was a hardcoded if-ladder: copilot-acp builds an ACP
stdio shim, gemini builds a native client, everything else gets an
``openai.OpenAI``. There was no extension point, so a provider whose wire
protocol is not OpenAI-over-HTTP could only be added by editing this function —
which is exactly why an ACP provider cannot ship outside this tree today, even
though ``providers/__init__.py`` has discovered out-of-tree profiles from
``~/.hermes/plugins/model-providers/`` and pip entry points for a while.

``ProviderProfile.create_client(**client_kwargs)`` closes that gap. It returns
``None`` by default, so every provider that wants the standard client is
unaffected and the existing ladder still runs as the fallback. copilot-acp is
migrated onto it — its hardcoded branch is gone and its profile supplies the
client in three lines, which is the same three lines an external package writes.

Resolution goes by provider name first, then by ``base_url`` prefix, so a
runtime configured only by URL still reaches its profile — matching what the
replaced ``startswith("acp://copilot")`` branch did. A profile that raises is
logged and skipped: a third-party plugin can fail to provide a client, but it
cannot take the turn down.

Also replaces the two ``isinstance`` checks in ``agent/auxiliary_client.py``
that mean "this client is complete, do not wrap it" with capability flags the
client class declares — ``HERMES_SKIP_TRANSPORT_WRAP`` and
``HERMES_SKIP_ASYNC_WRAP``, mirroring ``SUPPORTS_HERMES_TOOL_CALLS`` in
``background_review.py``. Two in-tree consumers (the ACP shim and the Gemini
native client), an out-of-tree client is covered by the same declaration, and
the hot path no longer imports those modules just to type-test.

Co-Authored-By: Junie <junie@jetbrains.com>
An external-process provider is an agent CLI Hermes drives over stdio rather
than an HTTP endpoint. Three things about it were spelled out for one vendor,
and each was a hard stop for any other:

* ``resolve_provider()`` gates on ``PROVIDER_REGISTRY``. Its auto-extend from
  ``providers/`` covered api-key providers only, so an external-process profile
  never entered it and ``hermes -m <that provider>`` died with "Unknown
  provider" before a client was ever built.
* ``resolve_runtime_provider()`` keyed the external-process branch on the
  literal ``"copilot-acp"``, so anything else silently fell through to the
  OpenRouter default instead of its own runtime.
* ``resolve_external_process_provider_credentials()`` hardcoded the binary
  (``copilot``), the argv (``--acp --stdio``), the env var names and the
  placeholder api_key — so a third-party provider would have been handed
  another vendor's CLI.

Now the profile carries what only the provider knows — ``process_command``,
``process_args``, ``process_command_env_vars``, ``process_args_env_var`` — and
the three core paths key on ``auth_type == "external_process"`` instead of a
name. copilot-acp's values move into its profile verbatim, so
``HERMES_COPILOT_ACP_COMMAND`` / ``COPILOT_CLI_PATH`` /
``HERMES_COPILOT_ACP_ARGS`` and its ``copilot-acp`` api_key placeholder behave
exactly as before; the new tests assert that alongside the out-of-tree case at
every step.

The error for a missing binary now names the provider and its own env override
instead of telling every user to install GitHub Copilot CLI.

Co-Authored-By: Junie <junie@jetbrains.com>
…ns install`

`hermes plugins install owner/repo` — and the plugin index behind
`hermes plugins search` — clones into `$HERMES_HOME/plugins/<name>/`, flat, one
directory per plugin. Provider discovery only ever scanned
`$HERMES_HOME/plugins/model-providers/<name>/`.

Nothing joined the two. `PluginManager` does not close the gap either: it
classifies `kind: model-provider` and deliberately skips importing it, because
provider lifecycle is owned by `providers/__init__.py` — which was not looking
in the directory the installer writes to.

So the documented install path half-worked. The CLI reported success, wrote its
install metadata, and the provider silently did not exist: `hermes -m <it>` said
"Unknown provider" and `/model` never listed it. Verified before the fix — a
plugin at `~/.hermes/plugins/<name>/` was NOT FOUND while the identical plugin
at `~/.hermes/plugins/model-providers/<name>/` was discovered.

Discovery now also walks the flat directory, importing only entries whose
manifest declares `kind: model-provider`. Everything else there belongs to
`PluginManager`, which owns its lifecycle and consent flow — importing it here
would run third-party code behind its back, so the tests assert we don't (with
fixtures that register on import, since a fixture that merely raised would be
swallowed by `_import_plugin_dir` and prove nothing).

Manifests are parsed with PyYAML when present and a line scan otherwise, so
provider discovery gains no hard dependency; an unreadable manifest is skipped
rather than allowed to blank the registry.

Co-Authored-By: Junie <junie@jetbrains.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant