Pluginify provider/platform/terminal backends

Move provider adapters (anthropic, bedrock, azure), platform adapters
(telegram, slack, discord, feishu, dingtalk, matrix), and terminal backends
(modal, daytona) out of core into plugins/ workspace members. Core references
them via the plugin registries (get_provider_namespace / get_provider_service /
get_tool_provider / get_credential_pool_hook) instead of direct imports.

- Provider/platform/terminal adapters relocated under plugins/; pyproject
  extras reference workspace members; nix variants aggregate per-platform extras.
- Anthropic credential discovery + OAuth-masquerade guard live in the plugin's
  credential_pool_hook; browser-open guarded by _can_open_graphical_browser.
- Vercel AI Gateway + Vercel Sandbox removed (upstream deletion); get_bedrock_model_ids
  removed (replaced by bedrock_model_ids_or_none + discover_bedrock_models).
- Terminal backends resolve ModalEnvironment / DaytonaEnvironment lazily from
  the plugin registry.
- uv.lock regenerated against the pluginified workspace.

Plugin test suites updated for the relocation: imports point at
hermes_agent_<plat>.adapter, caplog logger-name filters and monkeypatch targets
use the new module paths, and credential/rollback tests patch
registries.get_provider_service rather than the removed agent.*_adapter modules.

Verified: zero dead imports of relocated modules in core (import smoke test +
rename-map grep); nix develop succeeds; targeted plugin suites green
(bedrock, anthropic-auxiliary, matrix, dingtalk, feishu, credential_pool,
switch_model_rollback). Remaining full-suite failures are pre-existing on the
pre-merge tree (telegram setUpModule __code__) or environmental (voice/media/
PTY/network-dependent), not introduced here.
This commit is contained in:
ethernet 2026-05-29 09:28:00 -04:00
parent 75cd420b3b
commit 0fce82164a
327 changed files with 12506 additions and 6438 deletions

View file

@ -348,8 +348,7 @@ python -m pytest tests/hermes_cli/test_runtime_provider_resolution.py tests/cli/
For deeper changes, run the full suite before pushing:
```bash
source venv/bin/activate
python -m pytest tests/ -n0 -q
scripts/run_tests.sh
```
## Step 9: Live verification

View file

@ -175,15 +175,16 @@ Scheduler tick → load due jobs from jobs.json
If you are new to the codebase:
1. **This page** — orient yourself
2. **[Agent Loop Internals](./agent-loop.md)** — how AIAgent works
3. **[Prompt Assembly](./prompt-assembly.md)** — system prompt construction
4. **[Provider Runtime Resolution](./provider-runtime.md)** — how providers are selected
5. **[Adding Providers](./adding-providers.md)** — practical guide to adding a new provider
6. **[Tools Runtime](./tools-runtime.md)** — tool registry, dispatch, environments
7. **[Session Storage](./session-storage.md)** — SQLite schema, FTS5, session lineage
8. **[Gateway Internals](./gateway-internals.md)** — messaging platform gateway
9. **[Context Compression & Prompt Caching](./context-compression-and-caching.md)** — compression and caching
10. **[ACP Internals](./acp-internals.md)** — IDE integration
2. **[Plugin Architecture](./plugin-architecture.md)** — how plugins work, workspace layout, registries
3. **[Agent Loop Internals](./agent-loop.md)** — how AIAgent works
4. **[Prompt Assembly](./prompt-assembly.md)** — system prompt construction
5. **[Provider Runtime Resolution](./provider-runtime.md)** — how providers are selected
6. **[Adding Providers](./adding-providers.md)** — practical guide to adding a new provider
7. **[Tools Runtime](./tools-runtime.md)** — tool registry, dispatch, environments
8. **[Session Storage](./session-storage.md)** — SQLite schema, FTS5, session lineage
9. **[Gateway Internals](./gateway-internals.md)** — messaging platform gateway
10. **[Context Compression & Prompt Caching](./context-compression-and-caching.md)** — compression and caching
11. **[ACP Internals](./acp-internals.md)** — IDE integration
## Major Subsystems
@ -229,9 +230,13 @@ Long-running process with 20 platform adapters, unified session routing, user au
### Plugin System
Three discovery sources: `~/.hermes/plugins/` (user), `.hermes/plugins/` (project), and pip entry points. Plugins register tools, hooks, and CLI commands through a context API. Two specialized plugin types exist: memory providers (`plugins/memory/`) and context engines (`plugins/context_engine/`). Both are single-select — only one of each can be active at a time, configured via `hermes plugins` or `config.yaml`.
Plugin-first architecture: every optional capability lives in its own installable Python package under `plugins/` as a uv workspace member. The core codebase (`agent/`, `hermes_cli/`, `gateway/`, `tools/`) **never** imports from a `hermes_agent_*` plugin package directly — plugins register capabilities into typed registries during `register()`, and the core queries those registries at runtime.
→ [Plugin Guide](/guides/build-a-hermes-plugin), [Memory Provider Plugin](./memory-provider-plugin.md)
Registry types: `auth_providers`, `transport_builders`, `platform_adapters`, `tool_providers`, `model_metadata`, `credential_pools` (in `agent/plugin_registries.py`), plus existing specialized registries (`platform_registry`, `tts_registry`, `image_gen_provider`, etc.).
Discovery sources: `~/.hermes/plugins/` (user), `.hermes/plugins/` (project), pip entry points, and uv workspace members. On NixOS, `loadWorkspace` discovers all workspace members from `uv.lock` automatically.
→ [Plugin Architecture](/developer-guide/plugin-architecture), [Plugin Guide](/guides/build-a-hermes-plugin), [Memory Provider Plugin](./memory-provider-plugin)
### Cron
@ -259,7 +264,7 @@ Generates ShareGPT-format trajectories from agent sessions for training data gen
| **Observable execution** | Every tool call is visible to the user via callbacks. Progress updates in CLI (spinner) and gateway (chat messages). |
| **Interruptible** | API calls and tool execution can be cancelled mid-flight by user input or signals. |
| **Platform-agnostic core** | One AIAgent class serves CLI, gateway, ACP, batch, and API server. Platform differences live in the entry point, not the agent. |
| **Loose coupling** | Optional subsystems (MCP, plugins, memory providers, RL environments) use registry patterns and check_fn gating, not hard dependencies. |
| **Loose coupling** | Optional subsystems (MCP, plugins, memory providers, RL environments) use registry patterns and check_fn gating, not hard dependencies. Core never imports from `hermes_agent_*` plugin packages — it queries typed registries in `agent/plugin_registries.py`. |
| **Profile isolation** | Each profile (`hermes -p <name>`) gets its own HERMES_HOME, config, memory, sessions, and gateway PID. Multiple profiles run concurrently. |
## File Dependency Chain

View file

@ -81,7 +81,7 @@ hermes chat -q "Hello"
### Run Tests
```bash
pytest tests/ -v
scripts/run_tests.sh
```
## Code Style
@ -185,7 +185,7 @@ refactor/description # Code restructuring
### Before Submitting
1. **Run tests**: `pytest tests/ -v`
1. **Run tests**: `scripts/run_tests.sh` (same as CI — hermetic env + per-file process isolation)
2. **Test manually**: Run `hermes` and exercise the code path you changed
3. **Check cross-platform impact**: Consider macOS and different Linux distros
4. **Keep PRs focused**: One logical change per PR

View file

@ -0,0 +1,360 @@
---
sidebar_position: 2
title: "Plugin Architecture"
description: "How the plugin system works — workspace layout, capability registries, dependency isolation, and the hermetic core boundary"
---
# Plugin Architecture
Since v0.14, Hermes Agent is built on a **plugin-first architecture**: every
optional capability — model providers, platform adapters, TTS/STT, terminal
backends, image generation — lives in its own installable Python package under
`plugins/`. The core codebase (`agent/`, `hermes_cli/`, `gateway/`, `tools/`)
**never** imports from a plugin package directly. Instead, plugins register
their capabilities into typed registries during `register()`, and the core
queries those registries at runtime.
This page covers the structural design. For the user-facing guide to creating
plugins, see [Build a Hermes Plugin](/guides/build-a-hermes-plugin). For
enabling/disabling plugins, see [Plugins](/user-guide/features/plugins).
## Why everything is a plugin
Before v0.14, optional capabilities were wired into core with
`tools/lazy_deps.py` — a runtime `pip install` helper called `ensure()`. On
NixOS (and any sealed-venv environment), `ensure()` can't work because the
venv is immutable at build time. The old design also meant:
- **Single source of truth was split** — deps were declared in `pyproject.toml`
extras AND in `LAZY_DEPS` dicts inside plugin code.
- **Core was coupled to plugins** — `from hermes_agent_bedrock import
has_aws_credentials` in `hermes_cli/auth_commands.py` meant adding a new
provider required editing core files.
- **Testing was fragile**`ensure()` mocking was complex and tests regularly
passed locally but failed in CI (or vice versa) because of venv state leaks.
The plugin-first architecture fixes all three:
| Problem | Fix |
|---------|-----|
| `ensure()` doesn't work on NixOS | Dependencies are installed by the package manager. No runtime `pip install`. |
| Dual source of truth for deps | Each plugin's `pyproject.toml` is the **only** place its deps are declared. |
| Core imports plugins directly | Core queries typed registries. Plugins register themselves. |
| Flaky `ensure()` tests | Gone. If a plugin isn't installed, `ImportError` — same as any Python package. |
## Workspace layout
All plugin packages live under `plugins/` as members of a
[uv workspace](https://docs.astral.sh/uv/concepts/workspaces/). Each plugin
is a standard Python package with its own `pyproject.toml`:
```
plugins/
├── model-providers/
│ ├── anthropic/
│ │ ├── pyproject.toml # package: hermes-agent-anthropic
│ │ ├── plugin.yaml # directory-scanner manifest (dev mode)
│ │ └── hermes_agent_anthropic/
│ │ ├── __init__.py # register(), public re-exports
│ │ ├── adapter.py # Anthropic-specific client building
│ │ └── ...
│ ├── bedrock/
│ │ ├── pyproject.toml # package: hermes-agent-bedrock
│ │ └── hermes_agent_bedrock/
│ │ └── ...
│ └── azure-foundry/
│ ├── pyproject.toml # package: hermes-agent-azure
│ └── hermes_agent_azure/
│ └── ...
├── platforms/
│ ├── telegram/
│ │ ├── pyproject.toml # package: hermes-agent-telegram
│ │ └── hermes_agent_telegram/
│ │ └── ...
│ ├── slack/
│ ├── discord/
│ ├── feishu/
│ ├── dingtalk/
│ └── matrix/
├── tts/
│ ├── pyproject.toml # package: hermes-agent-tts
│ └── hermes_agent_tts/
├── stt/
│ ├── pyproject.toml # package: hermes-agent-stt
│ └── hermes_agent_stt/
├── image_gen/
│ └── fal_pkg/
│ ├── pyproject.toml # package: hermes-agent-fal
│ └── hermes_agent_fal/
├── terminals/
│ ├── daytona/
│ ├── modal/
│ └── vercel/
└── ...
```
The root `pyproject.toml` declares the workspace:
```toml
[tool.uv.workspace]
members = [
"plugins/model-providers/anthropic",
"plugins/model-providers/bedrock",
"plugins/model-providers/azure-foundry",
"plugins/platforms/telegram",
"plugins/platforms/slack",
# ... all 21 workspace members
]
```
And each plugin depends on the main `hermes-agent` package for shared
utilities:
```toml
# plugins/platforms/telegram/pyproject.toml
[project]
name = "hermes-agent-telegram"
dependencies = [
"hermes-agent",
"python-telegram-bot>=22.0",
]
[tool.uv.sources]
hermes-agent = { workspace = true }
```
### Single source of truth for dependencies
A plugin's `pyproject.toml` is the **only** place its runtime dependencies are
declared. The root `pyproject.toml` maps extras to workspace members:
```toml
[project.optional-dependencies]
telegram = ["hermes-agent-telegram"]
slack = ["hermes-agent-slack"]
anthropic = ["hermes-agent-anthropic"]
all = [
"hermes-agent-telegram",
"hermes-agent-slack",
"hermes-agent-anthropic",
# ... all plugins
]
```
When you `uv sync --extra telegram`, uv resolves the workspace member
`hermes-agent-telegram` and installs it (with its own deps) into the venv.
There is no `LAZY_DEPS` dict, no `ensure()`, no duplicate pin lists. The
`pyproject.toml` is the truth; `uv.lock` is the resolution.
## The hermetic core boundary
The core codebase (`agent/`, `hermes_cli/`, `gateway/`, `tools/`) must never
import from a `hermes_agent_*` plugin package. This is enforced by convention
and should be checked in CI.
### How core accesses plugin capabilities
Instead of direct imports, the core queries **typed registries** in
`agent/plugin_registries.py`:
```python
# ❌ OLD — core directly imports plugin
from hermes_agent_bedrock import has_aws_credentials
# ✅ NEW — core queries the registry
from agent.plugin_registries import registries
bedrock_auth = registries.get_auth_provider("bedrock")
if bedrock_auth and bedrock_auth.provider.has_credentials():
...
```
### Registry types
| Registry | What it stores | Populated by | Queried by |
|----------|---------------|---------------|------------|
| `auth_providers` | Auth check/resolve functions | Model-provider plugins | `hermes_cli/auth.py`, `auth_commands.py`, `doctor.py` |
| `transport_builders` | Client builders + message converters | Model-provider plugins | `agent/transports/`, `auxiliary_client.py` |
| `platform_adapters` | Adapter classes + `check_requirements()` | Platform plugins | `gateway/run.py`, `tools/send_message_tool.py` |
| `tool_providers` | Tool functions + constants | TTS, STT, FAL, terminal plugins | `tools/voice_mode.py`, `image_generation_tool.py`, `terminal_tool.py` |
| `model_metadata` | Context lengths, model IDs, betas | Model-provider plugins | `agent/model_metadata.py`, `hermes_cli/models.py` |
| `credential_pools` | Credential read/write/refresh | Model-provider plugins | `agent/credential_pool.py` |
Each registry entry is a dataclass or protocol instance with well-typed fields.
The `PluginRegistries` singleton lives at `agent.plugin_registries.registries`.
### Plugin registration
Each plugin's `register(ctx)` function populates the registries:
```python
# plugins/model-providers/bedrock/hermes_agent_bedrock/__init__.py
def register(ctx):
from agent.plugin_registries import AuthProviderEntry, ModelMetadataEntry
ctx.register_auth_provider(
name="bedrock",
provider=BedrockAuthProvider(),
cli_group="AWS / Bedrock",
)
ctx.register_model_metadata(ModelMetadataEntry(
name="bedrock",
list_models=bedrock_model_ids_or_none,
get_context_length=get_bedrock_context_length,
))
```
The `PluginContext` (`hermes_cli/plugins.py`) delegates each
`register_*()` call to the matching method on the global `PluginRegistries`
singleton. This keeps the existing PluginManager lifecycle intact — plugins
are still discovered and loaded the same way, they just register into more
registries.
### Existing specialized registries
Some plugin categories already had registries before the refactor. These
continue to work alongside the new generic registries:
| Registry | Module | Used by |
|----------|--------|---------|
| `platform_registry` | `gateway/platform_registry.py` | `ctx.register_platform()` |
| `tts_registry` | `agent/tts_registry.py` | `ctx.register_tts_provider()` |
| `transcription_registry` | `agent/transcription_registry.py` | `ctx.register_transcription_provider()` |
| `image_gen_provider` | `agent/image_gen_provider.py` | `ctx.register_image_gen_provider()` |
| `video_gen_provider` | `agent/video_gen_provider.py` | `ctx.register_video_gen_provider()` |
| `context_engine` | `agent/context_engine.py` | `ctx.register_context_engine()` |
| `memory_manager` | `agent/memory_manager.py` | `MemoryProvider` subclasses |
The new `plugin_registries` module covers the capabilities that **didn't** have
a registry before: auth, transport building, model metadata, credential
pooling, and tool-provider registration.
## Plugin discovery
Plugins are discovered through **three** mechanisms (same as before the
refactor, but now with workspace awareness):
1. **Directory scanner** — scans `plugins/` (bundled), `~/.hermes/plugins/`
(user), `.hermes/plugins/` (project) for directories with `plugin.yaml`.
This is the primary path for dev-mode and for user-installed plugins.
2. **Entry points** — packages that declare
`[project.entry-points."hermes_agent.plugins"]` in their `pyproject.toml`.
This is the primary path for `pip install`-ed plugins and for NixOS
installs where the venv already contains the installed packages.
3. **uv workspace members** — the 21 builtin plugins are workspace members,
so `uv sync --extra <name>` installs them into the venv. At runtime, the
entry-point scanner finds them because each plugin declares the
`hermes_agent.plugins` entry point in its `pyproject.toml`.
On NixOS, `loadWorkspace` discovers all workspace members from `uv.lock`
automatically, and `mkVirtualEnv { hermes-agent = ["all"] }` installs all
plugin packages as transitive deps.
## Building and publishing
### Dev / source installs
```bash
uv sync --all-extras # install all plugins + their deps
uv sync --extra telegram # install just the telegram plugin
```
### Wheel publishing (custom build backend)
The root `pyproject.toml` uses a custom PEP 517 build backend
(`_build_backend.py`) that wraps `setuptools.build_meta`. At wheel build time
it:
1. Reads each plugin's `pyproject.toml` from the workspace.
2. Inlines the plugin's runtime dependencies into the corresponding
`[project.optional-dependencies]` extra.
3. Delegates to `setuptools` to build the wheel.
This means the published wheel has `telegram = ["python-telegram-bot>=22.0",
...]` instead of `telegram = ["hermes-agent-telegram"]` — because the
individual plugin packages aren't on PyPI.
Source installs and NixOS use workspace resolution directly and never hit the
build-backend rewrite path.
### NixOS
```nix
services.hermes-agent = {
enable = true;
# All plugins are included by default via "all" extra.
# Select specific plugins with:
extraDependencyGroups = [ "telegram" "anthropic" ];
};
```
`loadWorkspace` discovers all workspace members from `uv.lock`. No structural
changes to the Nix files are needed — the existing `mkVirtualEnv` + `extraDependencyGroups`
mechanism already handles it.
## Tests
Plugin test files live in the plugin's own `tests/` directory:
```
plugins/platforms/telegram/
├── tests/
│ ├── conftest.py
│ ├── test_telegram_format.py
│ └── ...
└── hermes_agent_telegram/
└── ...
```
The test runner (`scripts/run_tests_parallel.py`) discovers tests under both
`tests/` (core) and `plugins/` (plugins). The root `conftest.py` provides
shared fixtures for both.
Running a plugin's tests requires the plugin to be installed:
```bash
uv sync --extra telegram
scripts/run_tests.sh plugins/platforms/telegram/tests/
```
If the plugin isn't installed, its tests fail with `ModuleNotFoundError`
which is correct. You can't run telegram tests without the telegram package.
## Migration checklist (for adding a new plugin)
When a new optional capability is added to Hermes:
1. **Create a plugin package** under `plugins/<category>/<name>/` with:
- `pyproject.toml` (name, version, deps, entry point declaration)
- `plugin.yaml` (for directory-scanner discovery in dev)
- `hermes_agent_<name>/__init__.py` with `register(ctx)`
- `hermes_agent_<name>/tests/` for plugin-specific tests
2. **Add to workspace** — add the directory to `[tool.uv.workspace].members`
and `[tool.uv.sources]` in the root `pyproject.toml`.
3. **Add an extra** — add `name = ["hermes-agent-<name>"]` to
`[project.optional-dependencies]` and include it in `all`.
4. **Register capabilities** — in `register(ctx)`, call the appropriate
`ctx.register_*()` methods to populate the typed registries.
5. **No core edits** — the core code should not need to change. If it does,
that's a sign the registry surface is incomplete and needs a new
`register_*()` method on `PluginContext`.
6. **Run `uv lock`** — resolve the new workspace member.
7. **Add NixOS support** — if the plugin has native deps, add an override
in `nix/python.nix`. Otherwise `loadWorkspace` handles it automatically.
## The rule
> **If it can be a plugin, it must be a plugin.**
Adding optional capabilities to core files is a code review rejection. If the
plugin surface doesn't support what you need, extend the surface (new
registry type, new hook, new `ctx` method) — don't inline the capability.