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

@ -0,0 +1,160 @@
#!/usr/bin/env python3
"""Check that core code and core tests never import from plugin packages.
Core (agent/, hermes_cli/, tools/, run_agent.py, etc.) must interact with
plugins exclusively through the registry layer (``registries.get_provider_service``,
``registries.register_*``). Direct imports from ``hermes_agent_*`` packages
couple core to plugin internals and break the plugin isolation boundary.
Allowed locations for plugin imports:
- ``plugins/`` (the plugin packages themselves)
- ``tests/plugins/`` (plugin-specific tests)
- ``tests/e2e/`` (end-to-end integration tests that load the full system)
- ``tests/gateway/`` (gateway integration tests)
- ``tests/tools/`` (tool integration tests these test plugin-provided tools)
- ``hermes_cli/plugins.py`` (the plugin loader itself it MUST import plugins)
Exit 0 on success, 1 on violation.
"""
from __future__ import annotations
import re
import sys
from pathlib import Path
# ── Configuration ────────────────────────────────────────────────────────────
ROOT = Path(__file__).resolve().parent.parent
# Directories where ``from hermes_agent_* import`` / ``import hermes_agent_*``
# is FORBIDDEN.
FORBIDDEN_DIRS: list[Path] = [
ROOT / "agent",
ROOT / "hermes_cli",
ROOT / "tools",
ROOT / "cron",
ROOT / "gateway",
ROOT / "acp_adapter",
ROOT / "tui_gateway",
ROOT / "ui-tui", # Ink/TUI frontend
ROOT / "batch_runner.py",
ROOT / "run_agent.py",
ROOT / "model_tools.py",
ROOT / "cli.py",
ROOT / "toolsets.py",
]
# Directories where plugin imports are ALLOWED (no check).
ALLOWED_DIRS: list[Path] = [
ROOT / "plugins",
ROOT / "tests" / "plugins",
ROOT / "tests" / "e2e",
ROOT / "tests" / "gateway",
ROOT / "tests" / "tools",
]
# Specific files where plugin imports are allowed even inside a forbidden dir.
ALLOWED_FILES: set[str] = {
# The plugin loader itself must import plugin packages.
"hermes_cli/plugins.py",
}
# Regex matching a plugin import line.
PLUGIN_IMPORT_RE = re.compile(
r'^\s*(?:from|import)\s+hermes_agent_\w+',
re.MULTILINE,
)
# ── Implementation ──────────────────────────────────────────────────────────
def _is_in_allowed_dir(path: Path) -> bool:
"""Return True if *path* is inside an allowed directory."""
for allowed in ALLOWED_DIRS:
try:
path.relative_to(allowed)
return True
except ValueError:
pass
return False
def _is_allowed_file(path: Path) -> bool:
"""Return True if *path* is an explicitly allowed file."""
rel = str(path.relative_to(ROOT))
return rel in ALLOWED_FILES
def _check_file(path: Path) -> list[tuple[int, str]]:
"""Return list of (line_number, line_content) for violating lines."""
try:
text = path.read_text(encoding="utf-8")
except (OSError, UnicodeDecodeError):
return []
violations: list[tuple[int, str]] = []
for i, line in enumerate(text.splitlines(), start=1):
if PLUGIN_IMPORT_RE.match(line):
# Allow noqa comments (F401-style) or explicit "noqa: plugin-import"
if "noqa" in line:
continue
violations.append((i, line.strip()))
return violations
def main() -> int:
all_violations: list[tuple[Path, int, str]] = []
# Check individual files at root level
for path in FORBIDDEN_DIRS:
if path.is_file() and path.suffix == ".py":
if _is_allowed_file(path):
continue
for lineno, line in _check_file(path):
all_violations.append((path, lineno, line))
# Check directories
for dir_path in FORBIDDEN_DIRS:
if not dir_path.is_dir():
continue
for py_file in dir_path.rglob("*.py"):
if _is_in_allowed_dir(py_file):
continue
if _is_allowed_file(py_file):
continue
for lineno, line in _check_file(py_file):
all_violations.append((py_file, lineno, line))
# Check tests/agent/ and tests/agent/transports/ specifically
# (these are core unit tests that must NOT import plugins)
for test_dir in [
ROOT / "tests" / "agent",
ROOT / "tests" / "agent" / "transports",
]:
if not test_dir.is_dir():
continue
for py_file in test_dir.rglob("*.py"):
if _is_in_allowed_dir(py_file):
continue
if _is_allowed_file(py_file):
continue
for lineno, line in _check_file(py_file):
all_violations.append((py_file, lineno, line))
if not all_violations:
print("✓ No plugin imports found in core code or core tests")
return 0
print("✗ Plugin imports found in core code or core tests:\n")
for path, lineno, line in sorted(all_violations):
rel = path.relative_to(ROOT)
print(f" {rel}:{lineno}: {line}")
print(
f"\n{len(all_violations)} violation(s). Core must interact with plugins "
"through the registry layer, not direct imports."
)
return 1
if __name__ == "__main__":
sys.exit(main())

View file

@ -24,11 +24,11 @@
#
# Everything after a literal '--' is passed through to each per-file
# pytest invocation. Positional path arguments before '--' override
# the default discovery root (tests/).
# the default discovery root (tests/ + plugins/).
set -euo pipefail
# ── Locate repo root ────────────────────────────────────────────────────────
# ── Locate repo root ────────────────────────────────────────────────────────l
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"

View file

@ -30,7 +30,7 @@ Usage:
Environment:
HERMES_TEST_WORKERS Override worker count (default: os.cpu_count())
HERMES_TEST_PATHS Override discovery roots (colon-sep, default: 'tests')
HERMES_TEST_PATHS Override discovery roots (colon-sep, default: 'tests:plugins')
Exit code: 0 if every file's pytest exited 0; 1 otherwise.
"""
@ -50,7 +50,7 @@ from typing import Dict, List, Tuple
# Default test discovery roots.
_DEFAULT_ROOTS = ["tests"]
_DEFAULT_ROOTS = ["tests", "plugins"]
# Directories to skip during discovery — these suites require real
# external services (a model gateway, a docker daemon with a prebuilt
@ -286,6 +286,7 @@ def _run_one_file(
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
text=True,
env={**os.environ, "HERMES_PARALLEL_RUNNER": "1"},
# POSIX: place the child at the head of its own process group so
# _kill_tree can SIGKILL the group atomically.
# Windows: this maps to CREATE_NEW_PROCESS_GROUP in CPython 3.12+;